Repository: PyCQA/pyflakes
Pull request #825 (2025-03-29 18:38:00)

add warning for unused global / nonlocal names
some example output from a work codebase:

```console
$ flake8  src/ tests/
src/sentry/backup/imports.py:397:9: F999 `nonlocal deferred_org_auth_tokens` is unused: name is never assigned in scope
src/sentry/backup/imports.py:397:9: F999 `nonlocal import_write_context` is unused: name is never assigned in scope
src/sentry/llm/usecases/__init__.py:29:5: F999 `global llm_provider_backends` is unused: name is never assigned in scope
src/sentry/runner/commands/workstations.py:191:13: F999 `nonlocal proc` is unused: name is never assigned in scope
src/sentry/runner/commands/workstations.py:191:13: F999 `nonlocal silence_stderr` is unused: name is never assigned in scope
src/sentry/taskworker/worker.py:95:9: F999 `nonlocal current_activation` is unused: name is never assigned in scope
src/sentry/taskworker/worker.py:95:9: F999 `nonlocal processed_tasks` is unused: name is never assigned in scope
src/sentry/utils/mockdata/core.py:941:17: F999 `nonlocal timestamp` is unused: name is never assigned in scope
src/sentry/utils/mockdata/core.py:1188:17: F999 `nonlocal timestamp` is unused: name is never assigned in scope
src/sentry/utils/mockdata/core.py:1189:17: F999 `nonlocal duration` is unused: name is never assigned in scope
src/social_auth/backends/__init__.py:731:5: F999 `global BACKENDSCACHE` is unused: name is never assigned in scope
tests/sentry/backup/test_rpc.py:57:13: F999 `nonlocal option_count` is unused: name is never assigned in scope
tests/sentry/backup/test_rpc.py:57:13: F999 `nonlocal import_chunk_count` is unused: name is never assigned in scope
tests/sentry/backup/test_rpc.py:57:13: F999 `nonlocal import_uuid` is unused: name is never assigned in scope
tests/sentry/backup/test_rpc.py:134:13: F999 `nonlocal control_option_count` is unused: name is never assigned in scope
tests/sentry/backup/test_rpc.py:134:13: F999 `nonlocal import_chunk_count` is unused: name is never assigned in scope
tests/sentry/backup/test_rpc.py:134:13: F999 `nonlocal import_uuid` is unused: name is never assigned in scope
tests/sentry/hybridcloud/rpc/test_caching.py:129:9: F999 `nonlocal true_value` is unused: name is never assigned in scope

```


Patch:
diff --git a/pyflakes/checker.py b/pyflakes/checker.py
index fbc0ff8..c7d3e88 100644
--- a/pyflakes/checker.py
+++ b/pyflakes/checker.py
@@ -543,6 +543,8 @@ class FunctionScope(Scope):
         super().__init__()
         # Simplify: manage the special locals as globals
         self.globals = self.alwaysUsed.copy()
+        # {name: node}
+        self.indirect_assignments = {}
 
     def unused_assignments(self):
         """
@@ -564,6 +566,9 @@ class FunctionScope(Scope):
             if not binding.used and isinstance(binding, Annotation):
                 yield name, binding
 
+    def unused_indirect_assignments(self):
+        return self.indirect_assignments.items()
+
 
 class TypeScope(Scope):
     pass
@@ -839,6 +844,8 @@ class Checker:
                     self.report(messages.UnusedVariable, binding.source, name)
                 for name, binding in scope.unused_annotations():
                     self.report(messages.UnusedAnnotation, binding.source, name)
+                for name, node in scope.unused_indirect_assignments():
+                    self.report(messages.UnusedIndirectAssignment, node, name)
 
             all_binding = scope.get('__all__')
             if all_binding and not isinstance(all_binding, ExportBinding):
@@ -981,6 +988,9 @@ class Checker:
                     self.report(messages.RedefinedWhileUnused,
                                 node, value.name, existing.source)
 
+                if isinstance(scope, FunctionScope):
+                    scope.indirect_assignments.pop(value.name, None)
+
             elif isinstance(existing, Importation) and value.redefines(existing):
                 existing.redefined.append(node)
 
@@ -1177,6 +1187,9 @@ class Checker:
             # be executed.
             return
 
+        if isinstance(self.scope, FunctionScope):
+            self.scope.indirect_assignments.pop(name, None)
+
         if isinstance(self.scope, FunctionScope) and name in self.scope.globals:
             self.scope.globals.remove(name)
         else:
@@ -1835,6 +1848,8 @@ class Checker:
                 for scope in self.scopeStack[global_scope_index + 1:]:
                     scope[node_name] = node_value
 
+                self.scope.indirect_assignments[node_name] = node
+
     NONLOCAL = GLOBAL
 
     def GENERATOREXP(self, node):
diff --git a/pyflakes/messages.py b/pyflakes/messages.py
index 86cf6c7..93307e5 100644
--- a/pyflakes/messages.py
+++ b/pyflakes/messages.py
@@ -168,6 +168,15 @@ class UnusedAnnotation(Message):
         self.message_args = (names,)
 
 
+class UnusedIndirectAssignment(Message):
+    """A `global` or `nonlocal` statement where the name is never reassigned"""
+    message = '`%s %s` is unused: name is never assigned in scope'
+
+    def __init__(self, filename, loc, name):
+        Message.__init__(self, filename, loc)
+        self.message_args = (type(loc).__name__.lower(), name)
+
+
 class ReturnOutsideFunction(Message):
     """
     Indicates a return statement outside of a function/method.
