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/1020.change.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Pickling and copying a slotted class that inherits a `__dict__` from a non-slotted base class now preserves attributes stored in the instance `__dict__` -- for example attributes set in `__attrs_post_init__` or by a mixin -- instead of silently dropping them.
26 changes: 25 additions & 1 deletion src/attr/_make.py
Original file line number Diff line number Diff line change
Expand Up @@ -1035,7 +1035,20 @@ 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}

# A slotted class can inherit a __dict__ from a non-slotted
# base class. Such instances may carry additional state in
# their __dict__ -- for example attributes set by a mixin or
# in __attrs_post_init__ -- which is not managed by attrs.
# Preserve it like object.__getstate__() does, so pickling
# and copying do not silently drop it.
inst_dict = getattr(self, "__dict__", None)
if inst_dict:
for name, value in inst_dict.items():
state.setdefault(name, value)

return state

hash_caching_enabled = self._cache_hash

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

# Restore additional __dict__ state that was preserved by
# slots_getstate (see above). Instances without a
# __dict__ cannot hold such state; extra keys are ignored
# for them, which keeps unpickling working if an
# attribute was removed from the class.
inst_dict = getattr(self, "__dict__", None)
if inst_dict is not None:
for name, value in state.items():
if name not in state_attr_names:
inst_dict[name] = value

# 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
42 changes: 42 additions & 0 deletions tests/test_slots.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
Unit tests for slots-related functionality.
"""

import copy
import functools
import pickle
import weakref
Expand Down Expand Up @@ -622,6 +623,19 @@ def f(self, a):
C(field=1)


@attr.s(slots=False)
class CNonSlottedBase:
name = attr.ib()


@attr.s(slots=True)
class CSlottedChildWithDict(CNonSlottedBase):
x = attr.ib()

def __attrs_post_init__(self):
self.z = self.x + 2


@attr.s(getstate_setstate=True)
class C2:
x = attr.ib()
Expand All @@ -645,6 +659,34 @@ def test_pickleable_by_default(self, protocol):
assert i1 == i2
assert i1 is not i2

@pytest.mark.parametrize("protocol", range(pickle.HIGHEST_PROTOCOL))
def test_pickle_preserves_dict_state(self, protocol):
"""
Attributes stored in the instance __dict__ of a slotted class
that inherits a __dict__ from a non-slotted base class are
preserved when pickling, instead of being silently dropped.
"""
i1 = CSlottedChildWithDict("spam", 3)

i2 = pickle.loads(pickle.dumps(i1, protocol))

assert i1 == i2
assert "spam" == i2.name
assert 3 == i2.x
assert 5 == i2.z

def test_copy_preserves_dict_state(self):
"""
Copying a slotted class with a __dict__ (see above) preserves
the attributes stored in the __dict__ as well.
"""
i1 = CSlottedChildWithDict("spam", 3)

i2 = copy.copy(i1)

assert i1 == i2
assert 5 == i2.z

def test_no_getstate_setstate_for_dict_classes(self):
"""
As long as getstate_setstate is None, nothing is done to dict
Expand Down
Loading