26
26
ast .DictComp ,
27
27
ast .GeneratorExp ,
28
28
)
29
+ FUNCTION_NODES = (ast .AsyncFunctionDef , ast .FunctionDef , ast .Lambda )
29
30
30
31
Context = namedtuple ("Context" , ["node" , "stack" ])
31
32
@@ -198,6 +199,23 @@ def _to_name_str(node):
198
199
return _to_name_str (node .value )
199
200
200
201
202
+ def names_from_assignments (assign_target ):
203
+ if isinstance (assign_target , ast .Name ):
204
+ yield assign_target .id
205
+ elif isinstance (assign_target , ast .Starred ):
206
+ yield from names_from_assignments (assign_target .value )
207
+ elif isinstance (assign_target , (ast .List , ast .Tuple )):
208
+ for child in assign_target .elts :
209
+ yield from names_from_assignments (child )
210
+
211
+
212
+ def children_in_scope (node ):
213
+ yield node
214
+ if not isinstance (node , FUNCTION_NODES ):
215
+ for child in ast .iter_child_nodes (node ):
216
+ yield from children_in_scope (child )
217
+
218
+
201
219
def _typesafe_issubclass (cls , class_or_tuple ):
202
220
try :
203
221
return issubclass (cls , class_or_tuple )
@@ -220,6 +238,7 @@ class BugBearVisitor(ast.NodeVisitor):
220
238
contexts = attr .ib (default = attr .Factory (list ))
221
239
222
240
NODE_WINDOW_SIZE = 4
241
+ _b023_seen = attr .ib (factory = set , init = False )
223
242
224
243
if False :
225
244
# Useful for tracing what the hell is going on.
@@ -348,6 +367,31 @@ def visit_Assign(self, node):
348
367
def visit_For (self , node ):
349
368
self .check_for_b007 (node )
350
369
self .check_for_b020 (node )
370
+ self .check_for_b023 (node )
371
+ self .generic_visit (node )
372
+
373
+ def visit_AsyncFor (self , node ):
374
+ self .check_for_b023 (node )
375
+ self .generic_visit (node )
376
+
377
+ def visit_While (self , node ):
378
+ self .check_for_b023 (node )
379
+ self .generic_visit (node )
380
+
381
+ def visit_ListComp (self , node ):
382
+ self .check_for_b023 (node )
383
+ self .generic_visit (node )
384
+
385
+ def visit_SetComp (self , node ):
386
+ self .check_for_b023 (node )
387
+ self .generic_visit (node )
388
+
389
+ def visit_DictComp (self , node ):
390
+ self .check_for_b023 (node )
391
+ self .generic_visit (node )
392
+
393
+ def visit_GeneratorExp (self , node ):
394
+ self .check_for_b023 (node )
351
395
self .generic_visit (node )
352
396
353
397
def visit_Assert (self , node ):
@@ -520,6 +564,59 @@ def check_for_b020(self, node):
520
564
n = targets .names [name ][0 ]
521
565
self .errors .append (B020 (n .lineno , n .col_offset , vars = (name ,)))
522
566
567
+ def check_for_b023 (self , loop_node ):
568
+ """Check that functions (including lambdas) do not use loop variables.
569
+
570
+ https://docs.python-guide.org/writing/gotchas/#late-binding-closures from
571
+ functions - usually but not always lambdas - defined inside a loop are a
572
+ classic source of bugs.
573
+
574
+ For each use of a variable inside a function defined inside a loop, we
575
+ emit a warning if that variable is reassigned on each loop iteration
576
+ (outside the function). This includes but is not limited to explicit
577
+ loop variables like the `x` in `for x in range(3):`.
578
+ """
579
+ # Because most loops don't contain functions, it's most efficient to
580
+ # implement this "backwards": first we find all the candidate variable
581
+ # uses, and then if there are any we check for assignment of those names
582
+ # inside the loop body.
583
+ suspicious_variables = []
584
+ for node in ast .walk (loop_node ):
585
+ if isinstance (node , FUNCTION_NODES ):
586
+ argnames = {
587
+ arg .arg for arg in ast .walk (node .args ) if isinstance (arg , ast .arg )
588
+ }
589
+ if isinstance (node , ast .Lambda ):
590
+ body_nodes = ast .walk (node .body )
591
+ else :
592
+ body_nodes = itertools .chain .from_iterable (map (ast .walk , node .body ))
593
+ for name in body_nodes :
594
+ if (
595
+ isinstance (name , ast .Name )
596
+ and name .id not in argnames
597
+ and isinstance (name .ctx , ast .Load )
598
+ ):
599
+ err = B023 (name .lineno , name .col_offset , vars = (name .id ,))
600
+ if err not in self ._b023_seen :
601
+ self ._b023_seen .add (err ) # dedupe across nested loops
602
+ suspicious_variables .append (err )
603
+
604
+ if suspicious_variables :
605
+ reassigned_in_loop = set (self ._get_assigned_names (loop_node ))
606
+
607
+ for err in sorted (suspicious_variables ):
608
+ if reassigned_in_loop .issuperset (err .vars ):
609
+ self .errors .append (err )
610
+
611
+ def _get_assigned_names (self , loop_node ):
612
+ loop_targets = (ast .For , ast .AsyncFor , ast .comprehension )
613
+ for node in children_in_scope (loop_node ):
614
+ if isinstance (node , (ast .Assign )):
615
+ for child in node .targets :
616
+ yield from names_from_assignments (child )
617
+ if isinstance (node , loop_targets + (ast .AnnAssign , ast .AugAssign )):
618
+ yield from names_from_assignments (node .target )
619
+
523
620
def check_for_b904 (self , node ):
524
621
"""Checks `raise` without `from` inside an `except` clause.
525
622
@@ -1041,6 +1138,8 @@ def visit_Lambda(self, node):
1041
1138
)
1042
1139
)
1043
1140
1141
+ B023 = Error (message = "B023 Function definition does not bind loop variable {!r}." )
1142
+
1044
1143
# Warnings disabled by default.
1045
1144
B901 = Error (
1046
1145
message = (
0 commit comments