From c17b3c9c518f647fbbef979f0762d7fa00366e24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Csahvx655-wq=E2=80=9D?= <“sahvx655@gmail.com”> Date: Thu, 28 May 2026 21:53:03 +0530 Subject: [PATCH 1/3] Improve handling of intermediate setattr in inheritance chains with mixed attrs and plain Python classes --- src/attr/_make.py | 18 ++++++++++++------ tests/test_setattr.py | 43 ++++++++++++++++++++++++++++++++++++++----- 2 files changed, 50 insertions(+), 11 deletions(-) diff --git a/src/attr/_make.py b/src/attr/_make.py index 793bfd89d..6644cacac 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -844,9 +844,14 @@ def _patch_original_class(self): # If we've inherited an attrs __setattr__ and don't write our own, # reset it to object's. - if not self._wrote_own_setattr and getattr( - cls, "__attrs_own_setattr__", False - ): + has_attrs_own_setattr = False + for base_cls in cls.__mro__[1:]: + if "__setattr__" in base_cls.__dict__: + if base_cls.__dict__.get("__attrs_own_setattr__", False): + has_attrs_own_setattr = True + break + + if not self._wrote_own_setattr and has_attrs_own_setattr: cls.__attrs_own_setattr__ = False if not self._has_custom_setattr: @@ -880,9 +885,10 @@ def _create_slots_class(self): cd["__attrs_own_setattr__"] = False if not self._has_custom_setattr: - for base_cls in self._cls.__bases__: - if base_cls.__dict__.get("__attrs_own_setattr__", False): - cd["__setattr__"] = _OBJ_SETATTR + for base_cls in self._cls.__mro__[1:]: + if "__setattr__" in base_cls.__dict__: + if base_cls.__dict__.get("__attrs_own_setattr__", False): + cd["__setattr__"] = _OBJ_SETATTR break # Traverse the MRO to collect existing slots diff --git a/tests/test_setattr.py b/tests/test_setattr.py index f44abf658..b8b2285ea 100644 --- a/tests/test_setattr.py +++ b/tests/test_setattr.py @@ -1,4 +1,5 @@ # SPDX-License-Identifier: MIT +# type: ignore import pickle @@ -316,14 +317,10 @@ def __setattr__(self, key, value): with pytest.raises(SystemError): A().x = 1 - @pytest.mark.xfail(raises=attr.exceptions.FrozenAttributeError) def test_slotted_confused(self): """ If we have a in-between non-attrs class, setattr reset detection - should still work, but currently doesn't. - - It works with dict classes because we can look the finished class and - patch it. With slotted classes we have to deduce it ourselves. + should still work. """ @attr.s(slots=True) @@ -339,6 +336,42 @@ class C(B): C(1).x = 2 + def test_setattr_inherited_do_not_reset_intermediate_non_attrs(self, slots): + """ + A user-provided intermediate __setattr__ on a non-attrs class is not reset + to object.__setattr__. + """ + + @attr.s(slots=slots) + class A: + x: int = attr.ib(on_setattr=setters.frozen) + + class BCustom(A): + x: int + def __setattr__(self, name, value): + object.__setattr__(self, name, value * 2) + + class BPlain(A): + x: int + + @attr.s(slots=slots) + class CFromCustom(BCustom): + x: int = attr.ib() + + @attr.s(slots=slots) + class CFromPlain(BPlain): + x: int = attr.ib() + + # CFromPlain should reset to object.__setattr__ and mutate normally + c_plain = CFromPlain(1) + c_plain.x = 2 + assert c_plain.x == 2 + + # CFromCustom should respect and call BCustom.__setattr__ + c_custom = CFromCustom(1) + c_custom.x = 2 + assert c_custom.x == 4 + def test_setattr_auto_detect_if_no_custom_setattr(self, slots): """ It's possible to remove the on_setattr hook from an attribute and From 65c4c9cbb9f2ebfb532e39249f52e16a45809d54 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 28 May 2026 16:31:39 +0000 Subject: [PATCH 2/3] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/attr/_make.py | 4 +++- tests/test_setattr.py | 5 ++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/attr/_make.py b/src/attr/_make.py index 6644cacac..e29bd2b2b 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -887,7 +887,9 @@ def _create_slots_class(self): if not self._has_custom_setattr: for base_cls in self._cls.__mro__[1:]: if "__setattr__" in base_cls.__dict__: - if base_cls.__dict__.get("__attrs_own_setattr__", False): + if base_cls.__dict__.get( + "__attrs_own_setattr__", False + ): cd["__setattr__"] = _OBJ_SETATTR break diff --git a/tests/test_setattr.py b/tests/test_setattr.py index b8b2285ea..3da611ead 100644 --- a/tests/test_setattr.py +++ b/tests/test_setattr.py @@ -336,7 +336,9 @@ class C(B): C(1).x = 2 - def test_setattr_inherited_do_not_reset_intermediate_non_attrs(self, slots): + def test_setattr_inherited_do_not_reset_intermediate_non_attrs( + self, slots + ): """ A user-provided intermediate __setattr__ on a non-attrs class is not reset to object.__setattr__. @@ -348,6 +350,7 @@ class A: class BCustom(A): x: int + def __setattr__(self, name, value): object.__setattr__(self, name, value * 2) From 1e5a266a45eb92ab4750feb094f270d384268d75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Csahvx655-wq=E2=80=9D?= <“sahvx655@gmail.com”> Date: Thu, 28 May 2026 22:20:44 +0530 Subject: [PATCH 3/3] Remove generic type ignore from test_setattr.py to satisfy Ruff PGH003 check --- tests/test_setattr.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_setattr.py b/tests/test_setattr.py index 3da611ead..ea15d8f9e 100644 --- a/tests/test_setattr.py +++ b/tests/test_setattr.py @@ -1,5 +1,4 @@ # SPDX-License-Identifier: MIT -# type: ignore import pickle