diff --git a/changelog.d/1424.change.md b/changelog.d/1424.change.md new file mode 100644 index 000000000..87b5dc2f2 --- /dev/null +++ b/changelog.d/1424.change.md @@ -0,0 +1 @@ +`attrs.make_class()` now works when its `attrs` argument is a dict of existing `attrs.Attribute` instances (e.g. from `attrs.fields(cls)`), not just fresh `attr.ib()` / `attrs.field()` call diff --git a/changelog.d/evolve-accept-attr-name.change.md b/changelog.d/evolve-accept-attr-name.change.md new file mode 100644 index 000000000..f92ebfa53 --- /dev/null +++ b/changelog.d/evolve-accept-attr-name.change.md @@ -0,0 +1 @@ +`attrs.evolve()` now accepts an attribute's underlying name (e.g. `_x` for a field with alias `x`) in addition to the alias itself, matching the way the generated `__init__` works when the alias is the same as the auto-derived public name. Previously, passing `_x=...` to `evolve()` on a class with a field `attr.ib(alias='x')` would raise `TypeError: __init__() got an unexpected keyword argument '_x'`, even though the diff --git a/src/attr/_make.py b/src/attr/_make.py index 793bfd89d..66b98d19b 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -636,7 +636,17 @@ def evolve(*args, **changes): continue attr_name = a.name # To deal with private attributes. init_name = a.alias - if init_name not in changes: + # Accept either the init alias or the (possibly name-mangled) + # attribute name. The alias is the public way to address a + # field, but if a caller uses the attribute's underlying name + # we should still recognize it rather than crash with a + # ``got an unexpected keyword argument`` TypeError from + # ``__init__``. + if init_name in changes: + continue + if attr_name in changes: + changes[init_name] = changes.pop(attr_name) + else: changes[init_name] = getattr(inst, attr_name) return cls(**changes) @@ -2570,6 +2580,36 @@ def from_counting_attr( elif ca.type is not None: msg = f"Type annotation and type argument cannot both be present for '{name}'." raise ValueError(msg) + + # `from_counting_attr` is also called with already-built `Attribute` + # instances (e.g. when a class is reconstructed from + # `attrs.fields(cls)` via `attrs.make_class`, see #1424). In that case + # `ca` is an `Attribute` and exposes the public `default`/`validator`/ + # `converter` attributes rather than the `_default`/`_validator`/ + # `_converter` internals of `_CountingAttr`. + if isinstance(ca, Attribute): + return cls( + name, + ca.default, + ca.validator, + ca.repr, + None, + ca.hash, + ca.init, + False, + ca.metadata, + type, + ca.converter, + kw_only if ca.kw_only is None else ca.kw_only, + ca.eq, + ca.eq_key, + ca.order, + ca.order_key, + ca.on_setattr, + ca.alias, + ca.alias_is_default, + ) + return cls( name, ca._default, diff --git a/src/attr/converters.py b/src/attr/converters.py index 0a79deef0..543aff0ff 100644 --- a/src/attr/converters.py +++ b/src/attr/converters.py @@ -135,6 +135,7 @@ def to_bool(val): - ``"on"`` - ``"1"`` - ``1`` + - bytes/bytearray whose ASCII-decoded value matches any of the strings above Values mapping to `False`: @@ -144,12 +145,26 @@ def to_bool(val): - ``"off"`` - ``"0"`` - ``0`` + - bytes/bytearray whose ASCII-decoded value matches any of the strings above Raises: ValueError: For any other value. + .. versionchanged:: 26.2 + bytes/bytearray inputs are decoded as ASCII before lookup, so values + read from environment variables as bytes (e.g. on Windows or from + ``os.environb``) work without a separate ``.decode("ascii")`` step at + the call site. + .. versionadded:: 21.3.0 """ + if isinstance(val, (bytes, bytearray)): + try: + val = val.decode("ascii") + except UnicodeDecodeError as e: + msg = f"Cannot convert value to bool: {val!r}" + raise ValueError(msg) from e + if isinstance(val, str): val = val.lower() diff --git a/tests/test_converters.py b/tests/test_converters.py index 5726ae210..5f2d0b092 100644 --- a/tests/test_converters.py +++ b/tests/test_converters.py @@ -364,3 +364,61 @@ def test_falsy(self): assert not to_bool("f") assert not to_bool("no") assert not to_bool("off") + + @pytest.mark.parametrize( + "value", [b"true", b"t", b"yes", b"y", b"on", b"1"] + ) + def test_truthy_bytes(self, value): + """ + Bytes values that decode to a truthy keyword match the str truthy path. + """ + assert to_bool(value) is True + + @pytest.mark.parametrize( + "value", [b"false", b"f", b"no", b"n", b"off", b"0"] + ) + def test_falsy_bytes(self, value): + """ + Bytes values that decode to a falsy keyword match the str falsy path. + """ + assert to_bool(value) is False + + @pytest.mark.parametrize("value", [b"TRUE", b"Yes", b"OFF", b"1"]) + def test_bytes_are_lowercased(self, value): + """ + Bytes inputs follow the same case-insensitive lookup as str inputs. + """ + assert to_bool(value) is bool( + value.decode("ascii").lower() + in {"true", "t", "yes", "y", "on", "1"} + ) + + def test_bytearray_truthy(self): + """ + bytearray is a bytes subclass and should be decoded the same way. + """ + assert to_bool(bytearray(b"true")) is True + assert to_bool(bytearray(b"off")) is False + + def test_bytes_non_ascii_raises(self): + """ + Bytes that are not valid ASCII raise ValueError with the same shape as + other unconvertible values. + """ + with pytest.raises(ValueError, match="Cannot convert value to bool"): + to_bool(b"\xfftrue") + + def test_bytes_unknown_keyword_raises(self): + """ + Bytes that decode to a value not in either keyword set still raise. + """ + with pytest.raises(ValueError, match="Cannot convert value to bool"): + to_bool(b"maybe") + + def test_bytes_empty_raises(self): + """ + An empty bytes value is not a valid keyword, so it raises the same + error as an empty string would. + """ + with pytest.raises(ValueError, match="Cannot convert value to bool"): + to_bool(b"") diff --git a/tests/test_funcs.py b/tests/test_funcs.py index b254e9898..c7b13af9e 100644 --- a/tests/test_funcs.py +++ b/tests/test_funcs.py @@ -740,6 +740,9 @@ class C: def test_private(self): """ evolve() acts as `__init__` with regards to private attributes. + The public alias is the canonical way to address a field, but the + underlying attribute name is also accepted, mirroring the way + ``__init__`` accepts the alias. """ @attr.s @@ -748,8 +751,8 @@ class C: assert evolve(C(1), a=2)._a == 2 - with pytest.raises(TypeError): - evolve(C(1), _a=2) + # Addressing the field by its underlying name is also accepted. + assert evolve(C(1), _a=2)._a == 2 with pytest.raises(TypeError): evolve(C(1), a=3, _a=2) diff --git a/tests/test_make.py b/tests/test_make.py index f83f85234..f932a634d 100644 --- a/tests/test_make.py +++ b/tests/test_make.py @@ -1535,6 +1535,50 @@ def test_make_class_ordered(self): assert "C(a=1, b=2)" == repr(C()) + def test_make_class_from_attrs_fields(self): + """ + make_class can rebuild a class from attrs.fields() output, not only + fresh _CountingAttr / attr.ib() values. + + https://github.com/python-attrs/attrs/issues/1424 + """ + + @attr.s + class Source: + a = attr.ib(type=int) + b = attr.ib( + default="x", validator=attr.validators.instance_of(str) + ) + c = attr.ib( + default=0, + converter=attr.converters.optional(int), + ) + + # Convert the source's _CountingAttr-derived fields into proper + # Attribute instances via make_class, then re-run make_class on those + # Attribute instances. The second pass previously failed with + # "'Attribute' object has no attribute '_default'". + rebuilt = attr.make_class( + "Rebuilt", + {f.name: f for f in attr.fields(Source)}, + ) + + # Order is preserved end-to-end. + assert ["a", "b", "c"] == [a.name for a in attr.fields(rebuilt)] + + # `a` has no default and the rest do, so call with positional a and + # rely on defaults to fill b/c. `7` for c is converted via int. + ri = rebuilt(5) + assert 5 == ri.a + assert "x" == ri.b + assert 0 == ri.c + + ri = rebuilt(5, "hello") + assert "hello" == ri.b + + ri = rebuilt(5, "hello", "7") + assert 7 == ri.c + def test_generic_dynamic_class(self): """ make_class can create generic dynamic classes.