From de1561f09016d4412852631fa4b031c2818d0828 Mon Sep 17 00:00:00 2001 From: Kenoia <73810401+Kenoia@users.noreply.github.com> Date: Wed, 7 Oct 2026 21:24:59 +0800 Subject: [PATCH] Include inherited slots when hashing slotted objects --- CHANGELOG.md | 1 + deepdiff/deephash.py | 24 ++++++----- tests/test_hash.py | 95 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 110 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9282c421..7d85877d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # DeepDiff Change log - Unreleased + - Fixed DeepHash omitting inherited and string-declared slot attributes, which could hide changes in unordered comparisons of slotted objects. - Fixed missing type changes between equal-comparing items inside ordered iterables, e.g. `DeepDiff([2], [2.0])` now reports the `int` → `float` change like `DeepDiff(2, 2.0)` and `DeepDiff({'a': 2}, {'a': 2.0})` already do (issue #605). - v9-1-0 diff --git a/deepdiff/deephash.py b/deepdiff/deephash.py index e0d60da2..0141888a 100644 --- a/deepdiff/deephash.py +++ b/deepdiff/deephash.py @@ -292,7 +292,7 @@ def __getitem__(self, obj: Any, extract_index: Optional[int] = 0) -> Any: @staticmethod def _get_slots_dict(obj: Any) -> Dict[str, Any]: - """Get a dict of initialized slot attributes. + """Get a dict of initialized slot attributes across the class hierarchy. Uses object.__getattribute__ to check each slot directly, bypassing __getattr__. For uninitialized slots on classes that define __getattr__, @@ -300,15 +300,19 @@ def _get_slots_dict(obj: Any) -> Dict[str, Any]: """ result = {} has_getattr = hasattr(type(obj), '__getattr__') - for slot in obj.__slots__: - try: - result[slot] = object.__getattribute__(obj, slot) - except AttributeError: - if has_getattr: - # The slot isn't initialized, but the class defines __getattr__. - # Try the normal getattr to let __getattr__ provide a value or - # raise — if it raises, we propagate to fail the strategy. - result[slot] = getattr(obj, slot) + for cls in type(obj).__mro__: + slots = cls.__dict__.get('__slots__', ()) + if isinstance(slots, str): + slots = (slots,) + for slot in slots: + try: + result[slot] = object.__getattribute__(obj, slot) + except AttributeError: + if has_getattr: + # The slot isn't initialized, but the class defines __getattr__. + # Try the normal getattr to let __getattr__ provide a value or + # raise — if it raises, we propagate to fail the strategy. + result[slot] = getattr(obj, slot) return result @staticmethod diff --git a/tests/test_hash.py b/tests/test_hash.py index f1e2e912..2658b1c2 100755 --- a/tests/test_hash.py +++ b/tests/test_hash.py @@ -299,6 +299,101 @@ def __init__(self, x, y): # Should still not raise AttributeError internally for uninitialized 'z' assert result[obj] is not unprocessed + @pytest.mark.parametrize('derived_slots', [(), ('y', 'uninitialized'), 'value']) + @pytest.mark.parametrize('nested', [False, True]) + def test_inherited_slot_values_affect_hash_and_unordered_diff(self, derived_slots, nested): + class Base: + __slots__ = ('x',) + + def __init__(self, x): + self.x = x + + class Derived(Base): + __slots__ = derived_slots + + class Leaf(Derived): + __slots__ = ('z',) + + cls = Leaf if nested else Derived + old = cls(1) + equal = cls(1) + new = cls(3) + for obj in (old, equal, new): + if derived_slots == 'value': + obj.value = 2 + elif derived_slots: + obj.y = 2 + if nested: + obj.z = 4 + + hashes = DeepHash([old, equal, new]) + assert hashes[old] == hashes[equal] + assert hashes[old] != hashes[new] + assert DeepDiff([old], [equal], ignore_order=True) == {} + assert DeepDiff([old], [new], ignore_order=True) == { + 'values_changed': {'root[0].x': {'old_value': 1, 'new_value': 3}} + } + + @pytest.mark.parametrize('slots', ['value', {'value': 'Stored value'}]) + def test_slot_declaration_values_affect_hash(self, slots): + class Slotted: + __slots__ = slots + + def __init__(self, value): + self.value = value + + old = Slotted(1) + equal = Slotted(1) + new = Slotted(3) + hashes = DeepHash([old, equal, new]) + assert hashes[old] == hashes[equal] + assert hashes[old] != hashes[new] + assert DeepDiff([old], [new], ignore_order=True) == { + 'values_changed': {'root[0].value': {'old_value': 1, 'new_value': 3}} + } + + @pytest.mark.parametrize('broken', [False, True]) + def test_inherited_uninitialized_slot_with_getattr(self, broken): + class Base: + __slots__ = ('x', 'y') + + def __init__(self, x): + self.x = x + + def __getattr__(self, name): + if name == 'y' and not broken: + return 2 + raise AttributeError(name) + + class Derived(Base): + __slots__ = () + + old = Derived(1) + new = Derived(3) + hashes = DeepHash([old, new]) + if broken: + assert hashes[old] is unprocessed + assert hashes[new] is unprocessed + else: + assert hashes[old] != hashes[new] + + def test_inherited_slots_respect_ignored_type_groups(self): + class Base: + __slots__ = ('x',) + + def __init__(self, x): + self.x = x + + class Derived(Base): + __slots__ = () + + old = Base(1) + new = Derived(1) + options = {'ignore_type_in_groups': [(Base, Derived)]} + hashes = DeepHash([old, new], **options) + assert hashes[old] == hashes[new] + assert DeepDiff([old], [new], ignore_order=True, **options) == {} + def test_slots_deepdiff_comparison(self): """Test that DeepDiff also works correctly with __slots__ classes (incl inherited and uninitialized attributes).""" class Base: