Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/1424.change.md
Original file line number Diff line number Diff line change
@@ -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
30 changes: 30 additions & 0 deletions src/attr/_make.py
Original file line number Diff line number Diff line change
Expand Up @@ -2570,6 +2570,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,
Expand Down
15 changes: 15 additions & 0 deletions src/attr/converters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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`:

Expand All @@ -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()

Expand Down
58 changes: 58 additions & 0 deletions tests/test_converters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"")
44 changes: 44 additions & 0 deletions tests/test_make.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down