From 5d192ce187d8ed47dce3ee3d1fdbd64891f2c11c Mon Sep 17 00:00:00 2001 From: Daniel Li Date: Tue, 18 Oct 2022 15:20:07 -0400 Subject: [PATCH 1/9] Add __debuggerskip__ as special local __debuggerskip__ is a special variable used by IPython, similar to __tracebackhide__. --- pyflakes/checker.py | 2 +- pyflakes/test/test_other.py | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/pyflakes/checker.py b/pyflakes/checker.py index 754ab30c..2b0a23de 100644 --- a/pyflakes/checker.py +++ b/pyflakes/checker.py @@ -548,7 +548,7 @@ class FunctionScope(Scope): """ usesLocals = False alwaysUsed = {'__tracebackhide__', '__traceback_info__', - '__traceback_supplement__'} + '__traceback_supplement__', '__debuggerskip__'} def __init__(self): super().__init__() diff --git a/pyflakes/test/test_other.py b/pyflakes/test/test_other.py index aebdceab..f81a09e5 100644 --- a/pyflakes/test/test_other.py +++ b/pyflakes/test/test_other.py @@ -1349,6 +1349,16 @@ def helper(): __tracebackhide__ = True """) + def test_debuggerskipSpecialVariable(self): + """ + Do not warn about unused local variable __debuggerskip__, which is + a special variable for IPython. + """ + self.flakes(""" + def helper(): + __debuggerskip__ = True + """) + def test_ifexp(self): """ Test C{foo if bar else baz} statements. From b1f8362e45aab6e5ba0b49b282b5be9c05467c50 Mon Sep 17 00:00:00 2001 From: Anthony Sottile Date: Fri, 16 Feb 2024 21:54:02 -0500 Subject: [PATCH 2/9] allow assignment expressions to redefine outer names (#801) --- pyflakes/checker.py | 19 +++++++++++-------- pyflakes/test/test_other.py | 7 +++++++ 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/pyflakes/checker.py b/pyflakes/checker.py index 2b0a23de..47a59ba2 100644 --- a/pyflakes/checker.py +++ b/pyflakes/checker.py @@ -1003,14 +1003,17 @@ def addBinding(self, node, value): # don't treat annotations as assignments if there is an existing value # in scope if value.name not in self.scope or not isinstance(value, Annotation): - cur_scope_pos = -1 - # As per PEP 572, use scope in which outermost generator is defined - while ( - isinstance(value, NamedExprAssignment) and - isinstance(self.scopeStack[cur_scope_pos], GeneratorScope) - ): - cur_scope_pos -= 1 - self.scopeStack[cur_scope_pos][value.name] = value + if isinstance(value, NamedExprAssignment): + # PEP 572: use scope in which outermost generator is defined + scope = next( + scope + for scope in reversed(self.scopeStack) + if not isinstance(scope, GeneratorScope) + ) + # it may be a re-assignment to an already existing name + scope.setdefault(value.name, value) + else: + self.scope[value.name] = value def _unknown_handler(self, node): # this environment variable configures whether to error on unknown diff --git a/pyflakes/test/test_other.py b/pyflakes/test/test_other.py index f81a09e5..0af87ec1 100644 --- a/pyflakes/test/test_other.py +++ b/pyflakes/test/test_other.py @@ -1717,6 +1717,13 @@ def test_assign_expr_generator_scope(self): print(y) ''') + def test_assign_expr_generator_scope_reassigns_parameter(self): + self.flakes(''' + def foo(x): + fns = [lambda x: x + 1, lambda x: x + 2, lambda x: x + 3] + return [(x := fn(x)) for fn in fns] + ''') + def test_assign_expr_nested(self): """Test assignment expressions in nested expressions.""" self.flakes(''' From c68ed1b1950f696408738806aed0a81177f06416 Mon Sep 17 00:00:00 2001 From: Stefan <96178532+stefan6419846@users.noreply.github.com> Date: Thu, 7 Mar 2024 15:47:17 +0100 Subject: [PATCH 3/9] Fix supported Python versions in README (#803) --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 1d521063..333d439e 100644 --- a/README.rst +++ b/README.rst @@ -9,7 +9,7 @@ parsing the source file, not importing it, so it is safe to use on modules with side effects. It's also much faster. It is `available on PyPI `_ -and it supports all active versions of Python: 3.6+. +and it supports all active versions of Python: 3.8+. From ac563ed24051d41fe04104eefa1563cbb8b36bf7 Mon Sep 17 00:00:00 2001 From: "Edward K. Ream" Date: Fri, 28 Jun 2024 15:42:48 -0500 Subject: [PATCH 4/9] Replace 'counter' function with 'collection.Counter' Closes #813 --- pyflakes/checker.py | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/pyflakes/checker.py b/pyflakes/checker.py index 47a59ba2..2fe7f37a 100644 --- a/pyflakes/checker.py +++ b/pyflakes/checker.py @@ -165,17 +165,6 @@ def __missing__(self, node_class): return fields -def counter(items): - """ - Simplest required implementation of collections.Counter. Required as 2.6 - does not have Counter in collections. - """ - results = {} - for item in items: - results[item] = results.get(item, 0) + 1 - return results - - def iter_child_nodes(node, omit=None, _fields_order=_FieldsOrder()): """ Yield all direct child nodes of *node*, that is, all fields that @@ -1777,7 +1766,7 @@ def DICT(self, node): convert_to_value(key) for key in node.keys ] - key_counts = counter(keys) + key_counts = collections.Counter(keys) duplicate_keys = [ key for key, count in key_counts.items() if count > 1 @@ -1786,7 +1775,7 @@ def DICT(self, node): for key in duplicate_keys: key_indices = [i for i, i_key in enumerate(keys) if i_key == key] - values = counter( + values = collections.Counter( convert_to_value(node.values[index]) for index in key_indices ) From d9e32c4cbdb569e66d5f63c9687b8a396796a44b Mon Sep 17 00:00:00 2001 From: Anthony Sottile Date: Thu, 3 Oct 2024 16:54:44 -0400 Subject: [PATCH 5/9] remove unused returnValue (#819) last referenced in 2246217295dc8cb30ef4a7b9d8dc449ce32e603a --- pyflakes/checker.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/pyflakes/checker.py b/pyflakes/checker.py index 2fe7f37a..fbc0ff83 100644 --- a/pyflakes/checker.py +++ b/pyflakes/checker.py @@ -543,7 +543,6 @@ def __init__(self): super().__init__() # Simplify: manage the special locals as globals self.globals = self.alwaysUsed.copy() - self.returnValue = None # First non-empty return def unused_assignments(self): """ @@ -1888,12 +1887,6 @@ def RETURN(self, node): self.report(messages.ReturnOutsideFunction, node) return - if ( - node.value and - hasattr(self.scope, 'returnValue') and - not self.scope.returnValue - ): - self.scope.returnValue = node.value self.handleNode(node.value, node) def YIELD(self, node): From 5f1f434c2f85aebd05776f8eb94e83a1bf9e881e Mon Sep 17 00:00:00 2001 From: Anthony Sottile Date: Sat, 29 Mar 2025 14:52:12 -0400 Subject: [PATCH 6/9] drop 3.8, add 3.13 (#826) --- .github/workflows/test.yml | 6 +-- pyflakes/test/test_api.py | 64 +++++++++----------------- pyflakes/test/test_type_annotations.py | 7 +++ setup.py | 2 +- 4 files changed, 32 insertions(+), 47 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index eacd67c6..9d630de6 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 ec1cbdbf..41201e4e 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 test_nonKeywordAfterKeywordSyntaxError(self): 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 @@ def test_invalidEscape(self): # 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 @@ def test_stdinReportsErrors(self): self.assertEqual(count, 1) errlines = err.getvalue().split("\n")[:-1] - if sys.version_info >= (3, 9): - expected_error = [ - ":1:5: Generator expression must be parenthesized", - "max(1 for i in range(10), key=lambda x: x+1)", - " ^", - ] - elif PYPY: - expected_error = [ - ":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 = [ - ":1:5: Generator expression must be parenthesized", - ] - + expected_error = [ + ":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 @@ def test_errors_syntax(self): 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_type_annotations.py b/pyflakes/test/test_type_annotations.py index 4c8b998f..343083e7 100644 --- a/pyflakes/test/test_type_annotations.py +++ b/pyflakes/test/test_type_annotations.py @@ -797,3 +797,10 @@ def g(*args: P.args, **kwargs: P.kwargs) -> R: 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/setup.py b/setup.py index 3cc2fbd9..437353fd 100755 --- a/setup.py +++ b/setup.py @@ -42,7 +42,7 @@ def get_long_description(): 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", From cadcd60a70c118c1d8b6dc8c09e53dc8f32b1666 Mon Sep 17 00:00:00 2001 From: Anthony Sottile Date: Sat, 29 Mar 2025 15:13:17 -0400 Subject: [PATCH 7/9] add warning for unused global / nonlocal names (#825) --- pyflakes/checker.py | 15 +++++++++++ pyflakes/messages.py | 9 +++++++ pyflakes/test/test_imports.py | 4 +-- pyflakes/test/test_other.py | 36 ++++++++++++++++++++++++++- pyflakes/test/test_undefined_names.py | 4 +-- 5 files changed, 63 insertions(+), 5 deletions(-) diff --git a/pyflakes/checker.py b/pyflakes/checker.py index fbc0ff83..c7d3e882 100644 --- a/pyflakes/checker.py +++ b/pyflakes/checker.py @@ -543,6 +543,8 @@ def __init__(self): 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 @@ def unused_annotations(self): 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 @@ def checkDeadScopes(self): 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 @@ def addBinding(self, node, value): 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 @@ def on_conditional_branch(): # 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 @@ def GLOBAL(self, node): 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 86cf6c78..93307e53 100644 --- a/pyflakes/messages.py +++ b/pyflakes/messages.py @@ -168,6 +168,15 @@ def __init__(self, filename, loc, names): 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/pyflakes/test/test_imports.py b/pyflakes/test/test_imports.py index fb5d2fd9..0fdf96df 100644 --- a/pyflakes/test/test_imports.py +++ b/pyflakes/test/test_imports.py @@ -654,7 +654,7 @@ def test_usedInGlobal(self): self.flakes(''' import fu def f(): global fu - ''', m.UnusedImport) + ''', m.UnusedImport, m.UnusedIndirectAssignment) def test_usedAndGlobal(self): """ @@ -665,7 +665,7 @@ def test_usedAndGlobal(self): 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 0af87ec1..68c38e95 100644 --- a/pyflakes/test/test_other.py +++ b/pyflakes/test/test_other.py @@ -1085,7 +1085,41 @@ def test_globalDeclaredInDifferentScope(self): 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_undefined_names.py b/pyflakes/test/test_undefined_names.py index c2d2d87f..f4d32e3c 100644 --- a/pyflakes/test/test_undefined_names.py +++ b/pyflakes/test/test_undefined_names.py @@ -327,7 +327,7 @@ def f1(): def f2(): global m - ''', m.UndefinedName) + ''', m.UndefinedName, m.UnusedIndirectAssignment) @skip("todo") def test_unused_global(self): @@ -462,7 +462,7 @@ def fun2(): a a = 2 return a - ''', m.UndefinedLocal) + ''', m.UndefinedLocal, m.UnusedIndirectAssignment) def test_intermediateClassScopeIgnored(self): """ From bb6806909922778967311ba491e4c3ccc942874b Mon Sep 17 00:00:00 2001 From: Anthony Sottile Date: Sat, 29 Mar 2025 15:22:10 -0400 Subject: [PATCH 8/9] update python version in README (#827) --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 333d439e..e729329e 100644 --- a/README.rst +++ b/README.rst @@ -9,7 +9,7 @@ parsing the source file, not importing it, so it is safe to use on modules with side effects. It's also much faster. It is `available on PyPI `_ -and it supports all active versions of Python: 3.8+. +and it supports all active versions of Python: 3.9+. From 433dfd001746a69d12597f7c97af78c13e1f662e Mon Sep 17 00:00:00 2001 From: Anthony Sottile Date: Sat, 29 Mar 2025 15:30:26 -0400 Subject: [PATCH 9/9] Release 3.3.0 --- NEWS.rst | 7 +++++++ pyflakes/__init__.py | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/NEWS.rst b/NEWS.rst index a0c10c98..764b2b87 100644 --- a/NEWS.rst +++ b/NEWS.rst @@ -1,3 +1,10 @@ +3.3.0 (2025-03-29) + +- Add ``__debuggerskip__`` as a special local +- Allow assignment expressions to redefine outer names +- Drop support for EOL python 3.8 +- Add new error for unused ``global`` / ``nonlocal`` names + 3.2.0 (2024-01-04) - Add support for ``*T`` (TypeVarTuple) and ``**P`` (ParamSpec) in PEP 695 diff --git a/pyflakes/__init__.py b/pyflakes/__init__.py index 573cf70b..6a157dcb 100644 --- a/pyflakes/__init__.py +++ b/pyflakes/__init__.py @@ -1 +1 @@ -__version__ = '3.2.0' +__version__ = '3.3.0'