Skip to content
Closed
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
3 changes: 2 additions & 1 deletion src/attr/_next_gen.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ def define(
field_transformer=None,
match_args=True,
force_kw_only=False,
collect_by_mro=True,
):
r"""
A class decorator that adds :term:`dunder methods` according to
Expand Down Expand Up @@ -381,7 +382,7 @@ def do_it(cls, auto_attribs):
eq=eq,
order=order,
auto_detect=auto_detect,
collect_by_mro=True,
collect_by_mro=collect_by_mro,
getstate_setstate=getstate_setstate,
on_setattr=on_setattr,
field_transformer=field_transformer,
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"")
89 changes: 89 additions & 0 deletions tests/test_next_gen.py
Original file line number Diff line number Diff line change
Expand Up @@ -581,3 +581,92 @@ def test_inspect_not_attrs_class():
"""
with pytest.raises(attrs.exceptions.NotAnAttrsClassError):
attrs.inspect(object)


class TestDefineCollectByMro:
"""
`attrs.define` documents a *collect_by_mro* option but the function
signature used to silently hard-code `True`, so the parameter was
unreachable. Adding it as a real parameter (and forwarding it to the
inner `attrs()` call) is what these tests exercise.
"""

def test_define_accepts_collect_by_mro_kwarg(self):
"""
@attrs.define(collect_by_mro=False) must not raise
TypeError: define() got an unexpected keyword argument 'collect_by_mro'.
"""

@attrs.define(collect_by_mro=False)
class A:
x: int = 1

assert A(1).x == 1

def test_define_collect_by_mro_false_matches_attrs_false(self):
"""
With collect_by_mro=False, the same diamond inheritance graph that
`attr.s(collect_by_mro=False)` would produce must match what
@attrs.define(collect_by_mro=False) produces (same field names, same
order, same defaults).
"""

@_attr.s(collect_by_mro=False)
class A_attr:
a1 = _attr.ib(default="a1")
a2 = _attr.ib(default="a2")

@_attr.s
class B_attr(A_attr):
b1 = _attr.ib(default="b1")
b2 = _attr.ib(default="b2")

@_attr.s
class C_attr(B_attr, A_attr):
c1 = _attr.ib(default="c1")
c2 = _attr.ib(default="c2")

@attrs.define(collect_by_mro=False)
class A_define:
a1: str = "a1"
a2: str = "a2"

@attrs.define
class B_define(A_define):
b1: str = "b1"
b2: str = "b2"

@attrs.define
class C_define(B_define, A_define):
c1: str = "c1"
c2: str = "c2"

attr_names = [a.name for a in _attr.fields(C_attr)]
define_names = [a.name for a in attrs.fields(C_define)]
assert (
attr_names
== define_names
== [
"a1",
"a2",
"b1",
"b2",
"c1",
"c2",
]
)

def test_define_collect_by_mro_default_is_true(self):
"""
Without the kwarg, define() should default to collect_by_mro=True
(matching the behavior of `attr.s` and the original hard-coded
True). This is a regression guard for the default.
"""

@attrs.define
class Base:
x: int = 1

# A class with no inheritance works either way; the test
# confirms define() with the default kwarg still works.
assert Base(1).x == 1