From b917ad4ca174db431090fb10167caab0ad6233ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Furga=C5=82a?= <83299832+00200200@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:11:30 +0200 Subject: [PATCH 1/2] Mix instance type into generated hashes. Undecorated subclasses inherited a hash that only captured the base class identity, so frozen subclasses with equal fields collided. Use id(type(self)) so each subclass hashes distinctly. --- changelog.d/528.change.md | 6 ++++++ docs/hashing.md | 2 +- src/attr/_make.py | 5 +++-- tests/test_dunders.py | 31 +++++++++++++++++++++++++++++++ 4 files changed, 41 insertions(+), 3 deletions(-) create mode 100644 changelog.d/528.change.md diff --git a/changelog.d/528.change.md b/changelog.d/528.change.md new file mode 100644 index 000000000..4641f8b62 --- /dev/null +++ b/changelog.d/528.change.md @@ -0,0 +1,6 @@ +Generated `__hash__` methods now mix in `id(type(self))` instead of a +class identity captured when the method was generated. + +Undecorated subclasses of frozen classes therefore no longer share hash +values for equal field contents. +[#528](https://github.com/python-attrs/attrs/issues/528) diff --git a/docs/hashing.md b/docs/hashing.md index 231d818af..d9f48600e 100644 --- a/docs/hashing.md +++ b/docs/hashing.md @@ -21,7 +21,7 @@ It can be obtained by calling {func}`hash` on an object and is implemented by wr *attrs* will happily write a `__hash__` method for you [^fn1], however it will *not* do so by default. Because according to the [definition](https://docs.python.org/3/glossary.html#term-hashable) from the official Python docs, the returned hash has to fulfill certain constraints: -[^fn1]: The hash is computed by hashing a tuple that consists of a unique id for the class plus all attribute values. +[^fn1]: The hash is computed by hashing a tuple that consists of `id(type(self))` plus all attribute values. 1. Two objects that are equal, **must** have the same hash. This means that if `x == y`, it *must* follow that `hash(x) == hash(y)`. diff --git a/src/attr/_make.py b/src/attr/_make.py index afbca4635..2f1107893 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -1650,7 +1650,6 @@ def _make_hash_script( tab = " " - type_hash = hash(_generate_unique_filename(cls, "hash")) # If eq is custom generated, we need to include the functions in globs globs = {} @@ -1678,7 +1677,9 @@ def append_hash_computation_lines(prefix, indent): method_lines.extend( [ indent + prefix + hash_func, - indent + f" {type_hash},", + # Use the instance class, not the class that generated + # ``__hash__``, so undecorated subclasses do not collide. + indent + " id(type(self)),", ] ) diff --git a/tests/test_dunders.py b/tests/test_dunders.py index f299f777a..d57ad30be 100644 --- a/tests/test_dunders.py +++ b/tests/test_dunders.py @@ -621,6 +621,37 @@ def test_hash_works(self, cls): # __hash__ for the cached-hash versions assert hash(a) != hash(b) + @pytest.mark.parametrize("slots", [True, False]) + @pytest.mark.parametrize("cache_hash", [True, False]) + def test_inherited_hash_includes_instance_type(self, slots, cache_hash): + """ + Generated `__hash__` mixes in `type(self)`, not only the class that + defined the method. + + Undecorated subclasses therefore get distinct hashes even though they + inherit `__hash__` from the base class. See GH #528. + """ + Base = make_class( + "Base", + {"x": attr.ib()}, + frozen=True, + slots=slots, + cache_hash=cache_hash, + ) + + class SubA(Base): + pass + + class SubB(Base): + pass + + assert SubA(1) != SubB(1) + assert SubA(1) != Base(1) + assert hash(SubA(1)) != hash(SubB(1)) + assert hash(SubA(1)) != hash(Base(1)) + assert hash(SubA(1)) == hash(SubA(1)) + assert hash(SubA(1)) != hash(SubA(2)) + def test_hash_default(self): """ Classes are not hashable by default. From cfc1c251323078f033cad9cd9b9378a13b4f53ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Furga=C5=82a?= <83299832+00200200@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:11:48 +0200 Subject: [PATCH 2/2] Name the news fragment after the pull request. --- changelog.d/{528.change.md => 1630.change.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{528.change.md => 1630.change.md} (100%) diff --git a/changelog.d/528.change.md b/changelog.d/1630.change.md similarity index 100% rename from changelog.d/528.change.md rename to changelog.d/1630.change.md