Skip to content

Commit 7104891

Browse files
committed
Add supereq to EqHash to allow comparing mutable/immutable objects.
The use case is an object with zope.schema fields defaulting to immutable tuples, and instance objects (perhaps constructed with nti.externalization from JSON) having mutable lists. When they're both empty, the objects should be equal.
1 parent f9a49d6 commit 7104891

4 files changed

Lines changed: 66 additions & 12 deletions

File tree

.github/workflows/tests.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ jobs:
2828
- "3.12"
2929
- "3.13"
3030
- "3.14"
31+
- "3.15-dev"
3132
extras:
3233
- "[test,docs]"
3334
# include:

CHANGES.rst

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
22
Changes
33
=========
44

5-
1.19.1 (unreleased)
5+
1.20.0 (unreleased)
66
===================
77

8-
- Nothing changed yet.
8+
- Add support for Python 3.15.
9+
- Add ``EqHash(supereq=True)`` to also work for comparing mutable
10+
and immutable lists/tuples.
911

1012

1113
1.19.0 (2025-11-14)

src/nti/schema/eqhash.py

Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ def _superhash(value):
4444
def EqHash(*names,
4545
**kwargs):
4646
"""
47-
EqHash(*names, include_super=False, superhash=False, include_type=False)
47+
EqHash(*names, include_super=False, superhash=False, supereq=False, include_type=False)
4848
4949
A class decorator factory for the common pattern of writing
5050
``__eq__``/``__ne__`` and ``__hash__`` methods that check the same
@@ -70,24 +70,43 @@ def EqHash(*names,
7070
>>> hash(ChildThing()) != hash(Thing()) != 0
7171
True
7272
73+
74+
>>> @EqHash('d', supereq=True)
75+
... class Thing2:
76+
... def __init__(self, d): self.d = d
77+
>>> Thing2([]) == Thing2(())
78+
True
79+
>>> Thing2(set()) == Thing2(frozenset())
80+
True
81+
82+
7383
:keyword include_super: If set to ``True`` (*not* the default)
7484
then the equality (and perhaps hash) values of super will be considered.
7585
:keyword superhash: If set to ``True`` (*not* the default),
7686
then the hash function will be made to support certain
7787
mutable types (lists and dictionaries) that ordinarily cannot
7888
be hashed. Use this only when those items are functionally
7989
treated as immutable.
90+
:keyword bool supereq: If set to ``True`` (*not* the default),
91+
then the equality method will treat lists and tuples as being
92+
equal. This is useful, for example, when you have a class
93+
default that's an immutable tuple, but instances might
94+
have their own list value (containing the same data)
8095
:keyword include_type: If set to ``True`` (*not* the default),
8196
equality will only be true if the other object is an instance
8297
of the class this is declared on. Use this only when there are
8398
a series of subclasses who differ in no attributes but should not
8499
compare equal to each other. Note that this can lead to violating
85100
the commutative property.
86101
102+
.. versionchanged:: NEXT
103+
Make *superhash* apply to the equality operator as well for
104+
comparing mutable and immutable objects.
87105
"""
88106

89107
_include_super = kwargs.pop('include_super', False)
90108
superhash = kwargs.pop("superhash", False)
109+
supereq = kwargs.pop('supereq', False)
91110
_include_type = kwargs.pop('include_type', False)
92111

93112
if kwargs:
@@ -98,14 +117,15 @@ def EqHash(*names,
98117

99118
def x(cls):
100119
__eq__, __hash__, __ne__ = _eq_hash(cls, names,
101-
_include_super, _include_type, superhash)
120+
_include_super, _include_type, superhash,
121+
supereq)
102122
cls.__eq__ = __eq__
103123
cls.__hash__ = __hash__
104124
cls.__ne__ = __ne__
105125
return cls
106126
return x
107127

108-
def _make_eq(cls, names, include_super, include_type): # pylint:disable=unused-argument
128+
def _make_eq(cls, names, include_super, include_type, supereq): # pylint:disable=unused-argument
109129
# 1 and 0 are constants and faster to load than the globals True/False
110130
# (in python 2)
111131

