From e72f653cba8f9afea73834bd0dcf8128cc9ee266 Mon Sep 17 00:00:00 2001 From: Paul Ganssle Date: Mon, 27 Jan 2020 13:17:36 -0500 Subject: [PATCH] Use an self-clearing subclass to store hash cache Rather than attempting to remove the hash cache from the object state on deserialization or serialization, instead we store the hash cache in an object that reduces to None, thus clearing itself when pickled or copied. This fixes GH #494 and #613. Co-authored-by: Matt Wozniski --- src/attr/_make.py | 64 ++++++++++++++++++++++++----------------------- 1 file changed, 33 insertions(+), 31 deletions(-) diff --git a/src/attr/_make.py b/src/attr/_make.py index 86730c72b..d99a84d33 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -70,6 +70,30 @@ def __repr__(self): """ +class _CacheHashWrapper(int): + """An integer subclass that pickles / copies as None + + This is used for non-slots classes with ``cache_hash=True``, to avoid + serializing a potentially (even likely) invalid hash value. Since ``None`` + is the default value for uncalculated hashes, whenever this is copied, + the copy's value for hte hash should automatically reset. + + See GH #613 for more details. + """ + + if PY2: + # For some reason `type(None)` isn't callable in Python 2, but we don't + # actually need a constructor for None objects, we just need any + # available function that returns None. + def __reduce__(self): + return (getattr, (0, "", None)) + + else: + + def __reduce__(self): + return (type(None), ()) + + def attrib( default=NOTHING, validator=None, @@ -523,34 +547,6 @@ def _patch_original_class(self): for name, value in self._cls_dict.items(): setattr(cls, name, value) - # Attach __setstate__. This is necessary to clear the hash code - # cache on deserialization. See issue - # https://github.com/python-attrs/attrs/issues/482 . - # Note that this code only handles setstate for dict classes. - # For slotted classes, see similar code in _create_slots_class . - if self._cache_hash: - existing_set_state_method = getattr(cls, "__setstate__", None) - if existing_set_state_method: - raise NotImplementedError( - "Currently you cannot use hash caching if " - "you specify your own __setstate__ method." - "See https://github.com/python-attrs/attrs/issues/494 ." - ) - - # Clears the cached hash state on serialization; for frozen - # classes we need to bypass the class's setattr method. - if self._frozen: - - def cache_hash_set_state(chss_self, _): - object.__setattr__(chss_self, _hash_cache_field, None) - - else: - - def cache_hash_set_state(chss_self, _): - setattr(chss_self, _hash_cache_field, None) - - cls.__setstate__ = cache_hash_set_state - return cls def _create_slots_class(self): @@ -1103,7 +1099,10 @@ def _make_hash(cls, attrs, frozen, cache_hash): unique_filename = _generate_unique_filename(cls, "hash") type_hash = hash(unique_filename) - method_lines = ["def __hash__(self):"] + method_lines = [ + "from attr._make import _CacheHashWrapper", + "def __hash__(self, _cache_wrapper=_CacheHashWrapper):", + ] def append_hash_computation_lines(prefix, indent): """ @@ -1112,13 +1111,16 @@ def append_hash_computation_lines(prefix, indent): a value which is then cached, depending on the value of cache_hash """ method_lines.extend( - [indent + prefix + "hash((", indent + " %d," % (type_hash,)] + [ + indent + prefix + "_cache_wrapper(hash((", + indent + " %d," % (type_hash,), + ] ) for a in attrs: method_lines.append(indent + " self.%s," % a.name) - method_lines.append(indent + " ))") + method_lines.append(indent + " )))") if cache_hash: method_lines.append(tab + "if self.%s is None:" % _hash_cache_field)