From 47b22a769d194640fe09f557036b8494abc31f25 Mon Sep 17 00:00:00 2001 From: "joao.faria" Date: Mon, 17 Nov 2025 17:14:27 -0300 Subject: [PATCH] fix: avoid false positive when module-level names match class-level names - Add scope comparision to avoid module-level constants to be incorrectly classified as variables when a class-level attribute with the same name exists Closes #10719 --- doc/whatsnew/fragments/10719.false_positive | 3 ++ pylint/checkers/utils.py | 46 ++++++++++++++----- tests/checkers/unittest_utils.py | 29 ++++++++++++ .../invalid_name/invalid_name_module_level.py | 12 +++++ tests/functional/n/no/no_dummy_redefined.py | 1 + tests/functional/n/no/no_dummy_redefined.txt | 2 +- 6 files changed, 80 insertions(+), 13 deletions(-) create mode 100644 doc/whatsnew/fragments/10719.false_positive diff --git a/doc/whatsnew/fragments/10719.false_positive b/doc/whatsnew/fragments/10719.false_positive new file mode 100644 index 0000000000..50f08a3d29 --- /dev/null +++ b/doc/whatsnew/fragments/10719.false_positive @@ -0,0 +1,3 @@ +Fixed false positive for ``invalid-name`` where module-level constants were incorrectly classified as variables when a class-level attribute with the same name exists. + +Closes #10719 diff --git a/pylint/checkers/utils.py b/pylint/checkers/utils.py index 5a79c1c943..95085ec3d0 100644 --- a/pylint/checkers/utils.py +++ b/pylint/checkers/utils.py @@ -1843,28 +1843,50 @@ def is_sys_guard(node: nodes.If) -> bool: return False +def _is_node_in_same_scope( + candidate: nodes.NodeNG, node_scope: nodes.LocalsDictNodeNG +) -> bool: + if isinstance(candidate, (nodes.ClassDef, nodes.FunctionDef)): + return candidate.parent is not None and candidate.parent.scope() is node_scope + return candidate.scope() is node_scope + + +def _is_reassigned_relative_to_current( + node: nodes.NodeNG, varname: str, before: bool +) -> bool: + """Check if the given variable name is reassigned in the same scope relative to + the current node. + """ + node_scope = node.scope() + node_lineno = node.lineno + if node_lineno is None: + return False + for a in node_scope.nodes_of_class( + (nodes.AssignName, nodes.ClassDef, nodes.FunctionDef) + ): + if a.name == varname and a.lineno is not None: + if before: + if a.lineno < node_lineno: + if _is_node_in_same_scope(a, node_scope): + return True + elif a.lineno > node_lineno: + if _is_node_in_same_scope(a, node_scope): + return True + return False + + def is_reassigned_before_current(node: nodes.NodeNG, varname: str) -> bool: """Check if the given variable name is reassigned in the same scope before the current node. """ - return any( - a.name == varname and a.lineno < node.lineno - for a in node.scope().nodes_of_class( - (nodes.AssignName, nodes.ClassDef, nodes.FunctionDef) - ) - ) + return _is_reassigned_relative_to_current(node, varname, before=True) def is_reassigned_after_current(node: nodes.NodeNG, varname: str) -> bool: """Check if the given variable name is reassigned in the same scope after the current node. """ - return any( - a.name == varname and a.lineno > node.lineno - for a in node.scope().nodes_of_class( - (nodes.AssignName, nodes.ClassDef, nodes.FunctionDef) - ) - ) + return _is_reassigned_relative_to_current(node, varname, before=False) def is_deleted_after_current(node: nodes.NodeNG, varname: str) -> bool: diff --git a/tests/checkers/unittest_utils.py b/tests/checkers/unittest_utils.py index 44fa13552a..61afacd033 100644 --- a/tests/checkers/unittest_utils.py +++ b/tests/checkers/unittest_utils.py @@ -520,3 +520,32 @@ def test_is_typing_member() -> None: ) assert not utils.is_typing_member(code[0], ("Literal",)) assert not utils.is_typing_member(code[1], ("Literal",)) + + +def test_is_reassigned_after_current_requires_isinstance_check() -> None: + tree = astroid.parse( + """ + CONSTANT = 1 + + def global_function_assign(): + global CONSTANT + def CONSTANT(): + pass + CONSTANT() + """ + ) + func = tree.body[1] + global_stmt = func.body[0] + nested_func = func.body[1] + + assert isinstance(global_stmt, nodes.Global) + assert isinstance(nested_func, nodes.FunctionDef) + + node_scope = global_stmt.scope() + + assert nested_func.scope() == nested_func + assert nested_func.scope() != node_scope + + assert nested_func.parent.scope() == node_scope + + assert utils.is_reassigned_after_current(global_stmt, "CONSTANT") is True diff --git a/tests/functional/i/invalid/invalid_name/invalid_name_module_level.py b/tests/functional/i/invalid/invalid_name/invalid_name_module_level.py index 99323d1119..75620aea57 100644 --- a/tests/functional/i/invalid/invalid_name/invalid_name_module_level.py +++ b/tests/functional/i/invalid/invalid_name/invalid_name_module_level.py @@ -55,3 +55,15 @@ def A(): # [invalid-name] from typing import Annotated IntWithAnnotation = Annotated[int, "anything"] + + +# Regression test for #10719: module-level constants should not be incorrectly +# classified as variables when a class-level attribute with the same name exists. +class Theme: + INPUT = ">>> " + + +INPUT = Theme() +input = Theme() # pylint: disable=redefined-builtin +OUTPUT = Theme() +output = Theme() diff --git a/tests/functional/n/no/no_dummy_redefined.py b/tests/functional/n/no/no_dummy_redefined.py index b902291b7a..6c326311c4 100644 --- a/tests/functional/n/no/no_dummy_redefined.py +++ b/tests/functional/n/no/no_dummy_redefined.py @@ -1,4 +1,5 @@ """Make sure warnings about redefinitions do not trigger for dummy variables.""" +# pylint: disable=invalid-name _, INTERESTING = 'a=b'.split('=') diff --git a/tests/functional/n/no/no_dummy_redefined.txt b/tests/functional/n/no/no_dummy_redefined.txt index c469db5b18..d800ec4804 100644 --- a/tests/functional/n/no/no_dummy_redefined.txt +++ b/tests/functional/n/no/no_dummy_redefined.txt @@ -1 +1 @@ -redefined-outer-name:11:4:11:9:clobbering:Redefining name 'value' from outer scope (line 6):UNDEFINED +redefined-outer-name:12:4:12:9:clobbering:Redefining name 'value' from outer scope (line 7):UNDEFINED