Repository: pylint-dev/pylint

Issue:
[used-before-assignment] Functions defined in type-checking guard not flagged when used later
In #9990, we [discovered](https://github.com/pylint-dev/pylint/pull/9990#issuecomment-2411463617) that extending the fix for false negatives for used-before-assignment for objects guarded behind type-checking blocks to include functions would cause a handful of false positives in the primer. So we backed out that change and opened this issue to investigate.

@zenlyj graciously offered to find time to look into it.


Discussion:
will also look at this `unused-import` false negative that @nickdrozd found.

```
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from math import pi  # unused-import false negative
```



Patch:
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,


Test patch:
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