Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix hashing for custom eq objects #909

Merged
merged 11 commits into from Jan 31, 2022
1 change: 1 addition & 0 deletions changelog.d/909.change.rst
@@ -0,0 +1 @@
If an eq key is defined, it is also used before hashing the attribute.
17 changes: 12 additions & 5 deletions src/attr/_make.py
Expand Up @@ -325,13 +325,11 @@ def _compile_and_eval(script, globs, locs=None, filename=""):
eval(bytecode, globs, locs)


def _make_method(name, script, filename, globs=None):
def _make_method(name, script, filename, globs):
"""
Create the method with the script given and return the method object.
"""
locs = {}
if globs is None:
globs = {}

# In order of debuggers like PDB being able to step through the code,
# we add a fake linecache entry.
Expand Down Expand Up @@ -1680,6 +1678,8 @@ def _make_hash(cls, attrs, frozen, cache_hash):

unique_filename = _generate_unique_filename(cls, "hash")
type_hash = hash(unique_filename)
# If eq is custom generated, we need to include the functions in globs
globs = {}

hash_def = "def __hash__(self"
hash_func = "hash(("
Expand Down Expand Up @@ -1714,7 +1714,14 @@ def append_hash_computation_lines(prefix, indent):
)

for a in attrs:
method_lines.append(indent + " self.%s," % a.name)
if a.eq_key:
cmp_name = "_%s_key" % (a.name,)
globs[cmp_name] = a.eq_key
method_lines.append(
indent + " %s(self.%s)," % (cmp_name, a.name)
)
else:
method_lines.append(indent + " self.%s," % a.name)

method_lines.append(indent + " " + closing_braces)

Expand All @@ -1734,7 +1741,7 @@ def append_hash_computation_lines(prefix, indent):
append_hash_computation_lines("return ", tab)

script = "\n".join(method_lines)
return _make_method("__hash__", script, unique_filename)
return _make_method("__hash__", script, unique_filename, globs)


def _add_hash(cls, attrs):
Expand Down
21 changes: 21 additions & 0 deletions tests/test_make.py
Expand Up @@ -2057,6 +2057,27 @@ def __repr__(self):

assert "hi" == repr(C(42))

@pytest.mark.parametrize("slots", [True, False])
@pytest.mark.parametrize("frozen", [True, False])
def test_hash_uses_eq(self, slots, frozen):
"""
If eq is passed in, then __hash__ should use the eq callable
to generate the hash code.
"""

@attr.s(slots=slots, frozen=frozen, hash=True)
class C(object):
x = attr.ib(eq=str)

@attr.s(slots=slots, frozen=frozen, hash=True)
class D(object):
x = attr.ib()

# These hashes should be the same because 1 is turned into
# string before hashing.
assert hash(C("1")) == hash(C(1))
assert hash(D("1")) != hash(D(1))

@pytest.mark.parametrize("slots", [True, False])
@pytest.mark.parametrize("frozen", [True, False])
def test_detect_auto_hash(self, slots, frozen):
Expand Down