Skip to content

Commit 9b33a6c

Browse files
committed
Improve attrs hashability detection
1 parent cbbcdb8 commit 9b33a6c

File tree

6 files changed

+73
-1
lines changed

6 files changed

+73
-1
lines changed

mypy/plugins/attrs.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,8 @@ def attr_class_maker_callback(
310310
it will add an __init__ or all the compare methods.
311311
For frozen=True it will turn the attrs into properties.
312312
313+
Hashability will be set according to https://www.attrs.org/en/stable/hashing.html.
314+
313315
See https://www.attrs.org/en/stable/how-does-it-work.html for information on how attrs works.
314316
315317
If this returns False, some required metadata was not ready yet and we need another
@@ -321,6 +323,7 @@ def attr_class_maker_callback(
321323
frozen = _get_frozen(ctx, frozen_default)
322324
order = _determine_eq_order(ctx)
323325
slots = _get_decorator_bool_argument(ctx, "slots", slots_default)
326+
hashable = _get_decorator_bool_argument(ctx, "hash", False) or _get_decorator_bool_argument(ctx, "unsafe_hash", False)
324327

325328
auto_attribs = _get_decorator_optional_bool_argument(ctx, "auto_attribs", auto_attribs_default)
326329
kw_only = _get_decorator_bool_argument(ctx, "kw_only", False)
@@ -359,10 +362,13 @@ def attr_class_maker_callback(
359362
adder = MethodAdder(ctx)
360363
# If __init__ is not being generated, attrs still generates it as __attrs_init__ instead.
361364
_add_init(ctx, attributes, adder, "__init__" if init else ATTRS_INIT_NAME)
365+
362366
if order:
363367
_add_order(ctx, adder)
364368
if frozen:
365369
_make_frozen(ctx, attributes)
370+
elif not hashable:
371+
_remove_hashability(ctx)
366372

367373
return True
368374

@@ -942,6 +948,9 @@ def _add_match_args(ctx: mypy.plugin.ClassDefContext, attributes: list[Attribute
942948
)
943949
add_attribute_to_class(api=ctx.api, cls=ctx.cls, name="__match_args__", typ=match_args)
944950

951+
def _remove_hashability(ctx: mypy.plugin.ClassDefContext) -> None:
952+
"""Remove hashability from a class."""
953+
add_attribute_to_class(ctx.api, ctx.cls, "__hash__", NoneType(), is_classvar=True, overwrite_existing=True)
945954

946955
class MethodAdder:
947956
"""Helper to add methods to a TypeInfo.

mypy/plugins/common.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,7 @@ def add_attribute_to_class(
399399
override_allow_incompatible: bool = False,
400400
fullname: str | None = None,
401401
is_classvar: bool = False,
402+
overwrite_existing: bool = False,
402403
) -> Var:
403404
"""
404405
Adds a new attribute to a class definition.
@@ -408,7 +409,7 @@ def add_attribute_to_class(
408409

409410
# NOTE: we would like the plugin generated node to dominate, but we still
410411
# need to keep any existing definitions so they get semantically analyzed.
411-
if name in info.names:
412+
if name in info.names and not overwrite_existing:
412413
# Get a nice unique name instead.
413414
r_name = get_unique_redefinition_name(name, info.names)
414415
info.names[r_name] = info.names[name]

test-data/unit/check-plugin-attrs.test

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2321,3 +2321,60 @@ reveal_type(b.x) # N: Revealed type is "builtins.int"
23212321
reveal_type(b.y) # N: Revealed type is "builtins.int"
23222322

23232323
[builtins fixtures/plugin_attrs.pyi]
2324+
2325+
[case testDefaultHashability]
2326+
from typing import Hashable
2327+
from attrs import define
2328+
2329+
@define
2330+
class A:
2331+
a: int
2332+
2333+
a: Hashable = A(1)
2334+
2335+
[out]
2336+
main:8: error: Incompatible types in assignment (expression has type "A", variable has type "Hashable")
2337+
main:8: note: Following member(s) of "A" have conflicts:
2338+
main:8: note: __hash__: expected "Callable[[], int]", got "None"
2339+
2340+
[builtins fixtures/plugin_attrs.pyi]
2341+
[typing fixtures/typing-full.pyi]
2342+
2343+
[case testFrozenHashability]
2344+
from typing import Hashable
2345+
from attrs import frozen
2346+
2347+
@frozen
2348+
class A:
2349+
a: int
2350+
2351+
a: Hashable = A(1)
2352+
2353+
[builtins fixtures/plugin_attrs.pyi]
2354+
[typing fixtures/typing-full.pyi]
2355+
2356+
[case testManualHashHashability]
2357+
from typing import Hashable
2358+
from attrs import define
2359+
2360+
@define(hash=True)
2361+
class A:
2362+
a: int
2363+
2364+
a: Hashable = A(1)
2365+
2366+
[builtins fixtures/plugin_attrs.pyi]
2367+
[typing fixtures/typing-full.pyi]
2368+
2369+
[case testManualUnsafeHashHashability]
2370+
from typing import Hashable
2371+
from attrs import define
2372+
2373+
@define(unsafe_hash=True)
2374+
class A:
2375+
a: int
2376+
2377+
a: Hashable = A(1)
2378+
2379+
[builtins fixtures/plugin_attrs.pyi]
2380+
[typing fixtures/typing-full.pyi]

test-data/unit/fixtures/plugin_attrs.pyi

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ class object:
55
def __init__(self) -> None: pass
66
def __eq__(self, o: object) -> bool: pass
77
def __ne__(self, o: object) -> bool: pass
8+
def __hash__(self) -> int: ...
89

910
class type: pass
1011
class bytes: pass

test-data/unit/lib-stub/attr/__init__.pyi

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ def define(
131131
*,
132132
these: Optional[Mapping[str, Any]] = ...,
133133
repr: bool = ...,
134+
unsafe_hash: Optional[bool]=None,
134135
hash: Optional[bool] = ...,
135136
init: bool = ...,
136137
slots: bool = ...,
@@ -153,6 +154,7 @@ def define(
153154
*,
154155
these: Optional[Mapping[str, Any]] = ...,
155156
repr: bool = ...,
157+
unsafe_hash: Optional[bool]=None,
156158
hash: Optional[bool] = ...,
157159
init: bool = ...,
158160
slots: bool = ...,

test-data/unit/lib-stub/attrs/__init__.pyi

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ def define(
2222
*,
2323
these: Optional[Mapping[str, Any]] = ...,
2424
repr: bool = ...,
25+
unsafe_hash: Optional[bool]=None,
2526
hash: Optional[bool] = ...,
2627
init: bool = ...,
2728
slots: bool = ...,
@@ -44,6 +45,7 @@ def define(
4445
*,
4546
these: Optional[Mapping[str, Any]] = ...,
4647
repr: bool = ...,
48+
unsafe_hash: Optional[bool]=None,
4749
hash: Optional[bool] = ...,
4850
init: bool = ...,
4951
slots: bool = ...,

0 commit comments

Comments
 (0)