@@ -131,25 +151,38 @@ def _make_eq(cls, names, include_super, include_type): # pylint:disable=unused-a
131151
eq_stmt += ' a = self.' + name + '\n'
132152
eq_stmt += ' try:\n b = other.' + name + '\n'
133153
eq_stmt += ' except AttributeError: return NotImplemented\n'
134-
eq_stmt += ' if a != b: return 0\n\n'
135-
136-
eq_stmt += ' return 1'
154+
if supereq:
155+
# pylint:disable-next=line-too-long
156+
eq_stmt += ' if a != b and type(a) != type(b):\n'
157+
# We use isinstance rather than exact ``type(a) in (list, tuple)`` to work for
158+
# subclasses of the standard library classes, like named tuples. However, the
159+
# consequence is that if the user-subclass defined special behaviour, we lose
160+
# it. That's why we check a!=b FIRST so if there was custom behaviour that
161+
# made it pass, it still gets called. The cost is calling the __ne__ method twice.
162+
eq_stmt += ' if isinstance(a, (list, tuple)) and isinstance(b, (list,tuple)):\n'
163+
eq_stmt += ' a = tuple(a); b = tuple(b)\n'
164+
# sets and frozensets compare correctly automatically.
165+
eq_stmt += ' else: return False\n'
166+
eq_stmt += ' if a != b: return False\n\n'
167+
168+
eq_stmt += ' return True'
137169

138170
# Must use a custom dictionary under Py3
139171
lcls = dict(locals())
140172
exec(eq_stmt, globals(), lcls) # pylint:disable=exec-used
141173

142174
return lcls['__eq__']
143175

144-
def _eq_hash(cls, names, include_super, include_type, superhash):
145-
# pylint:disable=too-complex
176+
def _eq_hash(cls, names, include_super, include_type,
177+
superhash=False, supereq=False):
178+
# pylint:disable=too-complex,too-many-locals,too-many-positional-arguments
146179
names = tuple((str(x) for x in names)) # make sure they're native strings, not unicode on Py2
147180
# We assume the class hierarchy of these objects does not change
148181
if include_super:
149182
superclass = cls.__mro__[1]
150183
superclass_hash = superclass.__hash__
151184

152-
__eq__ = _make_eq(cls, names, include_super, include_type)
185+
__eq__ = _make_eq(cls, names, include_super, include_type, supereq)
153186

154187
def __ne__(self, other):
155188
eq = __eq__(self, other)

src/nti/schema/tests/test_eqhash.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ class Thing(object):
3333
a = 'a'
3434
b = 'b'
3535

36-
@EqHash('a', 'b', superhash=True)
36+
@EqHash('a', 'b', superhash=True, supereq=True)
3737
class Thing2(object):
3838
a = 'a'
3939
b = 'b'
@@ -69,6 +69,24 @@ class ManyThing(object):
6969

7070
class TestEqHash(unittest.TestCase):
7171

72+
def test_eq_hash_list_tuple(self):
73+
thing1_a = Thing()
74+
thing1_b = Thing()
75+
# Lists and tuples can't be compared.
76+
thing1_a.a = ()
77+
thing1_b.a = []
78+
self.assertNotEqual(thing1_a, thing1_b)
79+
80+
# sets and frozensets can
81+
thing1_a.a = set() # pylint: disable=redefined-variable-type
82+
thing1_b.a = frozenset() # pylint: disable=redefined-variable-type
83+
self.assertEqual(thing1_a, thing1_b)
84+
85+
# thing2 uses superhash to compare lists and tuples
86+
thing2_a = Thing2(a=[])
87+
thing2_b = Thing2(a=())
88+
self.assertEqual(thing2_a, thing2_b)
89+
7290
def test_eq_hash(self):
7391

7492
thing1 = Thing()

0 commit comments

Comments
 (0)