Skip to content

Commit 91be285

Browse files
Improve attrs hashability detection (#16556)
Fixes #16550 Improve hashability detection for attrs classes. I added a new parameter to `add_attribute_to_class`, `overwrite_existing`, since I needed it. Frozen classes remain hashable, non-frozen default to no. This can be overriden by passing in `hash=True` or `unsafe_hash=True` (new parameter, added to stubs) to `define()`. Inheritance-wise I think we're correct, if a non-hashable class inherits from a hashable one, it's still unhashable at runtime. Accompanying tests. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 5fa9569 commit 91be285

File tree

6 files changed

+81
-1
lines changed

6 files changed

+81
-1
lines changed

mypy/plugins/attrs.py

Lines changed: 15 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,9 @@ 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(
327+
ctx, "unsafe_hash", False
328+
)
324329

325330
auto_attribs = _get_decorator_optional_bool_argument(ctx, "auto_attribs", auto_attribs_default)
326331
kw_only = _get_decorator_bool_argument(ctx, "kw_only", False)
@@ -359,10 +364,13 @@ def attr_class_maker_callback(
359364
adder = MethodAdder(ctx)
360365
# If __init__ is not being generated, attrs still generates it as __attrs_init__ instead.
361366
_add_init(ctx, attributes, adder, "__init__" if init else ATTRS_INIT_NAME)
367+
362368
if order:
363369
_add_order(ctx, adder)
364370
if frozen:
365371
_make_frozen(ctx, attributes)
372+
elif not hashable:
373+
_remove_hashability(ctx)
366374

367375
return True
368376

@@ -943,6 +951,13 @@ def _add_match_args(ctx: mypy.plugin.ClassDefContext, attributes: list[Attribute
943951
add_attribute_to_class(api=ctx.api, cls=ctx.cls, name="__match_args__", typ=match_args)
944952

945953

954+
def _remove_hashability(ctx: mypy.plugin.ClassDefContext) -> None:
955+
"""Remove hashability from a class."""
956+
add_attribute_to_class(
957+
ctx.api, ctx.cls, "__hash__", NoneType(), is_classvar=True, overwrite_existing=True
958+
)
959+
960+
946961
class MethodAdder:
947962
"""Helper to add methods to a TypeInfo.
948963

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: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2321,3 +2321,62 @@ 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 attrs import define
2327+
2328+
@define
2329+
class A:
2330+
a: int
2331+
2332+
reveal_type(A.__hash__) # N: Revealed type is "None"
2333+
2334+
[builtins fixtures/plugin_attrs.pyi]
2335+
2336+
[case testFrozenHashability]
2337+
from attrs import frozen
2338+
2339+
@frozen
2340+
class A:
2341+
a: int
2342+
2343+
reveal_type(A.__hash__) # N: Revealed type is "def (self: builtins.object) -> builtins.int"
2344+
2345+
[builtins fixtures/plugin_attrs.pyi]
2346+
2347+
[case testManualHashHashability]
2348+
from attrs import define
2349+
2350+
@define(hash=True)
2351+
class A:
2352+
a: int
2353+
2354+
reveal_type(A.__hash__) # N: Revealed type is "def (self: builtins.object) -> builtins.int"
2355+
2356+
[builtins fixtures/plugin_attrs.pyi]
2357+
2358+
[case testManualUnsafeHashHashability]
2359+
from attrs import define
2360+
2361+
@define(unsafe_hash=True)
2362+
class A:
2363+
a: int
2364+
2365+
reveal_type(A.__hash__) # N: Revealed type is "def (self: builtins.object) -> builtins.int"
2366+
2367+
[builtins fixtures/plugin_attrs.pyi]
2368+
2369+
[case testSubclassingHashability]
2370+
from attrs import define
2371+
2372+
@define(unsafe_hash=True)
2373+
class A:
2374+
a: int
2375+
2376+
@define
2377+
class B(A):
2378+
pass
2379+
2380+
reveal_type(B.__hash__) # N: Revealed type is "None"
2381+
2382+
[builtins fixtures/plugin_attrs.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)