diff --git a/doc/whatsnew/fragments/10028.false_negative b/doc/whatsnew/fragments/10028.false_negative new file mode 100644 index 0000000000..eaf356ff6c --- /dev/null +++ b/doc/whatsnew/fragments/10028.false_negative @@ -0,0 +1,3 @@ +Fix false negative for `used-before-assignment` when a function is defined inside a `TYPE_CHECKING` guard block and used later. + +Closes #10028 diff --git a/pylint/checkers/variables.py b/pylint/checkers/variables.py index 150dcba838..31942128b7 100644 --- a/pylint/checkers/variables.py +++ b/pylint/checkers/variables.py @@ -303,6 +303,31 @@ def _assigned_locally(name_node: nodes.Name) -> bool: ) +def _is_before(node: nodes.NodeNG, reference_node: nodes.NodeNG) -> bool: + """Checks if node appears before reference_node.""" + if node.lineno < reference_node.lineno: + return True + if ( + node.lineno == reference_node.lineno + and node.col_offset < reference_node.col_offset + ): + return True + return False + + +def _is_nonlocal_name(node: nodes.Name, frame: nodes.LocalsDictNodeNG) -> bool: + """Checks if name node has a nonlocal declaration in the given frame.""" + if not isinstance(frame, nodes.FunctionDef): + return False + + return any( + isinstance(stmt, nodes.Nonlocal) + and node.name in stmt.names + and _is_before(stmt, node) + for stmt in frame.body + ) + + def _has_locals_call_after_node(stmt: nodes.NodeNG, scope: nodes.FunctionDef) -> bool: skip_nodes = ( nodes.FunctionDef, @@ -562,10 +587,7 @@ def get_next_to_consume(self, node: nodes.Name) -> list[nodes.NodeNG] | None: found_nodes = None # Before filtering, check that this node's name is not a nonlocal - if any( - isinstance(child, nodes.Nonlocal) and node.name in child.names - for child in node.frame().get_children() - ): + if _is_nonlocal_name(node, node.frame()): return found_nodes # And no comprehension is under the node's frame @@ -723,7 +745,7 @@ def _uncertain_nodes_if_tests( name = other_node.name elif isinstance(other_node, (nodes.Import, nodes.ImportFrom)): name = node.name - elif isinstance(other_node, nodes.ClassDef): + elif isinstance(other_node, (nodes.FunctionDef, nodes.ClassDef)): name = other_node.name else: continue @@ -1959,6 +1981,8 @@ def _report_unfound_name_definition( return False if self._is_variable_annotation_in_function(node): return False + if self._has_nonlocal_binding(node): + return False if ( node.name in self._reported_type_checking_usage_scopes and node.scope() in self._reported_type_checking_usage_scopes[node.name] @@ -2215,10 +2239,7 @@ def _is_variable_violation( ) # check if we have a nonlocal elif node.name in defframe.locals: - maybe_before_assign = not any( - isinstance(child, nodes.Nonlocal) and node.name in child.names - for child in defframe.get_children() - ) + maybe_before_assign = not _is_nonlocal_name(node, defframe) if ( base_scope_type == "lambda" @@ -2291,19 +2312,11 @@ def _is_variable_violation( # x = b if (b := True) else False maybe_before_assign = False elif ( - isinstance( # pylint: disable=too-many-boolean-expressions - defnode, nodes.NamedExpr - ) + isinstance(defnode, nodes.NamedExpr) and frame is defframe and defframe.parent_of(stmt) and stmt is defstmt - and ( - ( - defnode.lineno == node.lineno - and defnode.col_offset < node.col_offset - ) - or (defnode.lineno < node.lineno) - ) + and _is_before(defnode, node) ): # Relation of a name to the same name in a named expression # Could be used before assignment if self-referencing: @@ -2358,6 +2371,15 @@ def _maybe_used_and_assigned_at_once(defstmt: _base_nodes.Statement) -> bool: def _is_builtin(self, name: str) -> bool: return name in self.linter.config.additional_builtins or utils.is_builtin(name) + def _has_nonlocal_binding(self, node: nodes.Name) -> bool: + """Checks if name node has a nonlocal binding in any enclosing frame.""" + frame = node.frame() + while frame: + if _is_nonlocal_name(node, frame): + return True + frame = frame.parent.frame() if frame.parent else None + return False + @staticmethod def _is_only_type_assignment( node: nodes.Name, diff --git a/tests/functional/u/used/used_before_assignment_nonlocal.py b/tests/functional/u/used/used_before_assignment_nonlocal.py index 4d926e9eb0..4dc8bbf943 100644 --- a/tests/functional/u/used/used_before_assignment_nonlocal.py +++ b/tests/functional/u/used/used_before_assignment_nonlocal.py @@ -106,3 +106,30 @@ def inner(): print(args) inner() print(args) + + +def nonlocal_in_outer_frame_fail(): + """Nonlocal declared in outer frame, bad usage and assignment in inner frame.""" + num = 1 + def outer(): + nonlocal num + def inner(): + print(num) # [used-before-assignment] + num = 2 + inner() + outer() + + +def nonlocal_in_outer_frame_ok(callback, condition_a, condition_b): + """Nonlocal declared in outer frame, usage and definition in different frames.""" + def outer(): + nonlocal callback + if condition_a: + def inner(): + callback() # should not emit possibly-used-before-assignment + inner() + else: + if condition_b: + def callback(): + pass + outer() diff --git a/tests/functional/u/used/used_before_assignment_nonlocal.txt b/tests/functional/u/used/used_before_assignment_nonlocal.txt index 2bdbf2fe1e..887985fda2 100644 --- a/tests/functional/u/used/used_before_assignment_nonlocal.txt +++ b/tests/functional/u/used/used_before_assignment_nonlocal.txt @@ -6,3 +6,4 @@ used-before-assignment:33:44:33:53:test_fail4:Using variable 'undefined' before used-before-assignment:39:18:39:28:test_fail5:Using variable 'undefined1' before assignment:HIGH used-before-assignment:90:10:90:18:type_annotation_never_gets_value_despite_nonlocal:Using variable 'some_num' before assignment:HIGH used-before-assignment:96:14:96:18:inner_function_lacks_access_to_outer_args.inner:Using variable 'args' before assignment:HIGH +used-before-assignment:117:18:117:21:nonlocal_in_outer_frame_fail.outer.inner:Using variable 'num' before assignment:HIGH diff --git a/tests/functional/u/used/used_before_assignment_typing.py b/tests/functional/u/used/used_before_assignment_typing.py index 585a26a628..5229260d2f 100644 --- a/tests/functional/u/used/used_before_assignment_typing.py +++ b/tests/functional/u/used/used_before_assignment_typing.py @@ -173,7 +173,7 @@ def defined_in_elif_branch(self) -> calendar.Calendar: # [possibly-used-before- def defined_in_else_branch(self) -> urlopen: print(zoneinfo) # [used-before-assignment] - print(pprint()) + print(pprint()) # [used-before-assignment] print(collections()) # [used-before-assignment] return urlopen diff --git a/tests/functional/u/used/used_before_assignment_typing.txt b/tests/functional/u/used/used_before_assignment_typing.txt index 886ac70759..06e162e9c4 100644 --- a/tests/functional/u/used/used_before_assignment_typing.txt +++ b/tests/functional/u/used/used_before_assignment_typing.txt @@ -6,6 +6,7 @@ used-before-assignment:153:20:153:28:VariableAnnotationsGuardedByTypeChecking:Us possibly-used-before-assignment:170:40:170:48:TypeCheckingMultiBranch.defined_in_elif_branch:Possibly using variable 'calendar' before assignment:INFERENCE possibly-used-before-assignment:171:14:171:20:TypeCheckingMultiBranch.defined_in_elif_branch:Possibly using variable 'bisect' before assignment:INFERENCE used-before-assignment:175:14:175:22:TypeCheckingMultiBranch.defined_in_else_branch:Using variable 'zoneinfo' before assignment:INFERENCE +used-before-assignment:176:14:176:20:TypeCheckingMultiBranch.defined_in_else_branch:Using variable 'pprint' before assignment:INFERENCE used-before-assignment:177:14:177:25:TypeCheckingMultiBranch.defined_in_else_branch:Using variable 'collections' before assignment:INFERENCE possibly-used-before-assignment:180:43:180:48:TypeCheckingMultiBranch.defined_in_nested_if_else:Possibly using variable 'heapq' before assignment:INFERENCE used-before-assignment:184:39:184:44:TypeCheckingMultiBranch.defined_in_try_except:Using variable 'array' before assignment:INFERENCE