From 0e00c9f897a570122fe8484afdfb116abf14642e Mon Sep 17 00:00:00 2001 From: Anshu Raj Bisoyi Date: Sat, 3 Oct 2026 15:22:22 +0530 Subject: [PATCH 1/3] fix: preserve explicitly declared slots Signed-off-by: Anshu Raj Bisoyi --- changelog.d/1637.change.md | 2 + docs/glossary.md | 16 ++++++ src/attr/_make.py | 24 +++++++- tests/test_slots.py | 111 +++++++++++++++++++++++++++++++++++++ 4 files changed, 150 insertions(+), 3 deletions(-) create mode 100644 changelog.d/1637.change.md diff --git a/changelog.d/1637.change.md b/changelog.d/1637.change.md new file mode 100644 index 000000000..c60f71692 --- /dev/null +++ b/changelog.d/1637.change.md @@ -0,0 +1,2 @@ +Explicit `__slots__` declared on classes decorated with `attrs.define()` or `attr.s(slots=True)` are now preserved alongside generated slots. +Slots without attrs fields remain outside the attrs data model. diff --git a/docs/glossary.md b/docs/glossary.md index 54a2724cb..a223352e0 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -42,6 +42,22 @@ slotted classes AttributeError: 'Coordinates' object has no attribute 'z' ``` + - Explicit `__slots__` declared on the decorated class are preserved alongside the generated slots. + Slots without an attrs field remain outside the attrs data model: generated initialization, equality, `attrs.asdict()`, and generated pickling methods do not manage their values. + Initialize these slots yourself and supply custom pickling methods if their values need to be serialized. + + ```{doctest} + >>> @define + ... class WithInternalState: + ... __slots__ = ("internal",) + ... value: int = 1 + ... + >>> state = WithInternalState() + >>> state.internal = "ready" + >>> state.internal + 'ready' + ``` + - Slotted classes can inherit from other classes just like non-slotted classes, but some of the benefits of slotted classes are lost if you do that. If you must inherit from other classes, try to inherit only from other slotted classes. diff --git a/src/attr/_make.py b/src/attr/_make.py index e5b3760c1..68249f093 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -644,6 +644,21 @@ def evolve(*args, **changes): return cls(**changes) +def _get_own_slot_names(cls): + """ + Return the actual slot names declared on a class, including mangled names. + """ + if "__slots__" not in cls.__dict__: + return () + + return tuple( + name + for name, descriptor in cls.__dict__.items() + if isinstance(descriptor, types.MemberDescriptorType) + or name in ("__dict__", "__weakref__") + ) + + class _ClassBuilder: """ Iteratively build *one* class. @@ -860,10 +875,12 @@ def _create_slots_class(self): """ Build and return a new class with a `__slots__` attribute. """ + own_slots = _get_own_slot_names(self._cls) cd = { k: v for k, v in self._cls_dict.items() - if k not in (*tuple(self._attr_names), "__dict__", "__weakref__") + if k + not in (*self._attr_names, *own_slots, "__dict__", "__weakref__") } # 3.14.0rc2+ @@ -903,10 +920,11 @@ def _create_slots_class(self): base_names = set(self._base_names) - names = self._attr_names + names = self._attr_names + tuple( + name for name in own_slots if name not in self._attr_names + ) if ( self._weakref_slot - and "__weakref__" not in getattr(self._cls, "__slots__", ()) and "__weakref__" not in names and not weakref_inherited ): diff --git a/tests/test_slots.py b/tests/test_slots.py index a74c32b03..e865f7c8e 100644 --- a/tests/test_slots.py +++ b/tests/test_slots.py @@ -1214,3 +1214,114 @@ def test_slots_unpickle_is_backward_compatible(frozen): a_unpickled = pickle.loads(a_pickled) assert a_unpickled == a + + +@pytest.mark.parametrize( + "slots", [("private",), "private", {"private": "Internal state"}] +) +@pytest.mark.parametrize( + "decorator", [attrs.define, attr.s(slots=True, auto_attribs=True)] +) +def test_explicit_slots_preserved(slots, decorator): + """ + Explicit slots remain writable but do not become attrs fields. + """ + + @decorator + class C: + __slots__ = slots + public: str = "foo" + + instance = C() + instance.private = 1 + other = C() + other.private = 2 + + assert 1 == instance.private + assert {"public", "private", "__weakref__"} == set(C.__slots__) + assert ("public",) == tuple(field.name for field in attrs.fields(C)) + assert {"public": "foo"} == attrs.asdict(instance) + assert instance == other + assert {"public": "foo"} == instance.__getstate__() + + +def test_explicit_private_slot(): + """ + Name-mangled slots get descriptors belonging to the rebuilt class. + """ + + @attrs.define + class C: + __slots__ = ("__private",) + public: int = 1 + + def set_private(self, value): + self.__private = value + + def get_private(self): + return self.__private + + instance = C() + instance.set_private(42) + + assert 42 == instance.get_private() + assert "_C__private" in C.__slots__ + + +@pytest.mark.parametrize("special_slot", ["__dict__", "__weakref__"]) +def test_explicit_special_slot(special_slot): + """ + Explicit dictionary and weak-reference slots remain available. + """ + + @attrs.define(weakref_slot=False) + class C: + __slots__ = (special_slot,) + public: int = 1 + + instance = C() + if special_slot == "__dict__": + instance.extra = 42 + + assert {"extra": 42} == instance.__dict__ + else: + assert instance is weakref.ref(instance)() + + +def test_explicit_slots_with_inheritance(): + """ + Locally declared slots work alongside inherited non-attrs slots. + """ + + class Base: + __slots__ = ("inherited",) + + @attrs.define + class C(Base): + __slots__ = ("private",) + public: int = 1 + + instance = C() + instance.private = 2 + instance.inherited = 3 + + assert (1, 2, 3) == (instance.public, instance.private, instance.inherited) + assert "inherited" not in C.__slots__ + assert Base.inherited is C.inherited + + +def test_explicit_slot_also_an_attrs_field(): + """ + Explicit slots that are also attrs fields are allocated only once. + """ + + @attrs.define + class C: + __slots__ = ("private", "public") + public: int + + instance = C(1) + instance.private = 2 + + assert (1, 2) == (instance.public, instance.private) + assert 1 == C.__slots__.count("public") From 7c6d9a09ae888bc176f7169a323e34b063ec5be6 Mon Sep 17 00:00:00 2001 From: Anshu Raj Bisoyi Date: Sat, 3 Oct 2026 15:27:16 +0530 Subject: [PATCH 2/3] docs: mark explicit slot preservation version Signed-off-by: Anshu Raj Bisoyi --- docs/glossary.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/glossary.md b/docs/glossary.md index a223352e0..b9f54aed1 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -46,6 +46,10 @@ slotted classes Slots without an attrs field remain outside the attrs data model: generated initialization, equality, `attrs.asdict()`, and generated pickling methods do not manage their values. Initialize these slots yourself and supply custom pickling methods if their values need to be serialized. + ```{versionchanged} 26.2.0 + Explicit slots declared on the decorated class are preserved. + ``` + ```{doctest} >>> @define ... class WithInternalState: From b30f2c51f5d888fd8321f9a5094c2ab267bf5105 Mon Sep 17 00:00:00 2001 From: Anshu Raj Bisoyi Date: Sat, 3 Oct 2026 15:31:17 +0530 Subject: [PATCH 3/3] docs: associate changelog with PR 1638 Signed-off-by: Anshu Raj Bisoyi --- changelog.d/{1637.change.md => 1638.change.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{1637.change.md => 1638.change.md} (100%) diff --git a/changelog.d/1637.change.md b/changelog.d/1638.change.md similarity index 100% rename from changelog.d/1637.change.md rename to changelog.d/1638.change.md