Skip to content
Open
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/1577.change.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
`astuple()` now applies its *filter* callable to attrs instances that are nested inside dict values.
4 changes: 4 additions & 0 deletions src/attr/_funcs.py
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,8 @@ def astuple(
(
astuple(
kk,
recurse=True,
filter=filter,
tuple_factory=tuple_factory,
retain_collection_types=retain,
)
Expand All @@ -333,6 +335,8 @@ def astuple(
(
astuple(
vv,
recurse=True,
filter=filter,
tuple_factory=tuple_factory,
retain_collection_types=retain,
)
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"")
30 changes: 30 additions & 0 deletions tests/test_funcs.py
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,36 @@ def test_dicts_retain_type(self, container, C):
C(1, container({"a": C(4, 5)})), retain_collection_types=True
)

@pytest.mark.parametrize(
"container", MAPPING_TYPES, ids=lambda c: c.__name__
)
def test_dicts_filter_applies_to_values(self, container, C):
"""
astuple's *filter* is applied to attrs instances nested as dict
values, not silently dropped. Previously the dict-handling branch
recursed into nested attrs instances without forwarding *filter*
(or *recurse*), so a user-supplied filter had no effect on attrs
instances stored inside a dict value.
"""

@attr.s
class Inner:
x = attr.ib()
a = attr.ib()
b = attr.ib()

def keep_b_inner_only(attribute, value):
# Outer C has x/y; only drop x from Inner, keep b.
return attribute.name != "a"

result = astuple(
C(1, container({"k": Inner(x="X", a="A", b="B")})),
filter=keep_b_inner_only,
retain_collection_types=True,
)
# outer C keeps x=1 and y=dict; inner drops 'a', keeps x='X' and b='B'.
assert (1, container({"k": ("X", "B")})) == result

@given(simple_classes(), st.sampled_from(SEQUENCE_TYPES))
def test_roundtrip(self, cls, tuple_class):
"""
Expand Down