diff --git a/CHANGELOG.md b/CHANGELOG.md index b81359b9..320000ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # 2.17 (unreleased) * Add support for Python 3.15 and drop 3.9 (Hugo van Kemenade, #416). +* Fix false positive for `@override` function parameters (#410). # 2.16 (2026-03-25) diff --git a/tests/test_scavenging.py b/tests/test_scavenging.py index ec8e4565..71377c76 100644 --- a/tests/test_scavenging.py +++ b/tests/test_scavenging.py @@ -882,3 +882,118 @@ class Color(Enum): check(v.unused_classes, []) check(v.unused_vars, ["BLUE"]) + + +def test_override_typing(v): + v.scan( + """\ +from typing import override + +class A(): + def my_func(self, a: int, b: int, c: int): + return a + b + c + +class B(A): + @override + def my_func(self, a, b, c): + return a - b +""" + ) + check(v.defined_vars, ["a", "b", "c"]) + check(v.used_names, ["A", "a", "b", "c", "int", "override"]) + check(v.unused_vars, []) + + +def test_override_typing_extensions(v): + v.scan( + """\ +from typing_extensions import override + +class A(): + def my_func(self, a: int, b: int, c: int): + return a + b + c + +class B(A): + @override + def my_func(self, a, b, c): + return a - b +""" + ) + check(v.defined_vars, ["a", "b", "c"]) + check(v.used_names, ["A", "a", "b", "c", "int", "override"]) + check(v.unused_vars, []) + + +def test_override_async(v): + v.scan( + """\ +from typing import override + +class A(): + async def my_func(self, a: int, b: int, c: int): + return a + b + c + +class B(A): + @override + async def my_func(self, a, b, c): + return a - b +""" + ) + check(v.defined_vars, ["a", "b", "c"]) + check(v.used_names, ["A", "a", "b", "c", "int", "override"]) + check(v.unused_vars, []) + + +def test_override_nested_function(v): + v.scan( + """\ +from typing import override + +class A(): + def my_func(self, a: int, b: int, c: int): + return a + b + c + +class B(A): + @override + def my_func(self, a, b, c): + def inner(x, y): + return x + return a - b +""" + ) + check(v.defined_vars, ["a", "b", "c", "x", "y"]) + check(v.used_names, ["A", "a", "b", "c", "int", "override", "x"]) + check(v.unused_vars, ["y"]) + + +def test_override_with_args_kwargs(v): + v.scan( + """\ +from typing import override + +class A(): + def my_func(self, *args, **kwargs): + return args + +class B(A): + @override + def my_func(self, *args, **kwargs): + return None +""" + ) + check(v.defined_vars, ["args", "kwargs"]) + check(v.used_names, ["A", "args", "override"]) + check(v.unused_vars, ["kwargs"]) + + +def test_no_override_still_flags(v): + v.scan( + """\ +class A(): + def my_func(self, a, b, c): + return a - b +""" + ) + check(v.defined_vars, ["a", "b", "c"]) + check(v.used_names, ["a", "b"]) + check(v.unused_vars, ["c"]) diff --git a/vulture/core.py b/vulture/core.py index ff354d41..7c82f19a 100644 --- a/vulture/core.py +++ b/vulture/core.py @@ -216,6 +216,7 @@ def get_list(typ): self.code = [] self.exit_code = ExitCode.NoDeadCode self.noqa_lines = {} + self._override_depth = 0 report = partial( self._define, @@ -470,7 +471,8 @@ def _define_variable(self, name, node, confidence=DEFAULT_CONFIDENCE): def visit_arg(self, node): """Function argument""" - self._define_variable(node.arg, node, confidence=100) + if self._override_depth == 0: + self._define_variable(node.arg, node, confidence=100) def visit_AsyncFunctionDef(self, node): return self.visit_FunctionDef(node) @@ -547,6 +549,25 @@ def _is_locals_call(node): and not node.keywords ) + @staticmethod + def _has_override_decorator(node): + """Check if a function has the @override decorator. + + Supports: + - @override + - @typing.override + - @typing_extensions.override + """ + for decorator in node.decorator_list: + name = utils.get_decorator_name(decorator) + if name in ( + "@override", + "@typing.override", + "@typing_extensions.override", + ): + return True + return False + def visit_ClassDef(self, node): for decorator in node.decorator_list: if _match( @@ -623,9 +644,23 @@ def visit_MatchClass(self, node): self.used_names.add(kwd_attr) def visit(self, node): + # For function definitions, save and restore the override depth. + # This ensures inner functions don't inherit the parent's override state. + saved_override_depth = None + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + saved_override_depth = self._override_depth + if self._has_override_decorator(node): + self._override_depth = 1 + else: + self._override_depth = 0 + # Visit children nodes first to allow recursive reachability analysis. self.generic_visit(node) + # Restore override depth after visiting children. + if saved_override_depth is not None: + self._override_depth = saved_override_depth + self.reachability.visit(node) method = "visit_" + node.__class__.__name__