From 271ceb204814cd08e3ee4a88eac8312b7201f5cc Mon Sep 17 00:00:00 2001 From: DG-Elom Date: Wed, 14 Oct 2026 19:50:14 +0200 Subject: [PATCH 1/3] B007: don't flag loop variables read by functions defined in the same scope A loop variable read as a free variable by a function or lambda defined in the same scope as the loop is used. Use the standard library symtable to resolve this like CPython does: free variables of the functions nested in the loop scope, or globals they read for a module-level loop. Fall back to no exemption when the source cannot be compiled. Fixes #175. --- README.rst | 2 + bugbear.py | 58 +++++- tests/eval_files/b007.py | 260 ++++++++++++++++++++++++++ tests/eval_files/b007_module_scope.py | 53 ++++++ tests/eval_files/b007_py310.py | 25 +++ 5 files changed, 396 insertions(+), 2 deletions(-) create mode 100644 tests/eval_files/b007_module_scope.py create mode 100644 tests/eval_files/b007_py310.py diff --git a/README.rst b/README.rst index d3fdf88..9a1b920 100644 --- a/README.rst +++ b/README.rst @@ -565,6 +565,8 @@ Change Log UNRELEASED ~~~~~~~~~~ +* B007: don't flag a loop variable that is read by a function or lambda defined + in the same scope as the loop (#175) * B049: New check for ``@abstractmethod`` listed before ``@property``, ``@classmethod`` or ``@staticmethod`` (or a property ``.setter``/``.getter``/ ``.deleter``). It must be the innermost decorator, otherwise the class definition diff --git a/bugbear.py b/bugbear.py index cb927a7..5c6efa0 100644 --- a/bugbear.py +++ b/bugbear.py @@ -7,6 +7,7 @@ import logging import math import re +import symtable import sys import tokenize import warnings @@ -448,6 +449,16 @@ def names_from_assignments(assign_target: ast.expr) -> Iterator[str]: yield from names_from_assignments(child) +def _nested_tables(table: Any, deep: bool) -> Iterator[Any]: + """Tables of the functions nested in `table`, looking through class bodies.""" + for child in table.get_children() if table else (): + is_class = isinstance(child, symtable.Class) + if not is_class: + yield child + if deep or is_class: + yield from _nested_tables(child, deep) + + def children_in_scope(node: ast.AST) -> Iterator[ast.AST]: yield node if not isinstance(node, FUNCTION_NODES): @@ -753,6 +764,8 @@ class BugBearVisitor(ast.NodeVisitor): NODE_WINDOW_SIZE = 4 _b023_seen: set[ast.Name] = attr.ib(factory=set, init=False) + _closure_reads_cache: dict[int, set[str]] = attr.ib(factory=dict, init=False) + _symtable_root: Any = attr.ib(default=False, init=False) _b023_scopes: dict[int, tuple] = attr.ib(factory=dict, init=False) _b005_imports: set[str] = attr.ib(factory=set, init=False) # Module-scope libcst bindings from `_b906_scope_bindings` (name -> @@ -1398,11 +1411,52 @@ def check_for_b007(self, node: ast.For) -> None: body = NameFinder() for expr in node.body: body.visit(expr) - used_names = set(body.names) - for name in sorted(ctrl_names - used_names): + unused_names = ctrl_names - set(body.names) + scope = self.contexts[-1].node + # Names bound in a class body are not visible to the functions nested in it. + if unused_names and not isinstance(scope, ast.ClassDef): + unused_names -= self._closure_reads(scope) + for name in sorted(unused_names): n = targets.names[name][0] self.add_error("B007", n, name) + def _closure_reads(self, scope: Any) -> set[str]: + """Names read by functions nested in `scope`, as resolved by CPython: their + free variables, or for a module the globals they read.""" + if id(scope) not in self._closure_reads_cache: + if self._symtable_root is False: # once per file + try: + source = "".join(self.lines) + self._symtable_root = symtable.symtable( + source, self.filename, "exec" + ) + except Exception: # SyntaxError, ValueError, ...: no exemption + self._symtable_root = None + table, module = self._symtable_root, isinstance(scope, ast.Module) + if not module: + key = (scope.name, scope.lineno) + table = next( + ( + t + for t in _nested_tables(table, deep=True) + if isinstance(t, symtable.Function) + and (t.get_name(), t.get_lineno()) == key + ), + None, + ) + reads: set[str] = set() + for t in _nested_tables(table, deep=module): + if module: + reads.update( + s.get_name() + for s in t.get_symbols() + if s.is_global() and s.is_referenced() + ) + else: + reads.update(t.get_frees()) + self._closure_reads_cache[id(scope)] = reads + return self._closure_reads_cache[id(scope)] + def check_for_b011(self, node: ast.Assert) -> None: if isinstance(node.test, ast.Constant) and node.test.value is False: self.add_error("B011", node) diff --git a/tests/eval_files/b007.py b/tests/eval_files/b007.py index 7c08b18..8d413f5 100644 --- a/tests/eval_files/b007.py +++ b/tests/eval_files/b007.py @@ -1,3 +1,5 @@ +import functools + for i in range(10): print(i) @@ -29,3 +31,261 @@ def strange_generator(): for i, (j, (k, l)) in strange_generator(): # i, k not used # B007: 4, "i" # B007: 12, "k" print(j, l) + + +# A loop variable read by a function defined in the same scope is used (#175). +def closure_defined_before_loop(): + def f(x): + print(i, x) + + for i in [1, 2, 3]: + f("HI") + + +def closure_defined_after_loop(): + for i in [1, 2, 3]: + pass + + def f(): + return i + + return f + + +def lambda_reads_loop_variable(): + for i in range(3): + pass + return lambda: i + + +def closure_in_other_block(): + if True: + + def f(): + print(i) + + for i in range(3): + f() + + +def nested_closure_reads_through_enclosing_function(): + def outer(): + def inner(): + print(i) + + return inner + + for i in range(3): + outer() + + +def closure_reads_in_comprehension_iterable(): + def f(): + return [x for x in i] + + for i in range(3): + f() + + +def method_reads_loop_variable(): + class C: + def method(self): + return i + + for i in range(3): + C() + + +def nonlocal_read(): + def f(): + nonlocal i + i += 1 + + for i in range(3): + f() + + +def closure_that_assigns_its_own_variable(): + def f(): + i = 1 + return i + + for i in range(3): # B007: 8, "i" + f() + + +def closure_with_parameter_of_the_same_name(i): + def f(i): + return i + + for i in range(3): # B007: 8, "i" + f(1) + + +def closure_with_lambda_parameter_of_the_same_name(): + f = lambda i: i + + for i in range(3): # B007: 8, "i" + f(1) + + +def closure_with_local_assigned_after_read(): + def f(): + print(i) + i = 1 + + for i in range(3): # B007: 8, "i" + f() + + +def closure_with_comprehension_variable_of_the_same_name(): + def f(): + return [i for i in range(3)] + + for i in range(3): # B007: 8, "i" + f() + + +def closure_with_loop_of_the_same_name(): + def f(): + for i in range(3): + print(i) + + for i in range(3): # B007: 8, "i" + f() + + +def closure_with_import_of_the_same_name(): + def f(): + import i + + return i + + for i in range(3): # B007: 8, "i" + f() + + +def closure_with_global_declaration(): + # `g` in `f` is the module-level variable, not the loop variable + def f(): + global g + return g + + for g in range(3): # B007: 8, "g" + f() + + +def class_scope_variable_is_not_the_loop_variable(): + class C: + i = 1 + j = i + + for i in range(3): # B007: 8, "i" + C() + + +def function_in_other_scope_does_not_count(): + def other(): + return n + + def loops(): + for n in range(3): # B007: 12, "n" + pass + + return other, loops + + +def one_of_two_variables_is_read_by_the_closure(): + def f(): + return j + + for i, j in enumerate("ab"): # B007: 8, "i" + f() + + +async def async_closure_reads_loop_variable(): + async def f(): + return i + + for i in range(3): + await f() + + +def class_body_in_closure_reads_loop_variable(): + def f(): + class C: + x = i + + return C + + for i in range(3): + f() + + +def closure_that_binds_the_name_in_other_ways(): + def with_as(): + with open("f") as i: + return i + + def except_as(): + try: + pass + except ValueError as i: + return i + + def walrus(): + if (i := 1): + return i + + def def_name(): + def i(): + pass + + return i + + for i in range(3): # B007: 8, "i" + with_as() + except_as() + walrus() + def_name() + + +def closure_reads_in_nested_definitions(): + def f(): + @decorator(i) + def g(x=i, *, y=i): + pass + + class C(Base, metaclass=i): + pass + + return {k: v for k, v in i if v}, {*i} + + for i in range(3): + f() + + +def closure_reads_in_dict_comprehension_only(): + def f(): + return {k: i for k in "ab"} + + for i in range(3): + f() + + +def closure_shadows_in_dict_comprehension(): + def f(): + return {i: i for i in "ab"} + + for i in range(3): # B007: 8, "i" + f() + + +@staticmethod +@functools.cache +def decorated_scope_with_closure(): + def f(): + return i + + for i in range(3): + f() diff --git a/tests/eval_files/b007_module_scope.py b/tests/eval_files/b007_module_scope.py new file mode 100644 index 0000000..06d307c --- /dev/null +++ b/tests/eval_files/b007_module_scope.py @@ -0,0 +1,53 @@ +# A module-level loop variable read by a function is a global used by that function +# (#175). The function may be defined before or after the loop. +def reads_i(): + return i + + +for i in range(10): + pass + + +for j in range(10): + pass + + +def reads_j(): + return j + + +for k in range(10): # B007: 4, "k" + pass + + +def shadows_k(k): + return k + + +def assigns_k(): + k = 1 + return k + + +def declares_global_k(): + global k + k = 2 # a store is not a use + + +def reads_global_m(): + return m + + +def other_scope(): + # `m` is local to this function, the function above reads another variable + for m in range(10): # B007: 8, "m" + pass + + +for n in range(10): + pass + + +def reads_n_with_global(): + global n + return n diff --git a/tests/eval_files/b007_py310.py b/tests/eval_files/b007_py310.py new file mode 100644 index 0000000..00ab219 --- /dev/null +++ b/tests/eval_files/b007_py310.py @@ -0,0 +1,25 @@ +def closure_reads_loop_variable_in_match(): + def f(x): + match x: + case [1, i]: + return i + case {"k": rest, **i}: + return rest + return 0 + + def g(): + return i + + for i in range(3): + g() + + +def closure_binds_the_name_in_a_pattern(): + def f(x): + match x: + case [1, i]: + return i + return 0 + + for i in range(3): # B007: 8, "i" + f(1) From 9a73c9a25d51d0a7de2ea5932eea84e7f33a14ef Mon Sep 17 00:00:00 2001 From: DG-Elom Date: Wed, 14 Oct 2026 23:43:46 +0200 Subject: [PATCH 2/3] B007: only read function symbol tables (annotation scopes on 3.14+) --- bugbear.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/bugbear.py b/bugbear.py index 5c6efa0..a0aa19a 100644 --- a/bugbear.py +++ b/bugbear.py @@ -450,12 +450,13 @@ def names_from_assignments(assign_target: ast.expr) -> Iterator[str]: def _nested_tables(table: Any, deep: bool) -> Iterator[Any]: - """Tables of the functions nested in `table`, looking through class bodies.""" + """Tables of the functions nested in `table`, looking through class bodies and + the annotation and type parameter scopes of Python 3.12+.""" for child in table.get_children() if table else (): - is_class = isinstance(child, symtable.Class) - if not is_class: + is_function = isinstance(child, symtable.Function) + if is_function: yield child - if deep or is_class: + if deep or not is_function: yield from _nested_tables(child, deep) From 633c973730d6d129823daab14a26fbb502a817c5 Mon Sep 17 00:00:00 2001 From: cooperlees Date: Thu, 22 Oct 2026 03:28:30 +0000 Subject: [PATCH 3/3] B007: also exempt loop variables that are global in the enclosing scope A 'global' declaration makes the loop target global too, in which case nested readers have no free variable. Include referenced globals when the enclosing symtable marks the name global. --- bugbear.py | 25 +++++++++++++++++++-- tests/eval_files/b007.py | 48 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 2 deletions(-) diff --git a/bugbear.py b/bugbear.py index a0aa19a..df94b3b 100644 --- a/bugbear.py +++ b/bugbear.py @@ -1422,8 +1422,9 @@ def check_for_b007(self, node: ast.For) -> None: self.add_error("B007", n, name) def _closure_reads(self, scope: Any) -> set[str]: - """Names read by functions nested in `scope`, as resolved by CPython: their - free variables, or for a module the globals they read.""" + """Names read by functions nested in `scope`, as resolved by CPython: + their free variables, plus (for a function or module) the globals + they read when the name is global in the enclosing table.""" if id(scope) not in self._closure_reads_cache: if self._symtable_root is False: # once per file try: @@ -1434,6 +1435,7 @@ def _closure_reads(self, scope: Any) -> set[str]: except Exception: # SyntaxError, ValueError, ...: no exemption self._symtable_root = None table, module = self._symtable_root, isinstance(scope, ast.Module) + scope_globals: set[str] = set() if not module: key = (scope.name, scope.lineno) table = next( @@ -1445,6 +1447,12 @@ def _closure_reads(self, scope: Any) -> set[str]: ), None, ) + if table is not None: + # A `global` declaration makes the loop target global too, in + # which case nested readers have no free variable. + scope_globals = { + s.get_name() for s in table.get_symbols() if s.is_global() + } reads: set[str] = set() for t in _nested_tables(table, deep=module): if module: @@ -1455,6 +1463,19 @@ def _closure_reads(self, scope: Any) -> set[str]: ) else: reads.update(t.get_frees()) + if scope_globals: + # Referenced globals don't propagate through intermediate + # functions, so look at every function nested in the scope. A + # name bound locally in between is not global in the table that + # reads it, so shadowing still counts as unused. + for t in _nested_tables(table, deep=True): + reads.update( + s.get_name() + for s in t.get_symbols() + if s.get_name() in scope_globals + and s.is_global() + and s.is_referenced() + ) self._closure_reads_cache[id(scope)] = reads return self._closure_reads_cache[id(scope)] diff --git a/tests/eval_files/b007.py b/tests/eval_files/b007.py index 8d413f5..e6dc7ef 100644 --- a/tests/eval_files/b007.py +++ b/tests/eval_files/b007.py @@ -289,3 +289,51 @@ def f(): for i in range(3): f() + + +def global_loop_variable_read_by_closure(): + global gi + + def f(): + return gi + + for gi in range(3): + f() + + +def global_loop_variable_read_by_deeply_nested_closure(): + global gh + + def g(): + def h(): + return gh + + return h + + for gh in range(3): + g() + + +def global_loop_variable_shadowed_by_intermediate_local(): + global go + + def mid(): + go = 5 + + def inner(): + return go + + return inner + + for go in range(3): # B007: 8, "go" + mid() + + +def global_loop_variable_shadowed_by_comprehension(): + global gc + + def f(): + return [gc for gc in range(3)] + + for gc in range(3): # B007: 8, "gc" + f()