Skip to content

Adds __hash__ method to @dataclasses.dataclass, refs #11463 #11496

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

Closed
wants to merge 10 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions mypy/message_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -257,3 +257,12 @@ def format(self, *args: object, **kwargs: object) -> "ErrorMessage":
CLASS_PATTERN_UNKNOWN_KEYWORD: Final = 'Class "{}" has no attribute "{}"'
MULTIPLE_ASSIGNMENTS_IN_PATTERN: Final = 'Multiple assignments to name "{}" in pattern'
CANNOT_MODIFY_MATCH_ARGS: Final = 'Cannot assign to "__match_args__"'

# Dataclass plugin
DATACLASS_VERSION_DEPENDENT_KEYWORD: Final = (
'Keyword argument "{}" for "dataclass" is only valid in Python {} and higher'
)
DATACLASS_TWO_KINDS_OF_SLOTS: Final = (
'"{}" both defines "__slots__" and is used with "slots=True"'
)
DATACLASS_HASH_OVERRIDE: Final = 'Cannot overwrite attribute "__hash__" in class "{}"'
12 changes: 11 additions & 1 deletion mypy/plugins/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,16 @@
from mypy.fixup import TypeFixer


def _has_decorator_argument(ctx: ClassDefContext, name: str) -> bool:
"""Returns whether or not some argument was passed to a decorator.

We mostly need this because some arguments are version specific.
"""
if isinstance(ctx.reason, CallExpr):
return bool(name) and name in ctx.reason.arg_names
return False


