From 9cf1e261980b4a8bae0b61e7ee3586ad70be87b3 Mon Sep 17 00:00:00 2001 From: Zo Bot Date: Sat, 27 Jun 2026 03:52:48 +0000 Subject: [PATCH 1/4] decode bytes/bytearray inputs in to_bool os.environb yields bytes on POSIX and the documented use case for to_bool is reading values out of environment variables. The truthy and falsy lookup tuples only contain str and int, so a bytes('true') input raised 'Cannot convert value to bool: b\'true\'' even though its ASCII-decoded value is the canonical truthy literal. Decode bytes and bytearray as ASCII before the lowercase + lookup step, matching the str path. Non-ASCII bytes raise ValueError ('Cannot convert value to bool: ...') the same way an unknown string does, since the decoded text would still not match any known literal. Unknown byte values raise the same ValueError. --- src/attr/converters.py | 15 +++++++++++ tests/test_converters.py | 54 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+) 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..7fa631df4 100644 --- a/tests/test_converters.py +++ b/tests/test_converters.py @@ -364,3 +364,57 @@ 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"") From 12b993b587a57a50d2c1ef8a6b2b604543507f80 Mon Sep 17 00:00:00 2001 From: Zo Bot Date: Mon, 29 Jun 2026 21:34:12 +0000 Subject: [PATCH 2/4] astuple: forward filter and recurse into attrs nested in dict values The dict-handling branch in attrs.astuple recursively serialized attrs instances found as keys and values but only passed tuple_factory and retain_collection_types, dropping both recurse and filter. A user filter that was meant to drop fields from a nested attrs class therefore had no effect when the instance lived inside a dict, while the parallel list branch a few lines above did forward both. Pass recurse=True and filter=filter to the recursive astuple calls so the documented filter contract holds for dicts as well, and add a regression test in TestAsTuple covering dict and OrderedDict containers with a filter that drops a field on the nested attrs class. --- src/attr/_funcs.py | 4 ++++ tests/test_funcs.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/src/attr/_funcs.py b/src/attr/_funcs.py index 1adb50021..e903a1674 100644 --- a/src/attr/_funcs.py +++ b/src/attr/_funcs.py @@ -324,6 +324,8 @@ def astuple( ( astuple( kk, + recurse=True, + filter=filter, tuple_factory=tuple_factory, retain_collection_types=retain, ) @@ -333,6 +335,8 @@ def astuple( ( astuple( vv, + recurse=True, + filter=filter, tuple_factory=tuple_factory, retain_collection_types=retain, ) diff --git a/tests/test_funcs.py b/tests/test_funcs.py index b254e9898..7d3034874 100644 --- a/tests/test_funcs.py +++ b/tests/test_funcs.py @@ -426,6 +426,34 @@ 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): """ From 17d0bfc9b349d212de949f8758eb2af47e945429 Mon Sep 17 00:00:00 2001 From: Zo Bot Date: Mon, 29 Jun 2026 21:34:44 +0000 Subject: [PATCH 3/4] add changelog entry for #1577 --- changelog.d/1577.change.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/1577.change.md diff --git a/changelog.d/1577.change.md b/changelog.d/1577.change.md new file mode 100644 index 000000000..34761fca4 --- /dev/null +++ b/changelog.d/1577.change.md @@ -0,0 +1 @@ +`astuple()` now applies its *filter* callable to attrs instances that are nested inside dict values. \ No newline at end of file From 4d0de24aa05ab695656e398c19139391aafe1780 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2026 21:34:56 +0000 Subject: [PATCH 4/4] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- changelog.d/1577.change.md | 2 +- tests/test_converters.py | 10 +++++++--- tests/test_funcs.py | 4 +++- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/changelog.d/1577.change.md b/changelog.d/1577.change.md index 34761fca4..6aa756c28 100644 --- a/changelog.d/1577.change.md +++ b/changelog.d/1577.change.md @@ -1 +1 @@ -`astuple()` now applies its *filter* callable to attrs instances that are nested inside dict values. \ No newline at end of file +`astuple()` now applies its *filter* callable to attrs instances that are nested inside dict values. diff --git a/tests/test_converters.py b/tests/test_converters.py index 7fa631df4..5f2d0b092 100644 --- a/tests/test_converters.py +++ b/tests/test_converters.py @@ -365,7 +365,9 @@ def test_falsy(self): 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"]) + @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. @@ -386,8 +388,10 @@ 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"}) + assert to_bool(value) is bool( + value.decode("ascii").lower() + in {"true", "t", "yes", "y", "on", "1"} + ) def test_bytearray_truthy(self): """ diff --git a/tests/test_funcs.py b/tests/test_funcs.py index 7d3034874..fd8ca4d46 100644 --- a/tests/test_funcs.py +++ b/tests/test_funcs.py @@ -426,7 +426,9 @@ 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__) + @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