From f5fb5c388a9a75037ca44952230a27667960bf31 Mon Sep 17 00:00:00 2001 From: David Peter Date: Sun, 16 Nov 2025 10:52:30 +0100 Subject: [PATCH] [ty] Dataclasses: `__hash__` semantics and `unsafe_hash` (#21470) ## Summary Implement the semantics of `__hash__` for dataclasses and add support for `unsafe_hash` ## Test Plan New Markdown tests. --- .../mdtest/dataclasses/dataclasses.md | 66 ++++++++++++++++++- crates/ty_python_semantic/src/types/class.rs | 22 +++++++ 2 files changed, 86 insertions(+), 2 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/dataclasses/dataclasses.md b/crates/ty_python_semantic/resources/mdtest/dataclasses/dataclasses.md index 8b17128ef8..ba5151fa61 100644 --- a/crates/ty_python_semantic/resources/mdtest/dataclasses/dataclasses.md +++ b/crates/ty_python_semantic/resources/mdtest/dataclasses/dataclasses.md @@ -362,9 +362,71 @@ class AlreadyHasCustomDunderLt: return False ``` -### `unsafe_hash` +### `__hash__` and `unsafe_hash` -To do +If `eq` and `frozen` are both `True`, a `__hash__` method is generated by default: + +```py +from dataclasses import dataclass + +@dataclass(eq=True, frozen=True) +class WithHash: + x: int + +reveal_type(WithHash.__hash__) # revealed: (self: WithHash) -> int +``` + +If `eq` is set to `True` and `frozen` is set to `False`, `__hash__` will be set to `None`, to mark +is unhashable (because it is mutable): + +```py +from dataclasses import dataclass + +@dataclass(eq=True, frozen=False) +class WithoutHash: + x: int + +reveal_type(WithoutHash.__hash__) # revealed: None +``` + +If `eq` is set to `False`, `__hash__` will inherit from the parent class (which could be `object`). +Note that we see a revealed type of `def …` here, because `__hash__` refers to an actual function, +not a synthetic method like in the first example. + +```py +from dataclasses import dataclass +from typing import Any + +@dataclass(eq=False, frozen=False) +class InheritHash: + x: int + +reveal_type(InheritHash.__hash__) # revealed: def __hash__(self) -> int + +class Base: + # Type the `self` parameter as `Any` to distinguish it from `object.__hash__` + def __hash__(self: Any) -> int: + return 42 + +@dataclass(eq=False, frozen=False) +class InheritHash(Base): + x: int + +reveal_type(InheritHash.__hash__) # revealed: def __hash__(self: Any) -> int +``` + +If `unsafe_hash` is set to `True`, a `__hash__` method will be generated even if the dataclass is +mutable: + +```py +from dataclasses import dataclass + +@dataclass(eq=True, frozen=False, unsafe_hash=True) +class WithUnsafeHash: + x: int + +reveal_type(WithUnsafeHash.__hash__) # revealed: (self: WithUnsafeHash) -> int +``` ### `frozen` diff --git a/crates/ty_python_semantic/src/types/class.rs b/crates/ty_python_semantic/src/types/class.rs index e331b77ac3..b23de7d424 100644 --- a/crates/ty_python_semantic/src/types/class.rs +++ b/crates/ty_python_semantic/src/types/class.rs @@ -2400,6 +2400,28 @@ impl<'db> ClassLiteral<'db> { Some(CallableType::function_like(db, signature)) } + (CodeGeneratorKind::DataclassLike(_), "__hash__") => { + let unsafe_hash = has_dataclass_param(DataclassFlags::UNSAFE_HASH); + let frozen = has_dataclass_param(DataclassFlags::FROZEN); + let eq = has_dataclass_param(DataclassFlags::EQ); + + if unsafe_hash || (frozen && eq) { + let signature = Signature::new( + Parameters::new([Parameter::positional_or_keyword(Name::new_static( + "self", + )) + .with_annotated_type(instance_ty)]), + Some(KnownClass::Int.to_instance(db)), + ); + + Some(CallableType::function_like(db, signature)) + } else if eq && !frozen { + Some(Type::none(db)) + } else { + // No `__hash__` is generated, fall back to `object.__hash__` + None + } + } (CodeGeneratorKind::DataclassLike(_), "__match_args__") if Program::get(db).python_version(db) >= PythonVersion::PY310 => {