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
1 change: 1 addition & 0 deletions changelog.d/1637.change.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Slots that a slotted class declares on itself are now preserved instead of being dropped while the class is rebuilt. This allows for instance attributes that `attrs` is entirely unaware of -- they don't show up in `attrs.fields()`, `attrs.asdict()`, or the generated `__init__`/`__eq__`/`__hash__`.
19 changes: 19 additions & 0 deletions docs/examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -653,6 +653,25 @@ Defining `__slots__` by hand is tedious, in *attrs* it's just a matter of using

{func}`~attrs.define` sets `slots=True` by default.

If you need instance attributes that *attrs* should be entirely unaware of -- they don't appear in {func}`attrs.fields`, {func}`attrs.asdict`, or the generated `__init__`, `__eq__`, and `__hash__` -- you can declare them as slots on the class yourself:

```{doctest}
>>> from attrs import asdict

>>> @define
... class C:
... __slots__ = ("_internal",)
...
... x: int

>>> c = C(1)
>>> c._internal = "not part of the data model"
>>> c._internal
'not part of the data model'
>>> asdict(c)
{'x': 1}
```


## Immutability

Expand Down
77 changes: 76 additions & 1 deletion src/attr/_make.py
Original file line number Diff line number Diff line change
Expand Up @@ -644,6 +644,25 @@ def evolve(*args, **changes):
return cls(**changes)


def _mangle_slot_name(cls, name):
"""
Return the name *name* is stored under in *cls*'s class dict.

Python mangles identifiers that start with two underscores and don't end
with two -- `__slots__` entries included. Since we re-create classes
from a plain dict, type() applies the mangling to us, but we have to
look up (and deduplicate against) the mangled names ourselves.
"""
if not name.startswith("__") or name.endswith("__"):
return name

class_name = cls.__name__.lstrip("_")
if not class_name:
return name

return f"_{class_name}{name}"


class _ClassBuilder:
"""
Iteratively build *one* class.
Expand Down Expand Up @@ -947,6 +966,25 @@ def _create_slots_class(self):
# Setting __slots__ to inherited attributes wastes memory.
slot_names = [name for name in names if name not in base_names]

# Slots the class declares itself are not attrs fields, but they still
# have to be carried over -- otherwise `__slots__` below overwrites
# them and the descriptors created for the original class are left
# behind, raising on any access to instances of the new class.
# See <https://github.com/python-attrs/attrs/issues/1637>.
for declared_name in self._declared_slot_names():
name = _mangle_slot_name(self._cls, declared_name)

# The descriptor belongs to the original class and is useless for
# the new one, no matter whether the slot is laid out again.
cd.pop(name, None)

# Slots that are inherited (be it from attrs or from a hand-written
# base class) are already laid out and the descriptors of the base
# classes stay reachable through the MRO.
if name not in slot_names and name not in existing_slots:
# type() mangles the name of the new class for us again.
slot_names.append(declared_name)

# There are slots for attributes from current class
# that are defined in parent classes.
# As their descriptors may be overridden by a child class,
Expand Down Expand Up @@ -1022,6 +1060,20 @@ def __str__(self):
self._cls_dict["__str__"] = self._add_method_dunders(__str__)
return self

def _declared_slot_names(self):
"""
Return the names the class itself declares in its `__slots__`, as a
tuple.

Python accepts any iterable -- strings and mappings included -- as
`__slots__`, so this can't be used as-is.
"""
declared_slots = self._cls_dict.get("__slots__", ())
if isinstance(declared_slots, str):
return (declared_slots,)

return tuple(declared_slots)

def _make_getstate_setstate(self):
"""
Create custom __setstate__ and __getstate__ methods.
Expand All @@ -1031,11 +1083,30 @@ def _make_getstate_setstate(self):
an for an in self._attr_names if an != "__weakref__"
)

# Slots declared by the class itself are not attrs fields, but they're
# still part of the instance state and have to survive pickling and
# copying.
extra_state_names = tuple(
mangled
for mangled in (
_mangle_slot_name(self._cls, name)
for name in self._declared_slot_names()
)
if mangled not in state_attr_names
and mangled not in ("__dict__", "__weakref__", "__slots__")
)

def slots_getstate(self):
"""
Automatically created by attrs.
"""
return {name: getattr(self, name) for name in state_attr_names}
state = {name: getattr(self, name) for name in state_attr_names}
for name in extra_state_names:
value = getattr(self, name, _SENTINEL)
if value is not _SENTINEL:
state[name] = value

return state

hash_caching_enabled = self._cache_hash

Expand All @@ -1054,6 +1125,10 @@ def slots_setstate(self, state):
if name in state:
__bound_setattr(name, state[name])

for name in extra_state_names:
if name in state:
__bound_setattr(name, state[name])

# The hash code cache is not included when the object is
# serialized, but it still needs to be initialized to None to
# indicate that the first call to __hash__ should be a cache
Expand Down
Loading
Loading