Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions changelog.d/1638.change.md
Original file line number Diff line number Diff line change
@@ -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.
20 changes: 20 additions & 0 deletions docs/glossary.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,26 @@ 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.

```{versionchanged} 26.2.0
Explicit slots declared on the decorated class are preserved.
```

```{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.

Expand Down
24 changes: 21 additions & 3 deletions src/attr/_make.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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+
Expand Down Expand Up @@ -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
):
Expand Down
111 changes: 111 additions & 0 deletions tests/test_slots.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Loading