Skip to content

Commit 81f82a1

Browse files
Eljeescooperlees
andauthored
B023: don't flag a function that is only called inside the loop (#567)
* B023: don't flag a function that is only called inside the loop check_for_b023 warns whenever a function defined in a loop closes over the loop variable, but a function whose every reference is a direct call in the loop body cannot outlive the iteration it was defined in, so the value it closes over is the one the author meant. The existing safe_functions notion covered only a fixed set of shapes - filter/map/reduce, a key= argument, and 'return lambda: x'. It is replaced by the rule the maintainers described on the two issues: warn when the name escapes the loop or is referenced as anything other than a direct call, and stay silent otherwise. Decorated definitions keep warning, since a decorator can store the function. Fixes #468 Fixes #380 * Tie the B023 exemption to the definition, not to the identifier Addresses the five review points. The exemption is now granted only when every reference to the name, in the scope that holds the loop, is a call that runs in the same iteration as the definition: * only a plain `def` qualifies. Calling an `async def` builds a coroutine and calling a generator function builds a generator, so the body -- and the read of the loop variable -- is deferred. * a name that is bound anywhere else in the scope is skipped, so a reference to an unrelated binding of the same identifier can no longer decide the outcome. * the search covers the loop body only. The `else` suite runs after the loop, and a call placed above the `def` invokes the binding the previous iteration left behind. * a reference reached through a nested function, lambda or generator expression disqualifies: that body decides when the call happens. * a definition nothing refers to is reported, because its name is still bound after the loop. * the parent links, name uses and call targets of a scope are built once and cached, instead of two walks of the scope per loop. --------- Co-authored-by: Cooper Lees <me@cooperlees.com>
1 parent 4b502c5 commit 81f82a1

3 files changed

Lines changed: 276 additions & 1 deletion

File tree

README.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -501,6 +501,8 @@ UNRELEASED
501501
~~~~~~~~~~
502502

