Skip to content

Handle __hash__ = None in somewhat more intuitive fashion #19168

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

Open
wants to merge 9 commits into
base: master
Choose a base branch
from
25 changes: 20 additions & 5 deletions mypy/checker.py
Original file line number Diff line number Diff line change
Expand Up @@ -2216,6 +2216,9 @@ def check_method_override_for_base_with_name(
# Will always fail to typecheck below, since we know the node is a method
original_type = NoneType()

if isinstance(original_node, Var) and original_node.allow_incompatible_override:
return False

always_allow_covariant = False
if is_settable_property(defn) and (
is_settable_property(original_node) or isinstance(original_node, Var)
Expand Down Expand Up @@ -3440,18 +3443,30 @@ def check_compatibility_all_supers(self, lvalue: RefExpr, rvalue: Expression) ->
return

for base in lvalue_node.info.mro[1:]:
# The type of "__slots__" and some other attributes usually doesn't need to
# be compatible with a base class. We'll still check the type of "__slots__"
# against "object" as an exception.
if lvalue_node.allow_incompatible_override and not (
lvalue_node.name == "__slots__" and base.fullname == "builtins.object"
if (
lvalue_node.name == "__hash__"
and base.fullname == "builtins.object"
and isinstance(get_proper_type(lvalue_type), NoneType)
):
# allow `__hash__ = None` if the overridden `__hash__` comes from object
# This isn't LSP-compliant, but too common in real code.
continue

if is_private(lvalue_node.name):
continue

base_type, base_node = self.node_type_from_base(lvalue_node.name, base, lvalue)
# The type of "__slots__" and some other attributes usually doesn't need to
# be compatible with a base class. We'll still check the type of "__slots__"
# against "object" as an exception.
if (
isinstance(base_node, Var)
and base_node.allow_incompatible_override
and not (
lvalue_node.name == "__slots__" and base.fullname == "builtins.object"
)
):
continue
custom_setter = is_custom_settable_property(base_node)
if isinstance(base_type, PartialType):
base_type = None
Expand Down
37 changes: 37 additions & 0 deletions mypy/semanal.py
Original file line number Diff line number Diff line change
Expand Up @@ -2017,6 +2017,27 @@ def analyze_class_body_common(self, defn: ClassDef) -> None:
self.setup_self_type()
defn.defs.accept(self)
self.apply_class_plugin_hooks(defn)

if (
"__eq__" in defn.info.names
and "__hash__" not in defn.info.names
and not defn.info.is_protocol
):
# If a class defines `__eq__` without `__hash__`, it's no longer hashable.
# Excludes Protocol from consideration as we don't want to enforce unhashability
# of their instances.
hash_none = Var("__hash__", NoneType())
hash_none.info = defn.info
hash_none.set_line(defn)
hash_none.is_classvar = True
# Making a class hashable is allowed even if its parents weren't.
# The only possible consequence of this LSP violation would be
# `assert child.__hash__ is None` no longer passing, probably nobody
# cares about that - it's impossible to statically restrict to non-hashable
# anyway.
hash_none.allow_incompatible_override = True
self.add_symbol("__hash__", hash_none, defn)

self.leave_class()

def analyze_typeddict_classdef(self, defn: ClassDef) -> bool:
Expand Down Expand Up @@ -3236,6 +3257,22 @@ def visit_assignment_stmt(self, s: AssignmentStmt) -> None:
self.process__all__(s)
self.process__deletable__(s)
self.process__slots__(s)
self.process__hash__(s)

def process__hash__(self, s: AssignmentStmt) -> None:
# Allow overriding `__hash__ = None` in subclasses.
if (
isinstance(self.type, TypeInfo)
and len(s.lvalues) == 1
and isinstance(s.lvalues[0], NameExpr)
and s.lvalues[0].name == "__hash__"
and s.lvalues[0].kind == MDEF
and isinstance(s.rvalue, NameExpr)
and s.rvalue.name == "None"
):
var = s.lvalues[0].node
if isinstance(var, Var):
var.allow_incompatible_override = True

def analyze_identity_global_assignment(self, s: AssignmentStmt) -> bool:
"""Special case 'X = X' in global scope.
Expand Down
1 change: 1 addition & 0 deletions mypyc/test-data/fixtures/ir.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ def __init__(self, x: object) -> None: pass
def __add__(self, x: str) -> str: pass
def __mul__(self, x: int) -> str: pass
def __rmul__(self, x: int) -> str: pass
def __hash__(self) -> int: pass
def __eq__(self, x: object) -> bool: pass
def __ne__(self, x: object) -> bool: pass
def __lt__(self, x: str) -> bool: ...
Expand Down
59 changes: 54 additions & 5 deletions test-data/unit/check-classes.test
Original file line number Diff line number Diff line change
Expand Up @@ -116,11 +116,7 @@ class Base:
__hash__: None = None

class Derived(Base):
def __hash__(self) -> int: # E: Signature of "__hash__" incompatible with supertype "Base" \
# N: Superclass: \
# N: None \
# N: Subclass: \
# N: def __hash__(self) -> int
def __hash__(self) -> int:
pass

# Correct:
Expand All @@ -147,6 +143,59 @@ class Base:
class Derived(Base):
__hash__ = 1 # E: Incompatible types in assignment (expression has type "int", base class "Base" defined the type as "Callable[[], int]")

[case testEqWithoutHash]
class A:
def __eq__(self, other) -> bool: ...

reveal_type(A.__hash__) # N: Revealed type is "None"
[builtins fixtures/primitives.pyi]

[case testHashNoneOverride]
class A:
__hash__ = None

class B(A):
def __hash__(self) -> int:
return 0

class C(A):
__hash__ = object.__hash__

class D(A):
def __hash__(self, x: int) -> str: # E: Signature of "__hash__" incompatible with supertype "object" \
# N: Superclass: \
# N: def __hash__(self) -> int \
# N: Subclass: \
# N: def __hash__(self, x: int) -> str
return ''

def bad_hash(x: E, y: str) -> str:
return y

class E(A):
__hash__ = bad_hash # E: Incompatible types in assignment (expression has type "Callable[[str], str]", base class "object" defined the type as "Callable[[], int]")
[builtins fixtures/primitives.pyi]

[case testHashNoneBadOverride]
class A:
def __hash__(self) -> int: return 0

class B(A):
__hash__ = None # E: Incompatible types in assignment (expression has type "None", base class "A" defined the type as "Callable[[], int]")
[builtins fixtures/primitives.pyi]

[case testHashOverrideInMultipleInheritance]
class Foo:
def __eq__(self, other: "Foo") -> bool: ...

class Bar:
def __hash__(self) -> int: ...

class BadChild(Foo, Bar): ... # E: Definition of "__hash__" in base class "Foo" is incompatible with definition in base class "Bar"
class GoodChild(Bar, Foo): ...
[builtins fixtures/tuple.pyi]


[case testOverridePartialAttributeWithMethod]
# This was crashing: https://github.com/python/mypy/issues/11686.
class Base:
Expand Down
4 changes: 2 additions & 2 deletions test-data/unit/fine-grained-inspect.test
Original file line number Diff line number Diff line change
Expand Up @@ -122,10 +122,10 @@ None
[builtins fixtures/args.pyi]
[out]
==
{"C": ["x"], "object": ["__eq__", "__init__", "__ne__"]}
{"C": ["x"], "object": ["__eq__", "__hash__", "__init__", "__ne__"]}
{"Iterable": ["__iter__"]}
NameExpr -> {}
NameExpr -> {"object": ["__eq__", "__init__", "__ne__"]}
NameExpr -> {"object": ["__eq__", "__hash__", "__init__", "__ne__"]}

[case testInspectTypeVarBoundAttrs]
# inspect2: --show=attrs tmp/foo.py:8:13
Expand Down
1 change: 1 addition & 0 deletions test-data/unit/fixtures/primitives.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ class object:
def __str__(self) -> str: pass
def __eq__(self, other: object) -> bool: pass
def __ne__(self, other: object) -> bool: pass
def __hash__(self) -> int: pass

class type:
def __init__(self, x: object) -> None: pass
Expand Down