diff --git a/mypy/plugins/attrs.py b/mypy/plugins/attrs.py index 3ddc234a7e4a8..269ddccb3bd59 100644 --- a/mypy/plugins/attrs.py +++ b/mypy/plugins/attrs.py @@ -305,6 +305,8 @@ def attr_class_maker_callback( it will add an __init__ or all the compare methods. For frozen=True it will turn the attrs into properties. + Hashability will be set according to https://www.attrs.org/en/stable/hashing.html. + See https://www.attrs.org/en/stable/how-does-it-work.html for information on how attrs works. If this returns False, some required metadata was not ready yet and we need another @@ -316,6 +318,7 @@ def attr_class_maker_callback( frozen = _get_frozen(ctx, frozen_default) order = _determine_eq_order(ctx) slots = _get_decorator_bool_argument(ctx, "slots", slots_default) + hashable = _get_decorator_bool_argument(ctx, "hash", False) or _get_decorator_bool_argument(ctx, "unsafe_hash", False) auto_attribs = _get_decorator_optional_bool_argument(ctx, "auto_attribs", auto_attribs_default) kw_only = _get_decorator_bool_argument(ctx, "kw_only", False) @@ -354,10 +357,13 @@ def attr_class_maker_callback( adder = MethodAdder(ctx) # If __init__ is not being generated, attrs still generates it as __attrs_init__ instead. _add_init(ctx, attributes, adder, "__init__" if init else ATTRS_INIT_NAME) + if order: _add_order(ctx, adder) if frozen: _make_frozen(ctx, attributes) + elif not hashable: + _remove_hashability(ctx) return True @@ -925,6 +931,9 @@ def _add_match_args(ctx: mypy.plugin.ClassDefContext, attributes: list[Attribute ) add_attribute_to_class(api=ctx.api, cls=ctx.cls, name="__match_args__", typ=match_args) +def _remove_hashability(ctx: mypy.plugin.ClassDefContext) -> None: + """Remove hashability from a class.""" + add_attribute_to_class(ctx.api, ctx.cls, "__hash__", NoneType(), is_classvar=True, overwrite_existing=True) class MethodAdder: """Helper to add methods to a TypeInfo. diff --git a/mypy/plugins/common.py b/mypy/plugins/common.py index 03041bfcebcd3..f0ff6f30a3b94 100644 --- a/mypy/plugins/common.py +++ b/mypy/plugins/common.py @@ -399,6 +399,7 @@ def add_attribute_to_class( override_allow_incompatible: bool = False, fullname: str | None = None, is_classvar: bool = False, + overwrite_existing: bool = False, ) -> Var: """ Adds a new attribute to a class definition. @@ -408,7 +409,7 @@ def add_attribute_to_class( # NOTE: we would like the plugin generated node to dominate, but we still # need to keep any existing definitions so they get semantically analyzed. - if name in info.names: + if name in info.names and not overwrite_existing: # Get a nice unique name instead. r_name = get_unique_redefinition_name(name, info.names) info.names[r_name] = info.names[name] diff --git a/test-data/unit/check-plugin-attrs.test b/test-data/unit/check-plugin-attrs.test index fb5f1f9472c25..c056e8f486ba0 100644 --- a/test-data/unit/check-plugin-attrs.test +++ b/test-data/unit/check-plugin-attrs.test @@ -2296,3 +2296,60 @@ reveal_type(b.x) # N: Revealed type is "builtins.int" reveal_type(b.y) # N: Revealed type is "builtins.int" [builtins fixtures/plugin_attrs.pyi] + +[case testDefaultHashability] +from typing import Hashable +from attrs import define + +@define +class A: + a: int + +a: Hashable = A(1) + +[out] +main:8: error: Incompatible types in assignment (expression has type "A", variable has type "Hashable") +main:8: note: Following member(s) of "A" have conflicts: +main:8: note: __hash__: expected "Callable[[], int]", got "None" + +[builtins fixtures/plugin_attrs.pyi] +[typing fixtures/typing-full.pyi] + +[case testFrozenHashability] +from typing import Hashable +from attrs import frozen + +@frozen +class A: + a: int + +a: Hashable = A(1) + +[builtins fixtures/plugin_attrs.pyi] +[typing fixtures/typing-full.pyi] + +[case testManualHashHashability] +from typing import Hashable +from attrs import define + +@define(hash=True) +class A: + a: int + +a: Hashable = A(1) + +[builtins fixtures/plugin_attrs.pyi] +[typing fixtures/typing-full.pyi] + +[case testManualUnsafeHashHashability] +from typing import Hashable +from attrs import define + +@define(unsafe_hash=True) +class A: + a: int + +a: Hashable = A(1) + +[builtins fixtures/plugin_attrs.pyi] +[typing fixtures/typing-full.pyi] \ No newline at end of file diff --git a/test-data/unit/fixtures/plugin_attrs.pyi b/test-data/unit/fixtures/plugin_attrs.pyi index 57e5ecd1b2bc3..5b87c47b5bc88 100644 --- a/test-data/unit/fixtures/plugin_attrs.pyi +++ b/test-data/unit/fixtures/plugin_attrs.pyi @@ -5,6 +5,7 @@ class object: def __init__(self) -> None: pass def __eq__(self, o: object) -> bool: pass def __ne__(self, o: object) -> bool: pass + def __hash__(self) -> int: ... class type: pass class bytes: pass diff --git a/test-data/unit/lib-stub/attr/__init__.pyi b/test-data/unit/lib-stub/attr/__init__.pyi index 24ffc0f3f275c..466c6913062de 100644 --- a/test-data/unit/lib-stub/attr/__init__.pyi +++ b/test-data/unit/lib-stub/attr/__init__.pyi @@ -131,6 +131,7 @@ def define( *, these: Optional[Mapping[str, Any]] = ..., repr: bool = ..., + unsafe_hash: Optional[bool]=None, hash: Optional[bool] = ..., init: bool = ..., slots: bool = ..., @@ -153,6 +154,7 @@ def define( *, these: Optional[Mapping[str, Any]] = ..., repr: bool = ..., + unsafe_hash: Optional[bool]=None, hash: Optional[bool] = ..., init: bool = ..., slots: bool = ..., diff --git a/test-data/unit/lib-stub/attrs/__init__.pyi b/test-data/unit/lib-stub/attrs/__init__.pyi index 7a88170d7271b..d255d56587d15 100644 --- a/test-data/unit/lib-stub/attrs/__init__.pyi +++ b/test-data/unit/lib-stub/attrs/__init__.pyi @@ -22,6 +22,7 @@ def define( *, these: Optional[Mapping[str, Any]] = ..., repr: bool = ..., + unsafe_hash: Optional[bool]=None, hash: Optional[bool] = ..., init: bool = ..., slots: bool = ..., @@ -44,6 +45,7 @@ def define( *, these: Optional[Mapping[str, Any]] = ..., repr: bool = ..., + unsafe_hash: Optional[bool]=None, hash: Optional[bool] = ..., init: bool = ..., slots: bool = ...,