503503
* B019: also flag `async_lru.alru_cache` and check cache decorators on `async def` methods (#488)
504+
* B023: don't flag a function whose every reference is a direct call inside the loop body:
505+
such a function cannot outlive the iteration it was defined in (#468, #380)
504506
* B020: don't flag `for self.a in self.b`: rebinding an attribute is not rebinding the
505507
base name, so two different attributes of the same object are two bindings (#248)
506508
* B018: handle also useless calls such as `isinstance(x, int)` without assigning or using the result

bugbear.py

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,29 @@ class Context(NamedTuple):
7676
stack: list[ast.AST]
7777

7878

79+
def _defines_a_generator(function_node: ast.FunctionDef) -> bool:
80+
"""Does this function's own body contain a yield? (nested ones do not count)"""
81+
stack = list(function_node.body)
82+
while stack:
83+
node = stack.pop()
84+
if isinstance(node, (ast.Yield, ast.YieldFrom)):
85+
return True
86+
if isinstance(node, FUNCTION_NODES):
87+
continue
88+
stack.extend(ast.iter_child_nodes(node))
89+
return False
90+
91+
92+
def _is_rebound(
93+
name: str, names_used: dict[str, list[ast.Name]], parents: dict[int, ast.AST]
94+
) -> bool:
95+
"""Is `name` bound anywhere in this scope other than by the definition?"""
96+
return any(
97+
isinstance(reference.ctx, (ast.Store, ast.Del))
98+
for reference in names_used.get(name, ())
99+
)
100+
101+
79102
@attr.s(unsafe_hash=False)
80103
class BugBearChecker:
81104
name = "flake8-bugbear"
@@ -428,6 +451,7 @@ class BugBearVisitor(ast.NodeVisitor):
428451

429452
NODE_WINDOW_SIZE = 4
430453
_b023_seen: set[ast.Name] = attr.ib(factory=set, init=False)
454+
_b023_scopes: dict[int, tuple] = attr.ib(factory=dict, init=False)
431455
_b005_imports: set[str] = attr.ib(factory=set, init=False)
432456

433457
# set to "*" when inside a try/except*, for correctly printing errors
@@ -1025,6 +1049,7 @@ def check_for_b023( # noqa: C901
10251049
# implement this "backwards": first we find all the candidate variable
10261050
# uses, and then if there are any we check for assignment of those names
10271051
# inside the loop body.
1052+
immediately_called = self._immediately_called_functions(loop_node)
10281053
safe_functions = []
10291054
suspicious_variables = []
10301055
for node in ast.walk(loop_node):
@@ -1058,6 +1083,15 @@ def check_for_b023( # noqa: C901
10581083
if isinstance(node.value, FUNCTION_NODES):
10591084
safe_functions.append(node.value)
10601085

1086+
# a function that is only ever *called* in the loop body cannot
1087+
# outlive the iteration its free variables were assigned in
1088+
if (
1089+
isinstance(node, ast.FunctionDef)
1090+
and not node.decorator_list
1091+
and node.name in immediately_called
1092+
):
1093+
safe_functions.append(node)
1094+
10611095
# find unsafe functions
10621096
if isinstance(node, FUNCTION_NODES) and node not in safe_functions:
10631097
argnames = {
@@ -1101,6 +1135,142 @@ def check_for_b023( # noqa: C901
11011135
if err.id in reassigned_in_loop:
11021136
self.add_error("B023", err, err.id)
11031137

1138+
def _immediately_called_functions(
1139+
self,
1140+
loop_node: (
1141+
ast.For
1142+
| ast.AsyncFor
1143+
| ast.While
1144+
| ast.GeneratorExp
1145+
| ast.SetComp
1146+
| ast.ListComp
1147+
| ast.DictComp
1148+
),
1149+
) -> set[str]:
1150+
"""Names of functions defined in the loop that cannot outlive an iteration.
1151+
1152+
A function defined in a loop is only subject to the late-binding gotcha
1153+
B023 warns about if a reference to it survives the iteration it was
1154+
created in. So a name is reported here -- and thus exempted -- only when
1155+
every reference to it, anywhere in the enclosing scope, is a direct call
1156+
that runs in the same iteration as the definition. Being appended to a
1157+
list, returned, passed as an argument, or called from a nested function
1158+
all let the function escape, and keep the warning.
1159+
"""
1160+
body = getattr(loop_node, "body", None)
1161+
if not isinstance(body, list):
1162+
return set()
1163+
1164+
# Only a plain `def` runs to completion when it is called. Calling an
1165+
# `async def` builds a coroutine and calling a generator function builds
1166+
# a generator: both defer the body, so the free variables are read after
1167+
# the loop has moved on. A decorator may stash the original as well.
1168+
candidates: dict[str, list[ast.FunctionDef]] = {}
1169+
for statement in body:
1170+
if (
1171+
isinstance(statement, ast.FunctionDef)
1172+
and not statement.decorator_list
1173+
and not _defines_a_generator(statement)
1174+
):
1175+
candidates.setdefault(statement.name, []).append(statement)
1176+
if not candidates:
1177+
return set()
1178+
1179+
parents, names_used, called_names = self._scope_reference_index(loop_node)
1180+
1181+
# the `else` suite of a loop runs once the loop is over, so a call there
1182+
# cannot be the call that keeps the function inside its iteration
1183+
in_body = set()
1184+
for statement in body:
1185+
for node in ast.walk(statement):
1186+
in_body.add(id(node))
1187+
1188+
safe: set[str] = set()
1189+
for name, definitions in candidates.items():
1190+
# matching a reference by its identifier alone is only sound while
1191+
# the name has exactly one binding in this scope
1192+
if len(definitions) != 1 or _is_rebound(name, names_used, parents):
1193+
continue
1194+
definition = definitions[0]
1195+
# a definition nothing refers to still leaves its name bound after
1196+
# the loop, so it can be called later with the last iteration's
1197+
# values -- exactly the bug, so it stays reported
1198+
references = names_used.get(name, ())
1199+
if references and all(
1200+
self._is_call_within_the_iteration(
1201+
reference, definition, loop_node, parents, called_names, in_body
1202+
)
1203+
for reference in references
1204+
):
1205+
safe.add(name)
1206+
return safe
1207+
1208+
def _scope_reference_index(
1209+
self, loop_node: ast.AST
1210+
) -> tuple[dict[int, ast.AST], dict[str, list[ast.Name]], set[int]]:
1211+
"""Parent links, name uses and call targets for the scope of `loop_node`.
1212+
1213+
Built once per scope and cached: walking the scope again for every loop
1214+
it contains turns a linear traversal into a quadratic one on files with
1215+
many sequential loops.
1216+
"""
1217+
scope: ast.AST = loop_node
1218+
for ancestor in reversed(self.node_stack):
1219+
if isinstance(ancestor, (ast.Module, ast.ClassDef, *FUNCTION_NODES)):
1220+
scope = ancestor
1221+
break
1222+
1223+
cached = self._b023_scopes.get(id(scope))
1224+
if cached is not None:
1225+
return cached
1226+
1227+
parents: dict[int, ast.AST] = {}
1228+
names_used: dict[str, list[ast.Name]] = {}
1229+
called_names: set[int] = set()
1230+
for parent in ast.walk(scope):
1231+
if isinstance(parent, ast.Call) and isinstance(parent.func, ast.Name):
1232+
called_names.add(id(parent.func))
1233+
for child in ast.iter_child_nodes(parent):
1234+
parents[id(child)] = parent
1235+
if isinstance(parent, ast.Name):
1236+
names_used.setdefault(parent.id, []).append(parent)
1237+
1238+
index = (parents, names_used, called_names)
1239+
self._b023_scopes[id(scope)] = index
1240+
return index
1241+
1242+
def _is_call_within_the_iteration(
1243+
self,
1244+
reference: ast.Name,
1245+
definition: ast.FunctionDef,
1246+
loop_node: ast.AST,
1247+
parents: dict[int, ast.AST],
1248+
called_names: set[int],
1249+
in_body: set[int],
1250+
) -> bool:
1251+
"""Is this reference a call that runs in the iteration that defined it?"""
1252+
if id(reference) not in in_body:
1253+
return False
1254+
if not isinstance(reference.ctx, ast.Load):
1255+
return False
1256+
if id(reference) not in called_names:
1257+
return False
1258+
# a call placed above the `def` invokes the binding the previous
1259+
# iteration left behind, which is the very bug B023 is about
1260+
if (reference.lineno, reference.col_offset) < (
1261+
definition.lineno,
1262+
definition.col_offset,
1263+
):
1264+
return False
1265+
# a call reached through another deferred body happens whenever that
1266+
# body is run, which may be long after the loop has finished
1267+
node: ast.AST | None = parents.get(id(reference))
1268+
while node is not None and node is not loop_node:
1269+
if isinstance(node, (*FUNCTION_NODES, ast.GeneratorExp)):
1270+
return False
1271+
node = parents.get(id(node))
1272+
return True
1273+
11041274
def check_for_b024_and_b027(self, node: ast.ClassDef) -> None: # noqa: C901
11051275
"""Check for inheritance from abstract classes in abc and lack of
11061276
any methods decorated with abstract*"""

tests/eval_files/b023.py

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,4 +169,107 @@ def iter_f(names):
169169
return [lambda: name] # known false alarm # B023: 28, "name"
170170

171171
if False:
172-
return [lambda: i for i in range(3)] # error # B023: 28, "i"
172+
return [lambda: i for i in range(3)] # error # B023: 28, "i"
173+
174+
# OK because the function is only ever *called* inside the loop body, so it can
175+
# never outlive the iteration in which its free variables were assigned.
176+
# https://github.com/PyCQA/flake8-bugbear/issues/468
177+
for _ in range(10):
178+
foo = []
179+
180+
def immediately_called():
181+
foo.append(42)
182+
183+
immediately_called()
184+
185+
186+
# still an error: the function escapes the iteration even though it is also called
187+
for _ in range(10):
188+
bar = []
189+
190+
def called_and_escapes():
191+
bar.append(42) # B023: 8, "bar"
192+
193+
called_and_escapes()
194+
functions.append(called_and_escapes)
195+
196+
197+
# still an error: a decorator can stash the original function somewhere
198+
for _ in range(10):
199+
baz = []
200+
201+
@some_decorator
202+
def decorated():
203+
baz.append(42) # B023: 8, "baz"
204+
205+
decorated()
206+
207+
208+
# still an error: the call happens whenever the *outer* function runs, which may
209+
# be long after the loop finished
210+
for _ in range(10):
211+
qux = []
212+
213+
def target():
214+
qux.append(42) # B023: 8, "qux"
215+
216+
# (`target` itself is not reported: `_get_assigned_names` does not treat a
217+
# `def` as an assignment -- pre-existing behaviour, unrelated to this fix)
218+
def wrapper():
219+
target()
220+
221+
functions.append(wrapper)
222+
223+
224+
# still an error: the function is also called after the loop, so the binding it
225+
# closes over is whatever the last iteration left behind
226+
for _ in range(10):
227+
quux = []
228+
229+
def called_after_the_loop():
230+
quux.append(42) # B023: 8, "quux"
231+
232+
called_after_the_loop()
233+
234+
called_after_the_loop()
235+
236+
# still an error: calling an `async def` only builds a coroutine, so the body --
237+
# and with it the read of the loop variable -- runs whenever it is awaited
238+
for _ in range(10):
239+
corge = []
240+
241+
async def awaited_later():
242+
corge.append(42) # B023: 8, "corge"
243+
244+
awaited_later()
245+
246+
247+
# still an error: calling a generator function only builds a generator
248+
for _ in range(10):
249+
grault = []
250+
251+
def iterated_later():
252+
yield grault # B023: 14, "grault"
253+
254+
iterated_later()
255+
256+
257+
# still an error: a call above the `def` invokes the binding the previous
258+
# iteration left behind
259+
for _ in range(10):
260+
waldo = []
261+
262+
called_above_the_def()
263+
264+
def called_above_the_def():
265+
waldo.append(42) # B023: 8, "waldo"
266+
267+
268+
# still an error: the `else` suite runs once the loop is over
269+
for _ in range(10):
270+
garply = []
271+
272+
def called_in_the_else_suite():
273+
garply.append(42) # B023: 8, "garply"
274+
else:
275+
called_in_the_else_suite()

0 commit comments

Comments
 (0)