From dc448562b1872b3ec73a5566ef954fbf2301eba2 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 | 44 ++++++++++++++----- tests/checkers/unittest_utils.py | 29 ++++++++++++ .../c/const_class_attribute_same_name.py | 14 ++++++ 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 create mode 100644 tests/functional/c/const_class_attribute_same_name.py 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..cf817e3cf2 100644 --- a/pylint/checkers/utils.py +++ b/pylint/checkers/utils.py @@ -1843,28 +1843,48 @@ 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 bool( + candidate.parent is not None and candidate.parent.scope() == node_scope + ) + return bool(candidate.scope() == node_scope) + + 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) - ) - ) + 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 and a.lineno < node_lineno: + if _is_node_in_same_scope(a, node_scope): + return True + return False 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) - ) - ) + 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 and a.lineno > node_lineno: + if _is_node_in_same_scope(a, node_scope): + return True + return 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/c/const_class_attribute_same_name.py b/tests/functional/c/const_class_attribute_same_name.py new file mode 100644 index 0000000000..4ba090483f --- /dev/null +++ b/tests/functional/c/const_class_attribute_same_name.py @@ -0,0 +1,14 @@ +"""Test module-level constants with class attribute same name +Regression test for #10719. +""" +# pylint: disable=missing-docstring, too-few-public-methods, redefined-builtin + + +class Theme: + INPUT = ">>> " + + +INPUT = Theme() +input = Theme() +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