code-towel 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,597 @@
1
+ # Copyright 2025 Eric Allen
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """
16
+ Analyze identifier bindings and scopes in Python code.
17
+ """
18
+
19
+ import ast
20
+ from typing import Callable, Dict, Set, List, Optional, Tuple, Iterable, Union
21
+ from dataclasses import dataclass, field
22
+ from .builtins import filter_builtins
23
+
24
+
25
+ @dataclass
26
+ class Binding:
27
+ """Represents a binding of an identifier to a value."""
28
+
29
+ name: str
30
+ scope_id: int # Unique identifier for the scope
31
+ node: ast.AST # The AST node that creates this binding
32
+
33
+
34
+ @dataclass
35
+ class Scope:
36
+ """Represents a lexical scope."""
37
+
38
+ scope_id: int
39
+ parent: Optional["Scope"]
40
+ bindings: Dict[str, Binding] = field(default_factory=dict)
41
+ children: List["Scope"] = field(default_factory=list)
42
+
43
+ def lookup(self, name: str) -> Optional[Binding]:
44
+ """Lookup a binding in this scope or parent scopes."""
45
+ if name in self.bindings:
46
+ return self.bindings[name]
47
+ if self.parent:
48
+ return self.parent.lookup(name)
49
+ return None
50
+
51
+ def add_binding(self, name: str, node: ast.AST) -> None:
52
+ """Add a binding to this scope."""
53
+ self.bindings[name] = Binding(name, self.scope_id, node)
54
+
55
+
56
+ def _iter_argument_nodes(args: ast.arguments) -> Iterable[ast.arg]:
57
+ """Yield every ast.arg (positional, keyword, var/kw) from an arguments block."""
58
+ for arg in args.posonlyargs:
59
+ yield arg
60
+ for arg in args.args:
61
+ yield arg
62
+ for arg in args.kwonlyargs:
63
+ yield arg
64
+ if args.vararg:
65
+ yield args.vararg
66
+ if args.kwarg:
67
+ yield args.kwarg
68
+
69
+
70
+ def _iter_argument_names(args: ast.arguments) -> Iterable[str]:
71
+ for arg in _iter_argument_nodes(args):
72
+ yield arg.arg
73
+
74
+
75
+ def _visit_node_and_iterables(
76
+ visitor: ast.NodeVisitor, first: ast.AST, rest: Iterable[ast.AST]
77
+ ) -> None:
78
+ visitor.visit(first)
79
+ for expr in rest:
80
+ visitor.visit(expr)
81
+
82
+
83
+ def _visit_comprehension_generators(
84
+ visitor: ast.NodeVisitor, generators: List[ast.comprehension]
85
+ ) -> None:
86
+ for gen in generators:
87
+ visitor.visit(gen)
88
+
89
+
90
+ class ScopeAnalyzer(ast.NodeVisitor):
91
+ """
92
+ Analyze scopes and identifier bindings in an AST.
93
+
94
+ This builds a scope tree and tracks which identifiers refer to which
95
+ bindings in the enclosing environment.
96
+ """
97
+
98
+ def __init__(self) -> None:
99
+ self.scope_counter = 0
100
+ self.current_scope: Optional[Scope] = None
101
+ self.root_scope: Optional[Scope] = None
102
+
103
+ # Map AST nodes to their scopes
104
+ self.node_scopes: Dict[ast.AST, Scope] = {}
105
+
106
+ # Map identifier uses to their bindings
107
+ self.identifier_bindings: Dict[ast.Name, Optional[Binding]] = {}
108
+
109
+ # Track global and nonlocal declarations per scope
110
+ # Maps scope_id -> set of variable names
111
+ self.global_vars: Dict[int, Set[str]] = {}
112
+ self.nonlocal_vars: Dict[int, Set[str]] = {}
113
+
114
+ # Cache for free-variable analysis (keyed by node identity tuple)
115
+ self._free_var_cache: Dict[Tuple[int, ...], Set[str]] = {}
116
+
117
+ def analyze(self, tree: ast.AST) -> Scope:
118
+ """Analyze an AST and return the root scope."""
119
+ self.root_scope = self._create_scope(None)
120
+ self.current_scope = self.root_scope
121
+ self.visit(tree)
122
+ return self.root_scope
123
+
124
+ def _create_scope(self, parent: Optional[Scope]) -> Scope:
125
+ """Create a new scope."""
126
+ scope = Scope(self.scope_counter, parent)
127
+ self.scope_counter += 1
128
+ if parent:
129
+ parent.children.append(scope)
130
+ return scope
131
+
132
+ def _enter_scope(self, node: ast.AST) -> Scope:
133
+ """Enter a new scope."""
134
+ new_scope = self._create_scope(self.current_scope)
135
+ self.node_scopes[node] = new_scope
136
+ self.current_scope = new_scope
137
+ return new_scope
138
+
139
+ def _exit_scope(self) -> None:
140
+ """Exit the current scope."""
141
+ if self.current_scope and self.current_scope.parent:
142
+ self.current_scope = self.current_scope.parent
143
+
144
+ def _enter_named_scope(
145
+ self, node: Union[ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef]
146
+ ) -> None:
147
+ assert self.current_scope is not None
148
+ self.current_scope.add_binding(node.name, node)
149
+ self._enter_scope(node)
150
+
151
+ def _visit_body_and_exit(self, body: List[ast.stmt]) -> None:
152
+ for stmt in body:
153
+ self.visit(stmt)
154
+ self._exit_scope()
155
+
156
+ def _bind_function_parameters(self, args: ast.arguments) -> None:
157
+ assert self.current_scope is not None
158
+ for arg in _iter_argument_nodes(args):
159
+ self.current_scope.add_binding(arg.arg, arg)
160
+
161
+ def _visit_loop(self, node: Union[ast.For, ast.AsyncFor]) -> None:
162
+ self.visit(node.iter)
163
+ self._add_assignment_bindings(node.target)
164
+ for stmt in node.body:
165
+ self.visit(stmt)
166
+ for stmt in node.orelse:
167
+ self.visit(stmt)
168
+
169
+ def _visit_with_statement(self, node: Union[ast.With, ast.AsyncWith]) -> None:
170
+ for item in node.items:
171
+ self.visit(item.context_expr)
172
+ if item.optional_vars:
173
+ self._add_assignment_bindings(item.optional_vars)
174
+ for stmt in node.body:
175
+ self.visit(stmt)
176
+
177
+ def _record_scope_declaration(self, table: Dict[int, Set[str]], names: List[str]) -> None:
178
+ if not self.current_scope:
179
+ return
180
+ scope_id = self.current_scope.scope_id
181
+ table.setdefault(scope_id, set()).update(names)
182
+
183
+ def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
184
+ """Visit a function definition."""
185
+ self._enter_named_scope(node)
186
+ self._bind_function_parameters(node.args)
187
+ self._visit_body_and_exit(node.body)
188
+
189
+ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
190
+ """Visit an async function definition."""
191
+ # Type ignore needed because mypy doesn't recognize structural compatibility
192
+ self.visit_FunctionDef(node) # type: ignore[arg-type]
193
+
194
+ def visit_ClassDef(self, node: ast.ClassDef) -> None:
195
+ """Visit a class definition."""
196
+ self._enter_named_scope(node)
197
+ self._visit_body_and_exit(node.body)
198
+
199
+ def visit_Assign(self, node: ast.Assign) -> None:
200
+ """Visit an assignment."""
201
+ # Visit RHS first
202
+ self.visit(node.value)
203
+
204
+ # Add bindings for LHS targets
205
+ for target in node.targets:
206
+ self._add_assignment_bindings(target)
207
+
208
+ def visit_AnnAssign(self, node: ast.AnnAssign) -> None:
209
+ """Visit an annotated assignment."""
210
+ if node.value:
211
+ self.visit(node.value)
212
+ self._add_assignment_bindings(node.target)
213
+
214
+ def visit_AugAssign(self, node: ast.AugAssign) -> None:
215
+ """Visit an augmented assignment."""
216
+ self.visit(node.value)
217
+ # Aug assigns don't create new bindings, they modify existing ones
218
+ self.visit(node.target)
219
+
220
+ def visit_For(self, node: ast.For) -> None:
221
+ """Visit a for loop."""
222
+ self._visit_loop(node)
223
+
224
+ def visit_AsyncFor(self, node: ast.AsyncFor) -> None:
225
+ """Visit an async for loop."""
226
+ self._visit_loop(node)
227
+
228
+ def visit_With(self, node: ast.With) -> None:
229
+ """Visit a with statement."""
230
+ self._visit_with_statement(node)
231
+
232
+ def visit_AsyncWith(self, node: ast.AsyncWith) -> None:
233
+ """Visit an async with statement."""
234
+ self._visit_with_statement(node)
235
+
236
+ def visit_NamedExpr(self, node: ast.NamedExpr) -> None:
237
+ """Visit a named expression (walrus operator :=)."""
238
+ # Visit RHS first
239
+ self.visit(node.value)
240
+ # Target creates binding (and it leaks to enclosing scope)
241
+ if isinstance(node.target, ast.Name):
242
+ assert self.current_scope is not None
243
+ self.current_scope.add_binding(node.target.id, node.target)
244
+
245
+ def visit_Global(self, node: ast.Global) -> None:
246
+ """Visit a global statement."""
247
+ self._record_scope_declaration(self.global_vars, node.names)
248
+
249
+ def visit_Nonlocal(self, node: ast.Nonlocal) -> None:
250
+ """Visit a nonlocal statement."""
251
+ self._record_scope_declaration(self.nonlocal_vars, node.names)
252
+
253
+ def visit_comprehension(self, node: ast.comprehension) -> None:
254
+ """Visit a comprehension."""
255
+ # Target creates bindings (in comprehension scope)
256
+ self._add_assignment_bindings(node.target)
257
+ _visit_node_and_iterables(self, node.iter, node.ifs)
258
+
259
+ def visit_ExceptHandler(self, node: ast.ExceptHandler) -> None:
260
+ if node.type:
261
+ self.visit(node.type)
262
+ if node.name:
263
+ assert self.current_scope is not None
264
+ self.current_scope.add_binding(node.name, node)
265
+ for stmt in node.body:
266
+ self.visit(stmt)
267
+
268
+ def visit_Name(self, node: ast.Name) -> None:
269
+ """Visit a name reference."""
270
+ # Record which binding this identifier refers to
271
+ assert self.current_scope is not None
272
+ binding = self.current_scope.lookup(node.id)
273
+ self.identifier_bindings[node] = binding
274
+
275
+ def _add_assignment_bindings(self, target: ast.AST) -> None:
276
+ """Add bindings created by an assignment target."""
277
+ if isinstance(target, ast.Name):
278
+ # Check if this variable is declared global or nonlocal
279
+ scope_id = self.current_scope.scope_id if self.current_scope else -1
280
+ is_global = scope_id in self.global_vars and target.id in self.global_vars[scope_id]
281
+ is_nonlocal = (
282
+ scope_id in self.nonlocal_vars and target.id in self.nonlocal_vars[scope_id]
283
+ )
284
+
285
+ # Only add as local binding if not global/nonlocal
286
+ if not is_global and not is_nonlocal:
287
+ assert self.current_scope is not None
288
+ self.current_scope.add_binding(target.id, target)
289
+ elif isinstance(target, (ast.Tuple, ast.List)):
290
+ for elt in target.elts:
291
+ self._add_assignment_bindings(elt)
292
+ # Other cases (subscript, attribute) don't create bindings
293
+
294
+ def get_free_variables(self, nodes: List[ast.AST]) -> Set[str]:
295
+ """
296
+ Get free variables in a block of code.
297
+
298
+ Free variables are identifiers that are referenced but not bound
299
+ within the block.
300
+
301
+ CRITICAL: Does NOT descend into nested function/class definitions,
302
+ since those have separate scopes.
303
+
304
+ Excludes Python builtins.
305
+ """
306
+
307
+ # Custom visitor that doesn't descend into nested functions
308
+ class ScopeRespectingWalker(ast.NodeVisitor):
309
+ def __init__(self) -> None:
310
+ self.uses: Set[str] = set()
311
+ self.bindings: Set[str] = set()
312
+ self.used_before_assigned: Set[str] = set() # Variables used before assignment
313
+ self.assigned_so_far: Set[str] = set() # Variables assigned so far in traversal
314
+ self.global_vars: Set[str] = set() # Variables declared global
315
+ self.nonlocal_vars: Set[str] = set() # Variables declared nonlocal
316
+
317
+ def _extract_binding_names(self, target: ast.AST) -> Set[str]:
318
+ """Extract variable names from an assignment target."""
319
+ names: Set[str] = set()
320
+ if isinstance(target, ast.Name):
321
+ names.add(target.id)
322
+ elif isinstance(target, (ast.Tuple, ast.List)):
323
+ for elt in ast.walk(target):
324
+ if isinstance(elt, ast.Name) and isinstance(elt.ctx, ast.Store):
325
+ names.add(elt.id)
326
+ return names
327
+
328
+ def _with_new_scope(
329
+ self, new_bindings: Set[str], body_visitor: Callable[[], None]
330
+ ) -> None:
331
+ """
332
+ Execute body_visitor with new bindings in a nested scope.
333
+ Bindings don't leak out, but uses of free variables are captured.
334
+ """
335
+ saved_bindings = self.bindings.copy()
336
+ saved_assigned = self.assigned_so_far.copy()
337
+
338
+ self.bindings.update(new_bindings)
339
+ self.assigned_so_far.update(new_bindings)
340
+
341
+ body_visitor()
342
+
343
+ # Remove ALL variables bound in this scope from uses
344
+ # This includes both new_bindings and any added during body_visitor
345
+ scope_bindings = self.bindings - saved_bindings
346
+ self.uses -= scope_bindings
347
+
348
+ # Restore bindings (they don't leak out of nested scope)
349
+ self.bindings = saved_bindings
350
+ self.assigned_so_far = saved_assigned
351
+
352
+ def _add_current_scope_bindings(self, new_bindings: Set[str]) -> None:
353
+ """Add bindings to the current scope (they persist)."""
354
+ self.bindings.update(new_bindings)
355
+ self.assigned_so_far.update(new_bindings)
356
+
357
+ def _bind_callable_parameters(self, args: ast.arguments) -> None:
358
+ """Reused parameter binding logic from the DRY run (docs/DRY_RUN_2025-11-28.md)."""
359
+ for name in _iter_argument_names(args):
360
+ self._add_current_scope_bindings({name})
361
+
362
+ def _visit_loop_with_bindings(self, node: Union[ast.For, ast.AsyncFor]) -> None:
363
+ """Shared traversal for sync/async loops (see docs/DRY_RUN_2025-11-28.md)."""
364
+ self.visit(node.iter)
365
+ loop_vars = self._extract_binding_names(node.target)
366
+ self._add_current_scope_bindings(loop_vars)
367
+ for stmt in node.body:
368
+ self.visit(stmt)
369
+ for stmt in node.orelse:
370
+ self.visit(stmt)
371
+
372
+ def _visit_with_like_block(self, node: Union[ast.With, ast.AsyncWith]) -> None:
373
+ """Centralizes binding + visitation for with/async-with blocks (DRY run helper)."""
374
+ for item in node.items:
375
+ self.visit(item.context_expr)
376
+ if item.optional_vars:
377
+ with_vars = self._extract_binding_names(item.optional_vars)
378
+ self._add_current_scope_bindings(with_vars)
379
+ for stmt in node.body:
380
+ self.visit(stmt)
381
+
382
+ def visit_Name(self, node: ast.Name) -> None:
383
+ if isinstance(node.ctx, ast.Load):
384
+ self.uses.add(node.id)
385
+ # If variable used before any assignment, mark it
386
+ if node.id not in self.assigned_so_far:
387
+ self.used_before_assigned.add(node.id)
388
+ elif isinstance(node.ctx, ast.Store):
389
+ # Check if this variable is global or nonlocal
390
+ if node.id in self.global_vars or node.id in self.nonlocal_vars:
391
+ # Global/nonlocal assignments are uses, not local bindings
392
+ self.uses.add(node.id)
393
+ else:
394
+ # Normal local binding
395
+ self.bindings.add(node.id)
396
+ self.assigned_so_far.add(node.id)
397
+ # Continue visiting (though Name has no children)
398
+ self.generic_visit(node)
399
+
400
+ def visit_Global(self, node: ast.Global) -> None:
401
+ """Track global declarations."""
402
+ self.global_vars.update(node.names)
403
+
404
+ def visit_Nonlocal(self, node: ast.Nonlocal) -> None:
405
+ """Track nonlocal declarations."""
406
+ self.nonlocal_vars.update(node.names)
407
+
408
+ def visit_Assign(self, node: ast.Assign) -> None:
409
+ # CRITICAL: Visit RHS before LHS to correctly track used-before-assigned
410
+ # In "x = y + 1", we must see the use of 'y' before marking 'x' as assigned
411
+ self.visit(node.value) # Visit RHS first
412
+ for target in node.targets:
413
+ self.visit(target) # Then visit LHS targets
414
+
415
+ def visit_AugAssign(self, node: ast.AugAssign) -> None:
416
+ # CRITICAL: Augmented assignments (x += 1) READ the variable
417
+ # They don't DEFINE it, so target should be in uses, NOT bindings
418
+ # (The variable must already exist for the augmented assignment to work)
419
+ if isinstance(node.target, ast.Name):
420
+ self.uses.add(node.target.id)
421
+ else:
422
+ # For subscripts (metrics["count"] += 1) or attributes (obj.x += 1),
423
+ # visit the target to capture the base variable
424
+ self.visit(node.target)
425
+ # Visit the RHS value
426
+ self.visit(node.value)
427
+
428
+ def visit_For(self, node: ast.For) -> None:
429
+ # Visit iterable first (before loop variable is bound)
430
+ self._visit_loop_with_bindings(node)
431
+
432
+ def visit_AsyncFor(self, node: ast.AsyncFor) -> None:
433
+ # Same as regular for loop
434
+ self._visit_loop_with_bindings(node)
435
+
436
+ def visit_With(self, node: ast.With) -> None:
437
+ # Visit context expressions first
438
+ self._visit_with_like_block(node)
439
+
440
+ def visit_AsyncWith(self, node: ast.AsyncWith) -> None:
441
+ # Same as regular with
442
+ self._visit_with_like_block(node)
443
+
444
+ def visit_NamedExpr(self, node: ast.NamedExpr) -> None:
445
+ # Walrus operator: Visit RHS first, then add binding
446
+ self.visit(node.value)
447
+ if isinstance(node.target, ast.Name):
448
+ # Walrus bindings leak into the current scope
449
+ self.bindings.add(node.target.id)
450
+ self.assigned_so_far.add(node.target.id)
451
+
452
+ def visit_comprehension(self, node: ast.comprehension) -> None:
453
+ # Comprehension variable binds within comprehension scope
454
+ # This is called from _visit_comprehension_node which handles scoping
455
+ comp_vars = self._extract_binding_names(node.target)
456
+ self._add_current_scope_bindings(comp_vars)
457
+ # Visit the rest (iter, ifs)
458
+ self.generic_visit(node)
459
+
460
+ def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
461
+ # Function name binds in outer scope
462
+ self._add_current_scope_bindings({node.name})
463
+
464
+ # Visit function body in a nested scope to capture free variables
465
+ # that the function closes over. This is important when the function
466
+ # definition itself is part of the extracted code.
467
+ def visit_func_body() -> None:
468
+ # Add function parameters as bindings in the nested scope
469
+ self._bind_callable_parameters(node.args)
470
+
471
+ # Visit function body
472
+ for stmt in node.body:
473
+ self.visit(stmt)
474
+
475
+ self._with_new_scope(set(), visit_func_body)
476
+
477
+ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
478
+ # Same as FunctionDef
479
+ # Type ignore needed because mypy doesn't recognize structural compatibility
480
+ self.visit_FunctionDef(node) # type: ignore[arg-type]
481
+
482
+ def visit_ClassDef(self, node: ast.ClassDef) -> None:
483
+ # Class name binds in outer scope
484
+ self._add_current_scope_bindings({node.name})
485
+ # DON'T visit body - it's a different scope!
486
+
487
+ def visit_Lambda(self, node: ast.Lambda) -> None:
488
+ # Lambda creates a nested scope
489
+ # Parameters bind within the lambda, but don't leak out
490
+ # Free variables used in body are captured from outer scope
491
+ def visit_body() -> None:
492
+ # Add lambda parameters as bindings
493
+ self._bind_callable_parameters(node.args)
494
+
495
+ # Visit lambda body
496
+ self.visit(node.body)
497
+
498
+ self._with_new_scope(set(), visit_body)
499
+
500
+ def visit_ListComp(self, node: ast.ListComp) -> None:
501
+ # List comprehensions have their own scope (Python 3+)
502
+ def visit_body() -> None:
503
+ _visit_comprehension_generators(self, node.generators)
504
+ self.visit(node.elt)
505
+
506
+ self._with_new_scope(set(), visit_body)
507
+
508
+ def visit_DictComp(self, node: ast.DictComp) -> None:
509
+ # Dict comprehensions have their own scope
510
+ def visit_body() -> None:
511
+ _visit_comprehension_generators(self, node.generators)
512
+ self.visit(node.key)
513
+ self.visit(node.value)
514
+
515
+ self._with_new_scope(set(), visit_body)
516
+
517
+ def visit_SetComp(self, node: ast.SetComp) -> None:
518
+ # Set comprehensions have their own scope
519
+ def visit_body() -> None:
520
+ _visit_comprehension_generators(self, node.generators)
521
+ self.visit(node.elt)
522
+
523
+ self._with_new_scope(set(), visit_body)
524
+
525
+ def visit_GeneratorExp(self, node: ast.GeneratorExp) -> None:
526
+ # Generator expressions have their own scope
527
+ def visit_body() -> None:
528
+ _visit_comprehension_generators(self, node.generators)
529
+ self.visit(node.elt)
530
+
531
+ self._with_new_scope(set(), visit_body)
532
+
533
+ def visit_ExceptHandler(self, node: ast.ExceptHandler) -> None:
534
+ # Exception variable binds in current scope
535
+ # In: except ValueError as e:
536
+ # The variable 'e' is bound here
537
+ if node.name:
538
+ self._add_current_scope_bindings({node.name})
539
+ # Visit the rest (type, body)
540
+ self.generic_visit(node)
541
+
542
+ def visit_Import(self, node: ast.Import) -> None:
543
+ # import foo, bar as baz
544
+ # Binds: foo, baz
545
+ imports: Set[str] = set()
546
+ for alias in node.names:
547
+ name = alias.asname if alias.asname else alias.name
548
+ imports.add(name)
549
+ self._add_current_scope_bindings(imports)
550
+
551
+ def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
552
+ # from module import foo, bar as baz
553
+ # Binds: foo, baz
554
+ imports: Set[str] = set()
555
+ for alias in node.names:
556
+ if alias.name == "*":
557
+ # from module import * - skip, can't determine bindings
558
+ continue
559
+ name = alias.asname if alias.asname else alias.name
560
+ imports.add(name)
561
+ self._add_current_scope_bindings(imports)
562
+
563
+ cache_key = tuple(id(node) for node in nodes)
564
+ if cache_key:
565
+ cached = self._free_var_cache.get(cache_key)
566
+ if cached is not None:
567
+ return set(cached)
568
+
569
+ # Collect uses and bindings
570
+ walker = ScopeRespectingWalker()
571
+ for node in nodes:
572
+ walker.visit(node)
573
+
574
+ uses = walker.uses
575
+ bindings = walker.bindings
576
+ used_before_assigned = walker.used_before_assigned
577
+
578
+ # Free variables are:
579
+ # 1. Variables used but not bound (classic free variables)
580
+ # 2. Variables used before they're assigned (shadowing cases)
581
+ #
582
+ # For case 2: If a variable is both used and assigned, Python treats it
583
+ # as a local variable for the ENTIRE scope. If it's used before it's
584
+ # assigned, we get UnboundLocalError unless it's passed as a parameter.
585
+ # So we must include such variables in free_vars.
586
+ free_vars = (uses - bindings) | (used_before_assigned & bindings)
587
+
588
+ # Filter out Python builtins
589
+ free_vars = filter_builtins(free_vars)
590
+
591
+ if cache_key:
592
+ self._free_var_cache[cache_key] = set(free_vars)
593
+ return free_vars
594
+
595
+ def get_binding_for_name(self, name_node: ast.Name) -> Optional[Binding]:
596
+ """Get the binding for a name node."""
597
+ return self.identifier_bindings.get(name_node)