From b6411cf8f00880360b2cf73e9f1ed673adb46c18 Mon Sep 17 00:00:00 2001 From: STerliakov Date: Wed, 18 Jun 2025 17:08:27 +0200 Subject: [PATCH 1/4] Check property setter/deleter decorators stricter --- mypy/semanal.py | 23 ++++++++++----- test-data/unit/check-classes.test | 43 ++++++++++++++++++++++++++--- test-data/unit/check-functions.test | 4 +-- 3 files changed, 56 insertions(+), 14 deletions(-) diff --git a/mypy/semanal.py b/mypy/semanal.py index d70abe911fea..101ee0872384 100644 --- a/mypy/semanal.py +++ b/mypy/semanal.py @@ -1527,33 +1527,33 @@ def analyze_property_with_multi_part_definition( assert isinstance(first_item, Decorator) deleted_items = [] bare_setter_type = None + func_name = first_item.func.name for i, item in enumerate(items[1:]): if isinstance(item, Decorator): item.func.accept(self) if item.decorators: first_node = item.decorators[0] - if isinstance(first_node, MemberExpr): + if self._is_valid_property_decorator(first_node, func_name): + # Get abstractness from the original definition. + item.func.abstract_status = first_item.func.abstract_status if first_node.name == "setter": # The first item represents the entire property. first_item.var.is_settable_property = True - # Get abstractness from the original definition. - item.func.abstract_status = first_item.func.abstract_status setter_func_type = function_type( item.func, self.named_type("builtins.function") ) assert isinstance(setter_func_type, CallableType) bare_setter_type = setter_func_type defn.setter_index = i + 1 - if first_node.name == "deleter": - item.func.abstract_status = first_item.func.abstract_status for other_node in item.decorators[1:]: other_node.accept(self) else: self.fail( - f"Only supported top decorator is @{first_item.func.name}.setter", item + f"Only supported top decorators are @{func_name}.setter and @{func_name}.deleter", + item, ) else: - self.fail(f'Unexpected definition for property "{first_item.func.name}"', item) + self.fail(f'Unexpected definition for property "{func_name}"', item) deleted_items.append(i + 1) for i in reversed(deleted_items): del items[i] @@ -1567,6 +1567,15 @@ def analyze_property_with_multi_part_definition( ) return bare_setter_type + def _is_valid_property_decorator(self, deco: Expression, property_name: str) -> bool: + if not isinstance(deco, MemberExpr): + return False + if not isinstance(deco.expr, NameExpr) or deco.expr.name != property_name: + return False + if deco.name not in {"setter", "deleter"}: + return False + return True + def add_function_to_symbol_table(self, func: FuncDef | OverloadedFuncDef) -> None: if self.is_class_scope(): assert self.type is not None diff --git a/test-data/unit/check-classes.test b/test-data/unit/check-classes.test index f4bbaf41dc47..c20a09bb669b 100644 --- a/test-data/unit/check-classes.test +++ b/test-data/unit/check-classes.test @@ -772,7 +772,7 @@ class A: class B(A): @property def f(self) -> Callable[[object], None]: pass - @func.setter + @f.setter def f(self, x: object) -> None: pass [builtins fixtures/property.pyi] @@ -786,7 +786,7 @@ class A: class B(A): @property def f(self) -> Callable[[object], None]: pass - @func.setter + @f.setter def f(self, x: object) -> None: pass [builtins fixtures/property.pyi] @@ -1622,7 +1622,42 @@ class A: self.x = '' # E: Incompatible types in assignment (expression has type "str", variable has type "int") return '' [builtins fixtures/property.pyi] -[out] + +[case testPropertyNameIsChecked] +import typing +class A: + @property + def f(self) -> str: ... + @not_f.setter # E: Only supported top decorators are @f.setter and @f.deleter + def f(self, val: str) -> None: ... + +a = A() +reveal_type(a.f) # N: Revealed type is "builtins.str" +a.f = '' # E: Property "f" defined in "A" is read-only + +class B: + @property + def f(self) -> str: ... + @not_f.deleter # E: Only supported top decorators are @f.setter and @f.deleter + def f(self) -> None: ... + +class C: + @property + def f(self) -> str: ... + @not_f.setter # E: Only supported top decorators are @f.setter and @f.deleter + def f(self, val: str) -> None: ... + @not_f.deleter # E: Only supported top decorators are @f.setter and @f.deleter + def f(self) -> None: ... +[builtins fixtures/property.pyi] + +[case testPropertyAttributeIsChecked] +import typing +class A: + @property + def f(self) -> str: ... + @f.unknown # E: Only supported top decorators are @f.setter and @f.deleter + def f(self, val: str) -> None: ... +[builtins fixtures/property.pyi] [case testDynamicallyTypedProperty] import typing @@ -7627,7 +7662,7 @@ class A: def y(self) -> int: ... @y.setter def y(self, value: int) -> None: ... - @dec # E: Only supported top decorator is @y.setter + @dec # E: Only supported top decorators are @y.setter and @y.deleter def y(self) -> None: ... reveal_type(A().y) # N: Revealed type is "builtins.int" diff --git a/test-data/unit/check-functions.test b/test-data/unit/check-functions.test index ceb7af433dce..f0559cbfd437 100644 --- a/test-data/unit/check-functions.test +++ b/test-data/unit/check-functions.test @@ -2752,7 +2752,7 @@ class B: @property @dec def f(self) -> int: pass - @dec # E: Only supported top decorator is @f.setter + @dec # E: Only supported top decorators are @f.setter and @f.deleter @f.setter def f(self, v: int) -> None: pass @@ -2764,7 +2764,6 @@ class C: @dec def f(self, v: int) -> None: pass [builtins fixtures/property.pyi] -[out] [case testInvalidArgCountForProperty] from typing import Callable, TypeVar @@ -2783,7 +2782,6 @@ class A: @property def h(self, *args, **kwargs) -> int: pass # OK [builtins fixtures/property.pyi] -[out] [case testSubtypingUnionGenericBounds] from typing import Callable, TypeVar, Union, Sequence From 29a47f1550d3c167e2c0dcba5fa80466aff086f1 Mon Sep 17 00:00:00 2001 From: STerliakov Date: Wed, 18 Jun 2025 17:22:39 +0200 Subject: [PATCH 2/4] Fix selfcheck --- mypy/semanal.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/mypy/semanal.py b/mypy/semanal.py index 101ee0872384..26ba012bc1f3 100644 --- a/mypy/semanal.py +++ b/mypy/semanal.py @@ -1567,7 +1567,9 @@ def analyze_property_with_multi_part_definition( ) return bare_setter_type - def _is_valid_property_decorator(self, deco: Expression, property_name: str) -> bool: + def _is_valid_property_decorator( + self, deco: Expression, property_name: str + ) -> TypeGuard[MemberExpr]: if not isinstance(deco, MemberExpr): return False if not isinstance(deco.expr, NameExpr) or deco.expr.name != property_name: From e0092bfd23d4e088412757e2a98f06c83b55d244 Mon Sep 17 00:00:00 2001 From: STerliakov Date: Wed, 18 Jun 2025 17:48:16 +0200 Subject: [PATCH 3/4] Update one more test --- test-data/unit/semanal-errors.test | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test-data/unit/semanal-errors.test b/test-data/unit/semanal-errors.test index fa5cec795931..ed5086f8fed4 100644 --- a/test-data/unit/semanal-errors.test +++ b/test-data/unit/semanal-errors.test @@ -1230,7 +1230,7 @@ class A: @overload # E: Decorators on top of @property are not supported @property def f(self) -> int: pass - @property # E: Only supported top decorator is @f.setter + @property # E: Only supported top decorators are @f.setter and @f.deleter @overload def f(self) -> int: pass [builtins fixtures/property.pyi] From 25ea88911c79acfe441150ee5851a5ac3b7c41c1 Mon Sep 17 00:00:00 2001 From: STerliakov Date: Sun, 22 Jun 2025 00:25:10 +0200 Subject: [PATCH 4/4] Document and test `.getter` rejection --- mypy/semanal.py | 6 ++++++ test-data/unit/check-classes.test | 10 ++++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/mypy/semanal.py b/mypy/semanal.py index 26ba012bc1f3..d06495396c35 100644 --- a/mypy/semanal.py +++ b/mypy/semanal.py @@ -1575,6 +1575,12 @@ def _is_valid_property_decorator( if not isinstance(deco.expr, NameExpr) or deco.expr.name != property_name: return False if deco.name not in {"setter", "deleter"}: + # This intentionally excludes getter. While `@prop.getter` is valid at + # runtime, that would mean replacing the already processed getter type. + # Such usage is almost definitely a mistake (except for overrides in + # subclasses but we don't support them anyway) and might be a typo + # (only one letter away from `setter`), it's likely almost never used, + # so supporting it properly won't pay off. return False return True diff --git a/test-data/unit/check-classes.test b/test-data/unit/check-classes.test index c20a09bb669b..c0451f0f7973 100644 --- a/test-data/unit/check-classes.test +++ b/test-data/unit/check-classes.test @@ -1624,7 +1624,6 @@ class A: [builtins fixtures/property.pyi] [case testPropertyNameIsChecked] -import typing class A: @property def f(self) -> str: ... @@ -1651,7 +1650,6 @@ class C: [builtins fixtures/property.pyi] [case testPropertyAttributeIsChecked] -import typing class A: @property def f(self) -> str: ... @@ -1659,6 +1657,14 @@ class A: def f(self, val: str) -> None: ... [builtins fixtures/property.pyi] +[case testPropertyGetterDecoratorIsRejected] +class A: + @property + def f(self) -> str: ... + @f.getter # E: Only supported top decorators are @f.setter and @f.deleter + def f(self, val: str) -> None: ... +[builtins fixtures/property.pyi] + [case testDynamicallyTypedProperty] import typing class A: