diff --git a/src/attr/_make.py b/src/attr/_make.py index 793bfd89d..e29bd2b2b 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,12 @@ 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..ea15d8f9e 100644 --- a/tests/test_setattr.py +++ b/tests/test_setattr.py @@ -316,14 +316,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 +335,45 @@ 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