diff --git a/setup.py b/setup.py
index 3cc2fbd..437353f 100755
--- a/setup.py
+++ b/setup.py
@@ -42,7 +42,7 @@ setup(
     author_email="code-quality@python.org",
     url="https://github.com/PyCQA/pyflakes",
     packages=["pyflakes", "pyflakes.scripts", "pyflakes.test"],
-    python_requires='>=3.8',
+    python_requires='>=3.9',
     classifiers=[
         "Development Status :: 6 - Mature",
         "Environment :: Console",


Test patch:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index eacd67c..9d630de 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -12,12 +12,12 @@ jobs:
     strategy:
       fail-fast: false
       matrix:
-        python-version: ["3.8", "3.9", "3.10", "3.11", "3.12-dev", "pypy-3.9"]
+        python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "pypy-3.9"]
         os: [ubuntu-latest]
         # Include minimum py3 + maximum py3 + pypy3 on Windows
         include:
-          - { os: "windows-latest" , python-version: "3.8" }
-          - { os: "windows-latest" , python-version: "3.11" }
+          - { os: "windows-latest" , python-version: "3.9" }
+          - { os: "windows-latest" , python-version: "3.13" }
           - { os: "windows-latest" , python-version: "pypy-3.9" }
 
     steps:
diff --git a/pyflakes/test/test_api.py b/pyflakes/test/test_api.py
index ec1cbdb..41201e4 100644
--- a/pyflakes/test/test_api.py
+++ b/pyflakes/test/test_api.py
@@ -479,16 +479,12 @@ def foo(bar=baz, bax):
             else:
                 msg = 'non-default argument follows default argument'
 
-            if PYPY and sys.version_info >= (3, 9):
+            if PYPY:
                 column = 18
-            elif PYPY:
-                column = 8
             elif sys.version_info >= (3, 10):
                 column = 18
-            elif sys.version_info >= (3, 9):
-                column = 21
             else:
-                column = 9
+                column = 21
             last_line = ' ' * (column - 1) + '^\n'
             self.assertHasErrors(
                 sourcePath,
@@ -508,23 +504,13 @@ def foo(bar=baz, bax):
 foo(bar=baz, bax)
 """
         with self.makeTempFile(source) as sourcePath:
-            if sys.version_info >= (3, 9):
-                column = 17
-            elif not PYPY:
-                column = 14
-            else:
-                column = 13
-            last_line = ' ' * (column - 1) + '^\n'
-            columnstr = '%d:' % column
-
-            message = 'positional argument follows keyword argument'
-
+            last_line = ' ' * 16 + '^\n'
             self.assertHasErrors(
                 sourcePath,
-                ["""\
-{}:1:{} {}
+                [f"""\
+{sourcePath}:1:17: positional argument follows keyword argument
 foo(bar=baz, bax)
-{}""".format(sourcePath, columnstr, message, last_line)])
+{last_line}"""])
 
     def test_invalidEscape(self):
         """
@@ -533,11 +519,9 @@ foo(bar=baz, bax)
         # ValueError: invalid \x escape
         with self.makeTempFile(r"foo = '\xyz'") as sourcePath:
             position_end = 1
-            if PYPY and sys.version_info >= (3, 9):
+            if PYPY:
                 column = 7
-            elif PYPY:
-                column = 6
-            elif (3, 9) <= sys.version_info < (3, 12):
+            elif sys.version_info < (3, 12):
                 column = 13
             else:
                 column = 7
@@ -669,23 +653,11 @@ x = "%s"
         self.assertEqual(count, 1)
         errlines = err.getvalue().split("\n")[:-1]
 
-        if sys.version_info >= (3, 9):
-            expected_error = [
-                "<stdin>:1:5: Generator expression must be parenthesized",
-                "max(1 for i in range(10), key=lambda x: x+1)",
-                "    ^",
-            ]
-        elif PYPY:
-            expected_error = [
-                "<stdin>:1:4: Generator expression must be parenthesized if not sole argument",  # noqa: E501
-                "max(1 for i in range(10), key=lambda x: x+1)",
-                "   ^",
-            ]
-        else:
-            expected_error = [
-                "<stdin>:1:5: Generator expression must be parenthesized",
-            ]
-
+        expected_error = [
+            "<stdin>:1:5: Generator expression must be parenthesized",
+            "max(1 for i in range(10), key=lambda x: x+1)",
+            "    ^",
+        ]
         self.assertEqual(errlines, expected_error)
 
 
@@ -774,8 +746,14 @@ class IntegrationTests(TestCase):
         with open(self.tempfilepath, 'wb') as fd:
             fd.write(b"import")
         d = self.runPyflakes([self.tempfilepath])
-        error_msg = '{0}:1:7: invalid syntax{1}import{1}      ^{1}'.format(
-            self.tempfilepath, os.linesep)
+
+        if sys.version_info >= (3, 13):
+            message = "Expected one or more names after 'import'"
+        else:
+            message = 'invalid syntax'
+
+        error_msg = '{0}:1:7: {1}{2}import{2}      ^{2}'.format(
+            self.tempfilepath, message, os.linesep)
         self.assertEqual(d, ('', error_msg, 1))
 
     def test_readFromStdin(self):
diff --git a/pyflakes/test/test_imports.py b/pyflakes/test/test_imports.py
index fb5d2fd..0fdf96d 100644
--- a/pyflakes/test/test_imports.py
+++ b/pyflakes/test/test_imports.py
@@ -654,7 +654,7 @@ class Test(TestCase):
         self.flakes('''
         import fu
         def f(): global fu
-        ''', m.UnusedImport)
+        ''', m.UnusedImport, m.UnusedIndirectAssignment)
 
     def test_usedAndGlobal(self):
         """
@@ -665,7 +665,7 @@ class Test(TestCase):
             import foo
             def f(): global foo
             def g(): foo.is_used()
-        ''')
+        ''', m.UnusedIndirectAssignment)
 
     def test_assignedToGlobal(self):
         """
diff --git a/pyflakes/test/test_other.py b/pyflakes/test/test_other.py
index 0af87ec..68c38e9 100644
--- a/pyflakes/test/test_other.py
+++ b/pyflakes/test/test_other.py
@@ -1085,7 +1085,41 @@ class Test(TestCase):
         self.flakes('''
         def f(): global foo
         def g(): foo = 'anything'; foo.is_used()
