From a2fd9c233e57bc2caa8838bd2f8624efbccde38b Mon Sep 17 00:00:00 2001 From: PGray Date: Thu, 5 Mar 2026 00:05:00 +0000 Subject: [PATCH 1/2] Fix false positive when @property getter and setter are non-adjacent `fix_function_overloads` in fastparse.py groups consecutive same-named decorators into an `OverloadedFuncDef`. When a property getter and its setter/deleter are separated by other method definitions, the setter was left as an isolated `Decorator` instead of being folded into the property's `OverloadedFuncDef`. This caused mypy to report: error: "Callable[[A], T]" has no attribute "setter" [attr-defined] error: Name "x" already defined on line N [no-redef] error: Property "x" defined in "A" is read-only [misc] Add a second pass `_merge_non_adjacent_property_overloads` that runs after the existing loop. It scans the output list for any `@x.setter` or `@x.deleter` `Decorator` nodes whose property getter was seen earlier in the same scope, and merges them into the getter's `OverloadedFuncDef` (promoting a lone getter `Decorator` to an `OverloadedFuncDef` when needed). The adjacent case is unaffected because the setter/deleter is already inside the `OverloadedFuncDef` after the first pass. Fixes #1465. --- mypy/fastparse.py | 67 ++++++++++++++++++++++++++++++- test-data/unit/check-classes.test | 53 ++++++++++++++++++++++++ 2 files changed, 119 insertions(+), 1 deletion(-) diff --git a/mypy/fastparse.py b/mypy/fastparse.py index e85b8fffaf9e..db2af8aaf5e1 100644 --- a/mypy/fastparse.py +++ b/mypy/fastparse.py @@ -761,7 +761,72 @@ def fix_function_overloads(self, stmts: list[Statement]) -> list[Statement]: ret.append(last_if_overload) elif last_if_stmt is not None: ret.append(last_if_stmt) - return ret + return self._merge_non_adjacent_property_overloads(ret) + + def _merge_non_adjacent_property_overloads( + self, stmts: list[Statement] + ) -> list[Statement]: + """Merge non-adjacent @x.setter and @x.deleter into their property getter. + + fix_function_overloads only groups *consecutive* same-named decorators. + When a property getter and its setter/deleter are separated by other + statements, the setter/deleter ends up as an isolated Decorator in the + output. This second pass finds those stray components and folds them + back into the property's OverloadedFuncDef (or promotes the lone getter + Decorator to an OverloadedFuncDef). + + See https://github.com/python/mypy/issues/1465. + """ + # Build a map: property name -> index of its getter / OverloadedFuncDef in stmts. + prop_getter_pos: dict[str, int] = {} + for i, stmt in enumerate(stmts): + if isinstance(stmt, Decorator): + if any(isinstance(d, NameExpr) and d.name == "property" for d in stmt.decorators): + prop_getter_pos[stmt.name] = i + elif isinstance(stmt, OverloadedFuncDef) and stmt.items: + first = stmt.items[0] + if isinstance(first, Decorator) and any( + isinstance(d, NameExpr) and d.name == "property" for d in first.decorators + ): + prop_getter_pos[first.name] = i + + if not prop_getter_pos: + return stmts + + # Find setter/deleter Decorators whose getter appeared earlier, then merge. + result: list[Statement | None] = list(stmts) + for i, stmt in enumerate(stmts): + if not isinstance(stmt, Decorator): + continue + # Identify @prop_name.setter or @prop_name.deleter + prop_name: str | None = None + for d in stmt.decorators: + if ( + isinstance(d, MemberExpr) + and isinstance(d.expr, NameExpr) + and d.name in {"setter", "deleter"} + ): + prop_name = d.expr.name + break + if prop_name is None: + continue + getter_pos = prop_getter_pos.get(prop_name) + if getter_pos is None or getter_pos >= i: + # No matching getter found earlier in the same scope. + continue + # This is a non-adjacent setter/deleter; merge it into the getter's node. + existing = result[getter_pos] + if isinstance(existing, Decorator): + # Promote the lone getter Decorator to an OverloadedFuncDef. + ovl = OverloadedFuncDef([existing, stmt]) + ovl.set_line(existing) + result[getter_pos] = ovl + elif isinstance(existing, OverloadedFuncDef): + existing.items.append(stmt) + existing.unanalyzed_items.append(stmt) + result[i] = None # Remove from its original (non-adjacent) position. + + return [s for s in result if s is not None] def _check_ifstmt_for_overloads( self, stmt: IfStmt, current_overload_name: str | None = None diff --git a/test-data/unit/check-classes.test b/test-data/unit/check-classes.test index 5a66eff2bd3b..f129e5e187d6 100644 --- a/test-data/unit/check-classes.test +++ b/test-data/unit/check-classes.test @@ -1812,6 +1812,59 @@ a.f = a.f # E: Property "f" defined in "A" is read-only a.f.x # E: "int" has no attribute "x" [builtins fixtures/property.pyi] +[case testPropertyWithNonAdjacentSetter] +# Regression test for https://github.com/python/mypy/issues/1465 +# @f.setter need not be immediately after @property def f. +class A: + @property + def f(self) -> int: + return 1 + def other(self) -> None: + pass + @f.setter + def f(self, x: int) -> None: + pass +a = A() +a.f = a.f +a.f = 1 +a.f = '' # E: Incompatible types in assignment (expression has type "str", variable has type "int") +reveal_type(a.f) # N: Revealed type is "builtins.int" +[builtins fixtures/property.pyi] + +[case testPropertyWithNonAdjacentDeleter] +class A: + @property + def f(self) -> int: + return 1 + def other(self) -> None: + pass + @f.deleter + def f(self) -> None: + pass +a = A() +a.f = a.f # E: Property "f" defined in "A" is read-only +[builtins fixtures/property.pyi] + +[case testPropertyWithNonAdjacentSetterAndDeleter] +class A: + @property + def f(self) -> int: + return 1 + def other(self) -> None: + pass + @f.setter + def f(self, x: int) -> None: + pass + def another(self) -> None: + pass + @f.deleter + def f(self) -> None: + pass +a = A() +a.f = 1 +a.f = '' # E: Incompatible types in assignment (expression has type "str", variable has type "int") +[builtins fixtures/property.pyi] + -- Descriptors -- ----------- From 5e1c3f7f8b1313d51525475844f943277eb49c3f Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 5 Mar 2026 00:07:26 +0000 Subject: [PATCH 2/2] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- mypy/fastparse.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/mypy/fastparse.py b/mypy/fastparse.py index db2af8aaf5e1..7c7da7147b0a 100644 --- a/mypy/fastparse.py +++ b/mypy/fastparse.py @@ -763,9 +763,7 @@ def fix_function_overloads(self, stmts: list[Statement]) -> list[Statement]: ret.append(last_if_stmt) return self._merge_non_adjacent_property_overloads(ret) - def _merge_non_adjacent_property_overloads( - self, stmts: list[Statement] - ) -> list[Statement]: + def _merge_non_adjacent_property_overloads(self, stmts: list[Statement]) -> list[Statement]: """Merge non-adjacent @x.setter and @x.deleter into their property getter. fix_function_overloads only groups *consecutive* same-named decorators.