From 9b33a6c34f95dab1e424f4e8600e172f17be4639 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tin=20Tvrtkovi=C4=87?= Date: Fri, 24 Nov 2023 19:01:35 +0100 Subject: [PATCH 1/5] Improve attrs hashability detection --- mypy/plugins/attrs.py | 9 ++++ mypy/plugins/common.py | 3 +- test-data/unit/check-plugin-attrs.test | 57 ++++++++++++++++++++++ test-data/unit/fixtures/plugin_attrs.pyi | 1 + test-data/unit/lib-stub/attr/__init__.pyi | 2 + test-data/unit/lib-stub/attrs/__init__.pyi | 2 + 6 files changed, 73 insertions(+), 1 deletion(-) diff --git a/mypy/plugins/attrs.py b/mypy/plugins/attrs.py index 81f96c088ecd..778098f49b55 100644 --- a/mypy/plugins/attrs.py +++ b/mypy/plugins/attrs.py @@ -310,6 +310,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 @@ -321,6 +323,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) @@ -359,10 +362,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 @@ -942,6 +948,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 03041bfcebcd..f0ff6f30a3b9 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 b2161b91e225..06e5df61ac43 100644 --- a/test-data/unit/check-plugin-attrs.test +++ b/test-data/unit/check-plugin-attrs.test @@ -2321,3 +2321,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 57e5ecd1b2bc..5b87c47b5bc8 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 24ffc0f3f275..466c6913062d 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 f610957a48a3..d0a65c84d9d8 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 = ..., From 6067f38af891e0d1a0044d8a86e410e75c2f15c4 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 24 Nov 2023 18:08:35 +0000 Subject: [PATCH 2/5] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- mypy/plugins/attrs.py | 10 ++++++++-- test-data/unit/check-plugin-attrs.test | 2 +- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/mypy/plugins/attrs.py b/mypy/plugins/attrs.py index 778098f49b55..19a402492aef 100644 --- a/mypy/plugins/attrs.py +++ b/mypy/plugins/attrs.py @@ -323,7 +323,9 @@ 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) + 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) @@ -948,9 +950,13 @@ 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) + 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/test-data/unit/check-plugin-attrs.test b/test-data/unit/check-plugin-attrs.test index 06e5df61ac43..dde93d61fd0a 100644 --- a/test-data/unit/check-plugin-attrs.test +++ b/test-data/unit/check-plugin-attrs.test @@ -2377,4 +2377,4 @@ class A: a: Hashable = A(1) [builtins fixtures/plugin_attrs.pyi] -[typing fixtures/typing-full.pyi] \ No newline at end of file +[typing fixtures/typing-full.pyi] From 30c3c5a216271140ee920f7d7ddaf7c0fe8fb471 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tin=20Tvrtkovi=C4=87?= Date: Sat, 9 Dec 2023 12:47:40 +0100 Subject: [PATCH 3/5] Tweak default hashability test --- test-data/unit/check-plugin-attrs.test | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/test-data/unit/check-plugin-attrs.test b/test-data/unit/check-plugin-attrs.test index dde93d61fd0a..d6e47ea35cc1 100644 --- a/test-data/unit/check-plugin-attrs.test +++ b/test-data/unit/check-plugin-attrs.test @@ -2330,12 +2330,7 @@ from attrs import 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" +reveal_type(A.__hash__) # N: Revealed type is "None" [builtins fixtures/plugin_attrs.pyi] [typing fixtures/typing-full.pyi] From feb0d5d3e054b74f92132139e4b565bdef4dbaa5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tin=20Tvrtkovi=C4=87?= Date: Sat, 9 Dec 2023 17:59:26 +0100 Subject: [PATCH 4/5] Rework more hash checks, add subclassing test --- test-data/unit/check-plugin-attrs.test | 29 ++++++++++++++++---------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/test-data/unit/check-plugin-attrs.test b/test-data/unit/check-plugin-attrs.test index d6e47ea35cc1..de78e877be3e 100644 --- a/test-data/unit/check-plugin-attrs.test +++ b/test-data/unit/check-plugin-attrs.test @@ -2323,7 +2323,6 @@ 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 @@ -2333,43 +2332,51 @@ class A: reveal_type(A.__hash__) # N: Revealed type is "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) +reveal_type(A.__hash__) # N: Revealed type is "def (self: builtins.object) -> builtins.int" [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) +reveal_type(A.__hash__) # N: Revealed type is "def (self: builtins.object) -> builtins.int" [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) +reveal_type(A.__hash__) # N: Revealed type is "def (self: builtins.object) -> builtins.int" [builtins fixtures/plugin_attrs.pyi] -[typing fixtures/typing-full.pyi] + +[case testSubclassingHashability] +from attrs import define + +@define(unsafe_hash=True) +class A: + a: int + +@define +class B(A): + pass + +reveal_type(B.__hash__) # N: Revealed type is "None" + +[builtins fixtures/plugin_attrs.pyi] \ No newline at end of file From 9794717634e4916a38edcde4ceb05de02cc3910c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 9 Dec 2023 16:59:49 +0000 Subject: [PATCH 5/5] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- test-data/unit/check-plugin-attrs.test | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test-data/unit/check-plugin-attrs.test b/test-data/unit/check-plugin-attrs.test index de78e877be3e..0f379724553a 100644 --- a/test-data/unit/check-plugin-attrs.test +++ b/test-data/unit/check-plugin-attrs.test @@ -2379,4 +2379,4 @@ class B(A): reveal_type(B.__hash__) # N: Revealed type is "None" -[builtins fixtures/plugin_attrs.pyi] \ No newline at end of file +[builtins fixtures/plugin_attrs.pyi]