diff --git a/mypy/plugins/dataclasses.py b/mypy/plugins/dataclasses.py index 2b4982a36bb6..d9038c5ad4b2 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,30 @@ 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 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", + self._cls, + code=errorcodes.OVERRIDE, + ) + 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-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 diff --git a/test-data/unit/check-dataclasses.test b/test-data/unit/check-dataclasses.test index dbcb4c82072c..50a77aaf83c2 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,9 +269,25 @@ 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] [case testDataclassesFields] @@ -2610,3 +2627,59 @@ 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] + +[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] 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