Skip to content

Commit 106c33c

Browse files
authored
Clear __setattr__ if it was inherited && written by us (#663)
Signed-off-by: Hynek Schlawack <[email protected]>
1 parent e554373 commit 106c33c

File tree

4 files changed

+414
-192
lines changed

4 files changed

+414
-192
lines changed

src/attr/_make.py

Lines changed: 57 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -557,6 +557,7 @@ class _ClassBuilder(object):
557557
"_on_setattr",
558558
"_slots",
559559
"_weakref_slot",
560+
"_has_own_setattr",
560561
)
561562

562563
def __init__(
@@ -573,6 +574,7 @@ def __init__(
573574
is_exc,
574575
collect_by_mro,
575576
on_setattr,
577+
has_custom_setattr,
576578
):
577579
attrs, base_attrs, base_map = _transform_attrs(
578580
cls, these, auto_attribs, kw_only, collect_by_mro,
@@ -585,20 +587,24 @@ def __init__(
585587
self._base_attr_map = base_map
586588
self._attr_names = tuple(a.name for a in attrs)
587589
self._slots = slots
588-
self._frozen = frozen or _has_frozen_base_class(cls)
590+
self._frozen = frozen
589591
self._weakref_slot = weakref_slot
590592
self._cache_hash = cache_hash
591593
self._has_post_init = bool(getattr(cls, "__attrs_post_init__", False))
592594
self._delete_attribs = not bool(these)
593595
self._is_exc = is_exc
594596
self._on_setattr = on_setattr
595597

598+
self._has_own_setattr = has_custom_setattr
599+
596600
self._cls_dict["__attrs_attrs__"] = self._attrs
597601

598602
if frozen:
599603
self._cls_dict["__setattr__"] = _frozen_setattrs
600604
self._cls_dict["__delattr__"] = _frozen_delattrs
601605

606+
self._has_own_setattr = True
607+
602608
if getstate_setstate:
603609
(
604610
self._cls_dict["__getstate__"],
@@ -645,6 +651,13 @@ def _patch_original_class(self):
645651
for name, value in self._cls_dict.items():
646652
setattr(cls, name, value)
647653

654+
# If we've inherited an attrs __setattr__ and don't write our own,
655+
# reset it to object's.
656+
if not self._has_own_setattr and getattr(
657+
cls, "__attrs_own_setattr__", False
658+
):
659+
cls.__setattr__ = object.__setattr__
660+
648661
return cls
649662

650663
def _create_slots_class(self):
@@ -658,14 +671,24 @@ def _create_slots_class(self):
658671
if k not in tuple(self._attr_names) + ("__dict__", "__weakref__")
659672
}
660673

674+
# Traverse the MRO to check for an existing __weakref__ and
675+
# __setattr__.
676+
custom_setattr_inherited = False
661677
weakref_inherited = False
662-
663-
# Traverse the MRO to check for an existing __weakref__.
664678
for base_cls in self._cls.__mro__[1:-1]:
665-
if "__weakref__" in getattr(base_cls, "__dict__", ()):
666-
weakref_inherited = True
679+
d = getattr(base_cls, "__dict__", {})
680+
681+
weakref_inherited = weakref_inherited or "__weakref__" in d
682+
custom_setattr_inherited = custom_setattr_inherited or not (
683+
d.get("__attrs_own_setattr__", False)
684+
)
685+
686+
if weakref_inherited and custom_setattr_inherited:
667687
break
668688

689+
if not self._has_own_setattr and not custom_setattr_inherited:
690+
cd["__setattr__"] = object.__setattr__
691+
669692
names = self._attr_names
670693
if (
671694
self._weakref_slot
@@ -836,7 +859,20 @@ def add_setattr(self):
836859
if not sa_attrs:
837860
return self
838861

862+
if self._has_own_setattr:
863+
# We need to write a __setattr__ but there already is one!
864+
raise ValueError(
865+
"Can't combine custom __setattr__ with on_setattr hooks."
866+
)
867+
868+
cls = self._cls
869+
839870
def __setattr__(self, name, val):
871+
"""
872+
Method generated by attrs for class %s.
873+
""" % (
874+
cls.__name__,
875+
)
840876
try:
841877
a, hook = sa_attrs[name]
842878
except KeyError:
@@ -846,7 +882,9 @@ def __setattr__(self, name, val):
846882

847883
_obj_setattr(self, name, nval)
848884

885+
self._cls_dict["__attrs_own_setattr__"] = True
849886
self._cls_dict["__setattr__"] = self._add_method_dunders(__setattr__)
887+
self._has_own_setattr = True
850888

851889
return self
852890

@@ -1076,6 +1114,8 @@ def attrs(
10761114
circumvent that limitation by using
10771115
``object.__setattr__(self, "attribute_name", value)``.
10781116
1117+
5. Subclasses of a frozen class are frozen too.
1118+
10791119
:param bool weakref_slot: Make instances weak-referenceable. This has no
10801120
effect unless ``slots`` is also enabled.
10811121
:param bool auto_attribs: If ``True``, collect `PEP 526`_-annotated
@@ -1200,13 +1240,20 @@ def wrap(cls):
12001240
if getattr(cls, "__class__", None) is None:
12011241
raise TypeError("attrs only works with new-style classes.")
12021242

1243+
is_frozen = frozen or _has_frozen_base_class(cls)
12031244
is_exc = auto_exc is True and issubclass(cls, BaseException)
1245+
has_own_setattr = auto_detect and _has_own_attribute(
1246+
cls, "__setattr__"
1247+
)
1248+
1249+
if has_own_setattr and is_frozen:
1250+
raise ValueError("Can't freeze a class with a custom __setattr__.")
12041251

12051252
builder = _ClassBuilder(
12061253
cls,
12071254
these,
12081255
slots,
1209-
frozen,
1256+
is_frozen,
12101257
weakref_slot,
12111258
_determine_whether_to_implement(
12121259
cls,
@@ -1221,6 +1268,7 @@ def wrap(cls):
12211268
is_exc,
12221269
collect_by_mro,
12231270
on_setattr,
1271+
has_own_setattr,
12241272
)
12251273
if _determine_whether_to_implement(
12261274
cls, repr, auto_detect, ("__repr__",)
@@ -1263,7 +1311,9 @@ def wrap(cls):
12631311
" hashing must be either explicitly or implicitly "
12641312
"enabled."
12651313
)
1266-
elif hash is True or (hash is None and eq is True and frozen is True):
1314+
elif hash is True or (
1315+
hash is None and eq is True and is_frozen is True
1316+
):
12671317
# Build a __hash__ if told so, or if it's safe.
12681318
builder.add_hash()
12691319
else:

tests/test_functional.py

Lines changed: 7 additions & 185 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,9 @@
1616

1717
import attr
1818

19-
from attr import setters
2019
from attr._compat import PY2, TYPE
2120
from attr._make import NOTHING, Attribute
22-
from attr.exceptions import FrozenAttributeError, FrozenInstanceError
23-
from attr.validators import instance_of, matches_re
21+
from attr.exceptions import FrozenInstanceError
2422

2523
from .strategies import optional_bool
2624

@@ -112,9 +110,9 @@ class WithMetaSlots(object):
112110
FromMakeClass = attr.make_class("FromMakeClass", ["x"])
113111

114112

115-
class TestDarkMagic(object):
113+
class TestFunctional(object):
116114
"""
117-
Integration tests.
115+
Functional tests.
118116
"""
119117

120118
@pytest.mark.parametrize("cls", [C2, C2Slots])
@@ -320,6 +318,7 @@ def test_pickle_object(self, cls, protocol):
320318
obj = cls(123, 456)
321319
else:
322320
obj = cls(123)
321+
323322
assert repr(obj) == repr(pickle.loads(pickle.dumps(obj, protocol)))
324323

325324
def test_subclassing_frozen_gives_frozen(self):
@@ -332,6 +331,9 @@ def test_subclassing_frozen_gives_frozen(self):
332331
assert i.x == "foo"
333332
assert i.y == "bar"
334333

334+
with pytest.raises(FrozenInstanceError):
335+
i.x = "baz"
336+
335337
@pytest.mark.parametrize("cls", [WithMeta, WithMetaSlots])
336338
def test_metaclass_preserved(self, cls):
337339
"""
@@ -683,183 +685,3 @@ class C(object):
683685
"2021-06-01. Please use `eq` and `order` instead."
684686
== w.message.args[0]
685687
)
686-
687-
688-
class TestSetAttr(object):
689-
def test_change(self):
690-
"""
691-
The return value of a hook overwrites the value. But they are not run
692-
on __init__.
693-
"""
694-
695-
def hook(*a, **kw):
696-
return "hooked!"
697-
698-
@attr.s
699-
class Hooked(object):
700-
x = attr.ib(on_setattr=hook)
701-
y = attr.ib()
702-
703-
h = Hooked("x", "y")
704-
705-
assert "x" == h.x
706-
assert "y" == h.y
707-
708-
h.x = "xxx"
709-
h.y = "yyy"
710-
711-
assert "yyy" == h.y
712-
assert "hooked!" == h.x
713-
714-
def test_frozen_attribute(self):
715-
"""
716-
Frozen attributes raise FrozenAttributeError, others are not affected.
717-
"""
718-
719-
@attr.s
720-
class PartiallyFrozen(object):
721-
x = attr.ib(on_setattr=setters.frozen)
722-
y = attr.ib()
723-
724-
pf = PartiallyFrozen("x", "y")
725-
726-
pf.y = "yyy"
727-
728-
assert "yyy" == pf.y
729-
730-
with pytest.raises(FrozenAttributeError):
731-
pf.x = "xxx"
732-
733-
assert "x" == pf.x
734-
735-
@pytest.mark.parametrize(
736-
"on_setattr",
737-
[setters.validate, [setters.validate], setters.pipe(setters.validate)],
738-
)
739-
def test_validator(self, on_setattr):
740-
"""
741-
Validators are run and they don't alter the value.
742-
"""
743-
744-
@attr.s(on_setattr=on_setattr)
745-
class ValidatedAttribute(object):
746-
x = attr.ib()
747-
y = attr.ib(validator=[instance_of(str), matches_re("foo.*qux")])
748-
749-
va = ValidatedAttribute(42, "foobarqux")
750-
751-
with pytest.raises(TypeError) as ei:
752-
va.y = 42
753-
754-
assert "foobarqux" == va.y
755-
756-
assert ei.value.args[0].startswith("'y' must be <")
757-
758-
with pytest.raises(ValueError) as ei:
759-
va.y = "quxbarfoo"
760-
761-
assert ei.value.args[0].startswith("'y' must match regex '")
762-
763-
assert "foobarqux" == va.y
764-
765-
va.y = "foobazqux"
766-
767-
assert "foobazqux" == va.y
768-
769-
def test_pipe(self):
770-
"""
771-
Multiple hooks are possible, in that case the last return value is
772-
used. They can be supplied using the pipe functions or by passing a
773-
list to on_setattr.
774-
"""
775-
776-
s = [setters.convert, lambda _, __, nv: nv + 1]
777-
778-
@attr.s
779-
class Piped(object):
780-
x1 = attr.ib(converter=int, on_setattr=setters.pipe(*s))
781-
x2 = attr.ib(converter=int, on_setattr=s)
782-
783-
p = Piped("41", "22")
784-
785-
assert 41 == p.x1
786-
assert 22 == p.x2
787-
788-
p.x1 = "41"
789-
p.x2 = "22"
790-
791-
assert 42 == p.x1
792-
assert 23 == p.x2
793-
794-
def test_make_class(self):
795-
"""
796-
on_setattr of make_class gets forwarded.
797-
"""
798-
C = attr.make_class("C", {"x": attr.ib()}, on_setattr=setters.frozen)
799-
800-
c = C(1)
801-
802-
with pytest.raises(FrozenAttributeError):
803-
c.x = 2
804-
805-
def test_no_validator_no_converter(self):
806-
"""
807-
validate and convert tolerate missing validators and converters.
808-
"""
809-
810-
@attr.s(on_setattr=[setters.convert, setters.validate])
811-
class C(object):
812-
x = attr.ib()
813-
814-
c = C(1)
815-
816-
c.x = 2
817-
818-
def test_validate_respects_run_validators_config(self):
819-
"""
820-
If run validators is off, validate doesn't run them.
821-
"""
822-
823-
@attr.s(on_setattr=setters.validate)
824-
class C(object):
825-
x = attr.ib(validator=attr.validators.instance_of(int))
826-
827-
c = C(1)
828-
829-
attr.set_run_validators(False)
830-
831-
c.x = "1"
832-
833-
assert "1" == c.x
834-
835-
attr.set_run_validators(True)
836-
837-
with pytest.raises(TypeError) as ei:
838-
c.x = "1"
839-
840-
assert ei.value.args[0].startswith("'x' must be <")
841-
842-
def test_frozen_on_setattr_class_is_caught(self):
843-
"""
844-
@attr.s(on_setattr=X, frozen=True) raises an ValueError.
845-
"""
846-
with pytest.raises(ValueError) as ei:
847-
848-
@attr.s(frozen=True, on_setattr=setters.validate)
849-
class C(object):
850-
x = attr.ib()
851-
852-
assert "Frozen classes can't use on_setattr." == ei.value.args[0]
853-
854-
def test_frozen_on_setattr_attribute_is_caught(self):
855-
"""
856-
attr.ib(on_setattr=X) on a frozen class raises an ValueError.
857-
"""
858-
859-
with pytest.raises(ValueError) as ei:
860-
861-
@attr.s(frozen=True)
862-
class C(object):
863-
x = attr.ib(on_setattr=setters.validate)
864-
865-
assert "Frozen classes can't use on_setattr." == ei.value.args[0]

0 commit comments

Comments
 (0)