From c544e9809487593e2fc3b88fee0036d25b95fa16 Mon Sep 17 00:00:00 2001 From: STerliakov Date: Tue, 6 May 2025 17:26:00 +0200 Subject: [PATCH 1/6] Generate dataclasses `__hash__` accroding to runtime semantics --- mypy/plugins/dataclasses.py | 32 +++++++++++++++++++++++++++ test-data/unit/check-dataclasses.test | 26 ++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/mypy/plugins/dataclasses.py b/mypy/plugins/dataclasses.py index 2b4982a36bb6..9f6201b5af2a 100644 --- a/mypy/plugins/dataclasses.py +++ b/mypy/plugins/dataclasses.py @@ -247,6 +247,7 @@ def transform(self) -> bool: "order": self._get_bool_arg("order", self._spec.order_default), "frozen": self._get_bool_arg("frozen", self._spec.frozen_default), "slots": self._get_bool_arg("slots", False), + "unsafe_hash": self._get_bool_arg("unsafe_hash", False), "match_args": self._get_bool_arg("match_args", True), } py_version = self._api.options.python_version @@ -291,6 +292,14 @@ def transform(self) -> bool: self._api, self._cls, "__init__", args=args, return_type=NoneType() ) + if "__hash__" not in info.names or info.names["__hash__"].plugin_generated: + # Presence of __hash__ usually isn't checked. However, when inheriting from + # an abstract Hashable, we need to override the abstract __hash__ to avoid + # false positives. + self._add_dunder_hash( + is_hashable=decorator_arguments["unsafe_hash"] or decorator_arguments["frozen"] + ) + if ( decorator_arguments["eq"] and info.get("__eq__") is None @@ -446,6 +455,29 @@ def _add_internal_post_init_method(self, attributes: list[DataclassAttribute]) - return_type=NoneType(), ) + def _add_dunder_hash(self, is_hashable: bool) -> None: + if is_hashable: + add_method_to_class( + self._api, + self._cls, + "__hash__", + args=[], + return_type=self._api.named_type("builtins.int"), + ) + else: + # Python sets `__hash__ = None` otherwise, do the same. + parent_method = self._cls.info.get_method("__hash__") + if parent_method is not None: + # If we inherited `__hash__`, ensure it isn't overridden + self._api.fail( + "Incompatible override of '__hash__': dataclasses without" + " 'frozen' or 'unsafe_hash' have '__hash__' set to None", + self._cls, + ) + add_attribute_to_class( + self._api, self._cls, "__hash__", typ=NoneType(), is_classvar=True + ) + def add_slots( self, info: TypeInfo, attributes: list[DataclassAttribute], *, correct_version: bool ) -> None: diff --git a/test-data/unit/check-dataclasses.test b/test-data/unit/check-dataclasses.test index dbcb4c82072c..aad866957165 100644 --- a/test-data/unit/check-dataclasses.test +++ b/test-data/unit/check-dataclasses.test @@ -2610,3 +2610,29 @@ class B2(B1): # E: A NamedTuple cannot be a dataclass pass [builtins fixtures/tuple.pyi] + +[case testDataclassHash] +from abc import ABC, abstractmethod +import dataclasses + +class Hashable(ABC): + @abstractmethod + def __hash__(self) -> int: + ... + +@dataclasses.dataclass(frozen=True) +class Good(Hashable): + a: int + +@dataclasses.dataclass(unsafe_hash=True) +class AlsoGood(Hashable): + a: int + +@dataclasses.dataclass() +class Bad(Hashable): # E: Incompatible override of '__hash__': dataclasses without 'frozen' or 'unsafe_hash' have '__hash__' set to None + a: int + +Good(4) +AlsoGood(4) +Bad(4) +[builtins fixtures/tuple.pyi] From 7d7045942ae95891f3d3ec00078f1915f18532fb Mon Sep 17 00:00:00 2001 From: STerliakov Date: Tue, 6 May 2025 17:29:04 +0200 Subject: [PATCH 2/6] Ignore object.__hash__ as we do everywhere else --- mypy/plugins/dataclasses.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mypy/plugins/dataclasses.py b/mypy/plugins/dataclasses.py index 9f6201b5af2a..88ce147204d0 100644 --- a/mypy/plugins/dataclasses.py +++ b/mypy/plugins/dataclasses.py @@ -467,8 +467,8 @@ def _add_dunder_hash(self, is_hashable: bool) -> None: else: # Python sets `__hash__ = None` otherwise, do the same. parent_method = self._cls.info.get_method("__hash__") - if parent_method is not None: - # If we inherited `__hash__`, ensure it isn't overridden + if parent_method is not None and parent_method.info.fullname != "builtins.object": + # If we inherited `__hash__` not from `object`, ensure it isn't overridden self._api.fail( "Incompatible override of '__hash__': dataclasses without" " 'frozen' or 'unsafe_hash' have '__hash__' set to None", From 1ed3f69295a573e4853f729a97e42ac365488df0 Mon Sep 17 00:00:00 2001 From: STerliakov Date: Tue, 6 May 2025 17:33:34 +0200 Subject: [PATCH 3/6] Update another affected test, add explicit example of None -> method override. --- test-data/unit/check-dataclasses.test | 40 +++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/test-data/unit/check-dataclasses.test b/test-data/unit/check-dataclasses.test index aad866957165..7770ef76fd57 100644 --- a/test-data/unit/check-dataclasses.test +++ b/test-data/unit/check-dataclasses.test @@ -260,7 +260,8 @@ class FrozenBase: pass @dataclass -class BadNormalDerived(FrozenBase): # E: Non-frozen dataclass cannot inherit from a frozen dataclass +class BadNormalDerived(FrozenBase): # E: Incompatible override of '__hash__': dataclasses without 'frozen' or 'unsafe_hash' have '__hash__' set to None \ + # E: Non-frozen dataclass cannot inherit from a frozen dataclass pass @dataclass @@ -268,7 +269,12 @@ class NormalBase: pass @dataclass(frozen=True) -class BadFrozenDerived(NormalBase): # E: Frozen dataclass cannot inherit from a non-frozen dataclass +class BadFrozenDerived(NormalBase): # E: Signature of "__hash__" incompatible with supertype "NormalBase" \ + # N: Superclass: \ + # N: None \ + # N: Subclass: \ + # N: def __hash__() -> int \ + # E: Frozen dataclass cannot inherit from a non-frozen dataclass pass [builtins fixtures/dataclasses.pyi] @@ -2636,3 +2642,33 @@ Good(4) AlsoGood(4) Bad(4) [builtins fixtures/tuple.pyi] + +[case testDataclassHash2] +import dataclasses + +# This +@dataclasses.dataclass() +class Parent: + a: int + +# is (at runtime, ignoring other methods) the same as +class ExplicitParent: + a: int + __hash__ = None + +class Child(Parent): + def __hash__(self) -> int: # E: Signature of "__hash__" incompatible with supertype "Parent" \ + # N: Superclass: \ + # N: None \ + # N: Subclass: \ + # N: def __hash__(self) -> int + return 0 + +class ExplicitChild(ExplicitParent): + def __hash__(self) -> int: # E: Signature of "__hash__" incompatible with supertype "ExplicitParent" \ + # N: Superclass: \ + # N: None \ + # N: Subclass: \ + # N: def __hash__(self) -> int + return 0 +[builtins fixtures/tuple.pyi] From c6169b681ccf84ce5345fd0bdd5a75c2d7275480 Mon Sep 17 00:00:00 2001 From: STerliakov Date: Tue, 6 May 2025 17:39:39 +0200 Subject: [PATCH 4/6] Use `self._reason` as error position instead --- mypy/plugins/dataclasses.py | 2 +- test-data/unit/check-dataclasses.test | 15 ++++++++++----- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/mypy/plugins/dataclasses.py b/mypy/plugins/dataclasses.py index 88ce147204d0..5ee6ba65dddf 100644 --- a/mypy/plugins/dataclasses.py +++ b/mypy/plugins/dataclasses.py @@ -472,7 +472,7 @@ def _add_dunder_hash(self, is_hashable: bool) -> None: self._api.fail( "Incompatible override of '__hash__': dataclasses without" " 'frozen' or 'unsafe_hash' have '__hash__' set to None", - self._cls, + self._reason, ) add_attribute_to_class( self._api, self._cls, "__hash__", typ=NoneType(), is_classvar=True diff --git a/test-data/unit/check-dataclasses.test b/test-data/unit/check-dataclasses.test index 7770ef76fd57..478edc84d347 100644 --- a/test-data/unit/check-dataclasses.test +++ b/test-data/unit/check-dataclasses.test @@ -259,9 +259,8 @@ from dataclasses import dataclass class FrozenBase: pass -@dataclass -class BadNormalDerived(FrozenBase): # E: Incompatible override of '__hash__': dataclasses without 'frozen' or 'unsafe_hash' have '__hash__' set to None \ - # E: Non-frozen dataclass cannot inherit from a frozen dataclass +@dataclass # E: Incompatible override of '__hash__': dataclasses without 'frozen' or 'unsafe_hash' have '__hash__' set to None +class BadNormalDerived(FrozenBase): # E: Non-frozen dataclass cannot inherit from a frozen dataclass pass @dataclass @@ -277,6 +276,12 @@ class BadFrozenDerived(NormalBase): # E: Signature of "__hash__" incompatible w # E: Frozen dataclass cannot inherit from a non-frozen dataclass pass + + + + + + [builtins fixtures/dataclasses.pyi] [case testDataclassesFields] @@ -2634,8 +2639,8 @@ class Good(Hashable): class AlsoGood(Hashable): a: int -@dataclasses.dataclass() -class Bad(Hashable): # E: Incompatible override of '__hash__': dataclasses without 'frozen' or 'unsafe_hash' have '__hash__' set to None +@dataclasses.dataclass() # E: Incompatible override of '__hash__': dataclasses without 'frozen' or 'unsafe_hash' have '__hash__' set to None +class Bad(Hashable): a: int Good(4) From e5ac5936e08e9c0658959d8eaca38f7ff5fa970f Mon Sep 17 00:00:00 2001 From: STerliakov Date: Tue, 6 May 2025 18:36:29 +0200 Subject: [PATCH 5/6] Update other affected tests --- test-data/unit/check-dataclass-transform.test | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test-data/unit/check-dataclass-transform.test b/test-data/unit/check-dataclass-transform.test index 8213f8df282a..52dc89275859 100644 --- a/test-data/unit/check-dataclass-transform.test +++ b/test-data/unit/check-dataclass-transform.test @@ -821,7 +821,11 @@ from typing import dataclass_transform, Type class Meta(type): ... class Base(metaclass=Meta): base: int -class Foo(Base, frozen=True): +class Foo(Base, frozen=True): # E: Signature of "__hash__" incompatible with supertype "Base" \ + # N: Superclass: \ + # N: None \ + # N: Subclass: \ + # N: def __hash__() -> int foo: int class Bar(Base, frozen=False): bar: int From c1c15b2e30f8c532c5a00ae607565038ad3af5cd Mon Sep 17 00:00:00 2001 From: STerliakov Date: Wed, 7 May 2025 01:48:00 +0200 Subject: [PATCH 6/6] Amend failing test, use _cls again for error context and use appropriate code --- mypy/plugins/dataclasses.py | 3 ++- test-data/unit/check-dataclasses.test | 14 ++++++++++---- test-data/unit/deps.test | 2 ++ 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/mypy/plugins/dataclasses.py b/mypy/plugins/dataclasses.py index 5ee6ba65dddf..d9038c5ad4b2 100644 --- a/mypy/plugins/dataclasses.py +++ b/mypy/plugins/dataclasses.py @@ -472,7 +472,8 @@ def _add_dunder_hash(self, is_hashable: bool) -> None: self._api.fail( "Incompatible override of '__hash__': dataclasses without" " 'frozen' or 'unsafe_hash' have '__hash__' set to None", - self._reason, + self._cls, + code=errorcodes.OVERRIDE, ) add_attribute_to_class( self._api, self._cls, "__hash__", typ=NoneType(), is_classvar=True diff --git a/test-data/unit/check-dataclasses.test b/test-data/unit/check-dataclasses.test index 478edc84d347..50a77aaf83c2 100644 --- a/test-data/unit/check-dataclasses.test +++ b/test-data/unit/check-dataclasses.test @@ -259,8 +259,9 @@ from dataclasses import dataclass class FrozenBase: pass -@dataclass # E: Incompatible override of '__hash__': dataclasses without 'frozen' or 'unsafe_hash' have '__hash__' set to None -class BadNormalDerived(FrozenBase): # E: Non-frozen dataclass cannot inherit from a frozen dataclass +@dataclass +class BadNormalDerived(FrozenBase): # E: Incompatible override of '__hash__': dataclasses without 'frozen' or 'unsafe_hash' have '__hash__' set to None \ + # E: Non-frozen dataclass cannot inherit from a frozen dataclass pass @dataclass @@ -282,6 +283,11 @@ class BadFrozenDerived(NormalBase): # E: Signature of "__hash__" incompatible w + + + + + [builtins fixtures/dataclasses.pyi] [case testDataclassesFields] @@ -2639,8 +2645,8 @@ class Good(Hashable): class AlsoGood(Hashable): a: int -@dataclasses.dataclass() # E: Incompatible override of '__hash__': dataclasses without 'frozen' or 'unsafe_hash' have '__hash__' set to None -class Bad(Hashable): +@dataclasses.dataclass() +class Bad(Hashable): # E: Incompatible override of '__hash__': dataclasses without 'frozen' or 'unsafe_hash' have '__hash__' set to None a: int Good(4) diff --git a/test-data/unit/deps.test b/test-data/unit/deps.test index 2c231c9afff6..f3d7575c9c6e 100644 --- a/test-data/unit/deps.test +++ b/test-data/unit/deps.test @@ -1390,6 +1390,7 @@ class B(A): [out] -> , m -> + -> -> , m.B.__init__ -> , m, m.B.__mypy-replace -> @@ -1422,6 +1423,7 @@ class B(A): [out] -> , m -> + -> -> , m.B.__init__ -> -> , m, m.B.__mypy-replace