def _get_decorator_bool_argument(
ctx: ClassDefContext,
name: str,
Expand Down Expand Up @@ -157,7 +167,7 @@ def add_method_to_class(


def add_attribute_to_class(
api: SemanticAnalyzerPluginInterface,
api: Union[SemanticAnalyzerPluginInterface, CheckerPluginInterface],
cls: ClassDef,
name: str,
typ: Type,
Expand Down
94 changes: 82 additions & 12 deletions mypy/plugins/dataclasses.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from typing import Dict, List, Set, Tuple, Optional
from typing_extensions import Final

from mypy import message_registry
from mypy.nodes import (
ARG_OPT, ARG_NAMED, ARG_NAMED_OPT, ARG_POS, ARG_STAR, ARG_STAR2, MDEF,
Argument, AssignmentStmt, CallExpr, TypeAlias, Context, Expression, JsonDict,
Expand All @@ -11,7 +12,8 @@
)
from mypy.plugin import ClassDefContext, SemanticAnalyzerPluginInterface
from mypy.plugins.common import (
add_method, _get_decorator_bool_argument, deserialize_and_fixup_type, add_attribute_to_class,
add_method, _get_decorator_bool_argument, deserialize_and_fixup_type,
_has_decorator_argument, add_attribute_to_class,
)
from mypy.typeops import map_type_from_supertype
from mypy.types import (
Expand All @@ -31,7 +33,6 @@
'dataclasses.field',
}


SELF_TVAR_NAME: Final = "_DT"


Expand Down Expand Up @@ -138,10 +139,14 @@ def transform(self) -> bool:
'eq': _get_decorator_bool_argument(self._ctx, 'eq', True),
'order': _get_decorator_bool_argument(self._ctx, 'order', False),
'frozen': _get_decorator_bool_argument(self._ctx, 'frozen', False),
'slots': _get_decorator_bool_argument(self._ctx, 'slots', False),
'match_args': _get_decorator_bool_argument(self._ctx, 'match_args', True),
'unsafe_hash': _get_decorator_bool_argument(self._ctx, 'unsafe_hash', False),
}
py_version = self._ctx.api.options.python_version
if py_version >= (3, 10):
decorator_arguments.update({
'slots': _get_decorator_bool_argument(self._ctx, 'slots', False),
})

# If there are no attributes, it may be that the semantic analyzer has not
# processed them yet. In order to work around this, we can simply skip generating
Expand Down Expand Up @@ -222,8 +227,8 @@ def transform(self) -> bool:
else:
self._propertize_callables(attributes)

if decorator_arguments['slots']:
self.add_slots(info, attributes, correct_version=py_version >= (3, 10))
self.add_slots(info, decorator_arguments, attributes, current_version=py_version)
self.add_hash(info, decorator_arguments)

self.reset_init_only_vars(info, attributes)

Expand All @@ -249,18 +254,20 @@ def transform(self) -> bool:

def add_slots(self,
info: TypeInfo,
decorator_arguments: Dict[str, bool],
attributes: List[DataclassAttribute],
*,
correct_version: bool) -> None:
if not correct_version:
current_version: Tuple[int, ...]) -> None:
if _has_decorator_argument(self._ctx, 'slots') and current_version < (3, 10):
# This means that version is lower than `3.10`,
# it is just a non-existent argument for `dataclass` function.
self._ctx.api.fail(
'Keyword argument "slots" for "dataclass" '
'is only valid in Python 3.10 and higher',
message_registry.DATACLASS_VERSION_DEPENDENT_KEYWORD.format('slots', '3.10'),
self._ctx.reason,
)
return
if not decorator_arguments.get('slots'):
return # `slots` is not provided, skip.

generated_slots = {attr.name for attr in attributes}
if ((info.slots is not None and info.slots != generated_slots)
Expand All @@ -270,15 +277,72 @@ def add_slots(self,
# And `@dataclass(slots=True)` is used.
# In runtime this raises a type error.
self._ctx.api.fail(
'"{}" both defines "__slots__" and is used with "slots=True"'.format(
self._ctx.cls.name,
),
message_registry.DATACLASS_TWO_KINDS_OF_SLOTS.format(self._ctx.cls.name),
self._ctx.cls,
)
return

info.slots = generated_slots

def add_hash(self,
info: TypeInfo,
decorator_arguments: Dict[str, bool]) -> None:
unsafe_hash = decorator_arguments.get('unsafe_hash', False)
eq = decorator_arguments['eq']
frozen = decorator_arguments['frozen']

existing_hash = info.names.get('__hash__')
existing = existing_hash and not existing_hash.plugin_generated

# https://github.com/python/cpython/blob/24af9a40a8f85af813ea89998aa4e931fcc78cd9/Lib/dataclasses.py#L846
if ((not unsafe_hash and not eq and not frozen)
or (not unsafe_hash and not eq and frozen)):
# "No __eq__, use the base class __hash__"
# It will use the base's class `__hash__` method by default.
# Nothing to do here.
pass
elif not unsafe_hash and eq and not frozen:
# "the default, not hashable"
# In this case, we just add `__hash__: None` to the body of the class
if not existing:
add_attribute_to_class(
self._ctx.api,
self._ctx.cls,
name='__hash__',
typ=NoneType(),
)
elif not unsafe_hash and eq and frozen:
# "Frozen, so hashable, allows override"
# In this case we never raise an error, even if superclass definition
# is incompatible.
if not existing:
add_method(
self._ctx,
name='__hash__',
args=[],
return_type=self._ctx.api.named_type('builtins.int'),
)
else:
# "Has no __eq__, but hashable" or
# "Not frozen, but hashable" or
# "Frozen, so hashable"
if existing:
# When class already has `__hash__` defined, we do not allow
# to override it. So, raise an error and do nothing.
self._ctx.api.fail(
message_registry.DATACLASS_HASH_OVERRIDE.format(self._ctx.cls.name),
self._ctx.cls,
)
return

# This means that class does not have `__hash__`, but we can add it.
add_method(
self._ctx,
name='__hash__',
args=[],
return_type=self._ctx.api.named_type('builtins.int'),
)

def reset_init_only_vars(self, info: TypeInfo, attributes: List[DataclassAttribute]) -> None:
"""Remove init-only vars from the class and reset init var declarations."""
for attr in attributes:
Expand Down Expand Up @@ -326,6 +390,12 @@ def collect_attributes(self) -> Optional[List[DataclassAttribute]]:
lhs = stmt.lvalues[0]
if not isinstance(lhs, NameExpr):
continue
if lhs.name == '__hash__':
# Special case, annotation like `__hash__: None` is fine
# It is not a field, it is:
# <slot wrapper '__hash__' of 'object' objects>
# https://github.com/python/mypy/issues/11495
continue

sym = cls.info.names.get(lhs.name)
if sym is None:
Expand Down
166 changes: 166 additions & 0 deletions test-data/unit/check-dataclasses.test
Original file line number Diff line number Diff line change
Expand Up @@ -1483,10 +1483,15 @@ class DynamicDef: # E: "DynamicDef" both defines "__slots__" and is used with "
x: int
[builtins fixtures/dataclasses.pyi]


[case testDataclassWithSlotsArgBefore310]
# flags: --python-version 3.9
from dataclasses import dataclass

@dataclass() # ok
class Correct:
x: int

@dataclass(slots=True) # E: Keyword argument "slots" for "dataclass" is only valid in Python 3.10 and higher
class Some:
x: int
Expand All @@ -1496,6 +1501,139 @@ class Some:
class Other:
__slots__ = ('x',)
x: int

@dataclass(slots=False) # E: Keyword argument "slots" for "dataclass" is only valid in Python 3.10 and higher
class Third:
x: int
[builtins fixtures/dataclasses.pyi]


[case testDataclassWithUnsafeHashFalse]
# flags: --python-version 3.7
from dataclasses import dataclass

@dataclass(unsafe_hash=False, eq=False, frozen=False)
class FirstCase1:
x: int
reveal_type(FirstCase1(1).__hash__) # N: Revealed type is "def () -> builtins.int"

@dataclass(unsafe_hash=False, eq=False, frozen=False)
class FirstCase2:
x: int
def __hash__(self) -> int: pass
reveal_type(FirstCase2(1).__hash__) # N: Revealed type is "def () -> builtins.int"

@dataclass(unsafe_hash=False, eq=False, frozen=False)
class FirstCase3:
x: int
__hash__: None # E: Incompatible types in assignment (expression has type "None", base class "object" defined the type as "Callable[[object], int]")
reveal_type(FirstCase3(1).__hash__) # N: Revealed type is "None"

@dataclass(unsafe_hash=False, eq=False, frozen=True)
class FirstCase4:
x: int
reveal_type(FirstCase4(1).__hash__) # N: Revealed type is "def () -> builtins.int"

@dataclass(unsafe_hash=False, eq=False, frozen=True)
class FirstCase5:
x: int
def __hash__(self) -> int: pass
reveal_type(FirstCase5(1).__hash__) # N: Revealed type is "def () -> builtins.int"

@dataclass(unsafe_hash=False, eq=False, frozen=True)
class FirstCase6:
x: int
__hash__: None # E: Incompatible types in assignment (expression has type "None", base class "object" defined the type as "Callable[[object], int]")
reveal_type(FirstCase6(1).__hash__) # N: Revealed type is "None"


@dataclass(unsafe_hash=False, eq=True, frozen=False)
class SecondCase1:
x: int
reveal_type(SecondCase1(1).__hash__) # N: Revealed type is "None"

@dataclass(unsafe_hash=False, eq=True, frozen=False)
class SecondCase2:
x: int
__hash__: None # E: Incompatible types in assignment (expression has type "None", base class "object" defined the type as "Callable[[object], int]")
reveal_type(SecondCase2(1).__hash__) # N: Revealed type is "None"

@dataclass(unsafe_hash=False, eq=True, frozen=False)
class SecondCase3:
x: int
def __hash__(self) -> int: pass # Custom impl
reveal_type(SecondCase3(1).__hash__) # N: Revealed type is "def () -> builtins.int"


@dataclass(unsafe_hash=False, eq=True, frozen=True)
class ThirdCase1:
x: int
reveal_type(ThirdCase1(1).__hash__) # N: Revealed type is "def () -> builtins.int"

@dataclass(unsafe_hash=False, eq=True, frozen=True)
class ThirdCase2:
x: int
def __hash__(self) -> int: pass # Custom impl
reveal_type(ThirdCase2(1).__hash__) # N: Revealed type is "def () -> builtins.int"

@dataclass(unsafe_hash=False, eq=True, frozen=True)
class ThirdCase3:
x: int
__hash__: None # E: Incompatible types in assignment (expression has type "None", base class "object" defined the type as "Callable[[object], int]")
reveal_type(ThirdCase3(1).__hash__) # N: Revealed type is "None"
[builtins fixtures/dataclasses.pyi]


[case testDataclassWithUnsafeHashTrue]
# flags: --python-version 3.7
from dataclasses import dataclass

@dataclass(unsafe_hash=True, eq=False, frozen=False)
class FirstCase1:
x: int
reveal_type(FirstCase1(1).__hash__) # N: Revealed type is "def () -> builtins.int"

@dataclass(unsafe_hash=True, eq=False, frozen=False)
class FirstCase2: # E: Cannot overwrite attribute "__hash__" in class "FirstCase2"
x: int
def __hash__(self) -> int: pass
reveal_type(FirstCase1(1).__hash__) # N: Revealed type is "def () -> builtins.int"


@dataclass(unsafe_hash=True, eq=False, frozen=True)
class SecondCase1:
x: int
reveal_type(SecondCase1(1).__hash__) # N: Revealed type is "def () -> builtins.int"

@dataclass(unsafe_hash=True, eq=False, frozen=True)
class SecondCase2: # E: Cannot overwrite attribute "__hash__" in class "SecondCase2"
x: int
def __hash__(self) -> int: pass
reveal_type(SecondCase1(1).__hash__) # N: Revealed type is "def () -> builtins.int"


@dataclass(unsafe_hash=True, eq=True, frozen=False)
class ThirdCase1:
x: int
reveal_type(ThirdCase1(1).__hash__) # N: Revealed type is "def () -> builtins.int"

@dataclass(unsafe_hash=True, eq=True, frozen=False)
class ThirdCase2: # E: Cannot overwrite attribute "__hash__" in class "ThirdCase2"
x: int
def __hash__(self) -> int: pass
reveal_type(ThirdCase1(1).__hash__) # N: Revealed type is "def () -> builtins.int"


@dataclass(unsafe_hash=True, eq=True, frozen=True)
class FourthCase1:
x: int
reveal_type(FourthCase1(1).__hash__) # N: Revealed type is "def () -> builtins.int"

@dataclass(unsafe_hash=True, eq=True, frozen=True)
class FourthCase2: # E: Cannot overwrite attribute "__hash__" in class "FourthCase2"
x: int
def __hash__(self) -> int: pass
reveal_type(FourthCase1(1).__hash__) # N: Revealed type is "def () -> builtins.int"
[builtins fixtures/dataclasses.pyi]


Expand Down Expand Up @@ -1578,6 +1716,34 @@ A(1)
A(a="foo") # E: Argument "a" to "A" has incompatible type "str"; expected "int"
[builtins fixtures/dataclasses.pyi]

[case testDataclassHashDefault]
# flags: --python-version 3.7
from dataclasses import dataclass

@dataclass
class FirstCase:
x: int
reveal_type(FirstCase(1).__hash__) # N: Revealed type is "None"

@dataclass
class SecondCase:
x: int
def __hash__(self) -> int: pass
reveal_type(SecondCase(1).__hash__) # N: Revealed type is "def () -> builtins.int"
[builtins fixtures/dataclasses.pyi]

[case testSemanalTwoPassesWhileHashGeneration]
# flags: --python-version 3.7
from dataclasses import dataclass

a = f() # Forward ref to force two semantic analysis passes

@dataclass(unsafe_hash=True) # Used to be an error here:
class C: # Cannot overwrite attribute "__hash__" in class "C"
x: str

def f(): pass

[case testDataclassesCallableFrozen]
# flags: --python-version 3.7
from dataclasses import dataclass
Expand Down
Loading