diff --git a/changelog.d/1020.change.md b/changelog.d/1020.change.md new file mode 100644 index 000000000..a7fd02d0b --- /dev/null +++ b/changelog.d/1020.change.md @@ -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. diff --git a/src/attr/_make.py b/src/attr/_make.py index e5b3760c1..44689c0f5 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -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 @@ -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 diff --git a/tests/test_slots.py b/tests/test_slots.py index a74c32b03..de376b84f 100644 --- a/tests/test_slots.py +++ b/tests/test_slots.py @@ -4,6 +4,7 @@ Unit tests for slots-related functionality. """ +import copy import functools import pickle import weakref @@ -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() @@ -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