2626 ast .DictComp ,
2727 ast .GeneratorExp ,
2828)
29+ FUNCTION_NODES = (ast .AsyncFunctionDef , ast .FunctionDef , ast .Lambda )
2930
3031Context = namedtuple ("Context" , ["node" , "stack" ])
3132
@@ -198,6 +199,23 @@ def _to_name_str(node):
198199 return _to_name_str (node .value )
199200
200201
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+
201219def _typesafe_issubclass (cls , class_or_tuple ):
202220 try :
203221 return issubclass (cls , class_or_tuple )
@@ -220,6 +238,7 @@ class BugBearVisitor(ast.NodeVisitor):
220238 contexts = attr .ib (default = attr .Factory (list ))
221239
222240 NODE_WINDOW_SIZE = 4
241+ _b023_seen = attr .ib (factory = set , init = False )
223242
224243 if False :
225244 # Useful for tracing what the hell is going on.
@@ -348,6 +367,31 @@ def visit_Assign(self, node):
348367 def visit_For (self , node ):
349368 self .check_for_b007 (node )
350369 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 )
351395 self .generic_visit (node )
352396
353397 def visit_Assert (self , node ):
@@ -520,6 +564,59 @@ def check_for_b020(self, node):
520564 n = targets .names [name ][0 ]
521565 self .errors .append (B020 (n .lineno , n .col_offset , vars = (name ,)))
522566
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+
523620 def check_for_b904 (self , node ):
524621 """Checks `raise` without `from` inside an `except` clause.
525622
@@ -1041,6 +1138,8 @@ def visit_Lambda(self, node):
10411138 )
10421139)
10431140
1141+ B023 = Error (message = "B023 Function definition does not bind loop variable {!r}." )
1142+
10441143# Warnings disabled by default.
10451144B901 = Error (
10461145 message = (
0 commit comments