-        ''')
+        ''', m.UnusedIndirectAssignment)
+
+    def test_unused_global_statement(self):
+        self.flakes('''
+        g = 0
+        def f1():
+            global g
+            g = 1
+        def f2():
+            global g  # this is unused!
+            return g
+        ''', m.UnusedIndirectAssignment)
+
+    def test_unused_nonlocal_statement(self):
+        self.flakes('''
+        def f():
+            x = 1
+            def set_x():
+                nonlocal x
+                x = 2
+            def get_x():
+                nonlocal x
+                return x
+            set_x()
+            return get_x()
+        ''', m.UnusedIndirectAssignment)
+
+    def test_unused_global_statement_not_marked_as_used_by_nested_scope(self):
+        self.flakes('''
+        g = 0
+        def f():
+            global g
+            def f2():
+                g = 2
+        ''', m.UnusedIndirectAssignment, m.UnusedVariable)
 
     def test_function_arguments(self):
         """
diff --git a/pyflakes/test/test_type_annotations.py b/pyflakes/test/test_type_annotations.py
index 4c8b998..343083e 100644
--- a/pyflakes/test/test_type_annotations.py
+++ b/pyflakes/test/test_type_annotations.py
@@ -797,3 +797,10 @@ class TestTypeAnnotations(TestCase):
                 return f(*args, **kwargs)
             return g
         """)
+
+    @skipIf(version_info < (3, 13), 'new in Python 3.13')
+    def test_type_parameter_defaults(self):
+        self.flakes("""
+        def f[T = int](u: T) -> T:
+            return u
+        """)
diff --git a/pyflakes/test/test_undefined_names.py b/pyflakes/test/test_undefined_names.py
index c2d2d87..f4d32e3 100644
--- a/pyflakes/test/test_undefined_names.py
+++ b/pyflakes/test/test_undefined_names.py
@@ -327,7 +327,7 @@ class Test(TestCase):
 
         def f2():
             global m
-        ''', m.UndefinedName)
+        ''', m.UndefinedName, m.UnusedIndirectAssignment)
 
     @skip("todo")
     def test_unused_global(self):
@@ -462,7 +462,7 @@ class Test(TestCase):
                     a
                     a = 2
                     return a
-        ''', m.UndefinedLocal)
+        ''', m.UndefinedLocal, m.UnusedIndirectAssignment)
 
     def test_intermediateClassScopeIgnored(self):
         """