"""AST-based detector for name shadowing in Python source. A 'shadow' occurs when a name bound in an inner scope reuses a name already bound in an enclosing scope, hiding the outer binding. This is a common source of subtle bugs (e.g. loop variable clobbering an outer variable, or a comprehension leaking its target name in older Pythons). Only *binding* occurrences are considered (assignments, function params, import names, for/comprehension targets, with-as targets, except-as). """ import ast import sys from dataclasses import dataclass from typing import List, Set, Tuple @dataclass(frozen=True) class Shadow: name: str outer_line: int inner_line: int kind: str # 'function-param', 'assignment', 'import', 'loop-target', 'comprehension', 'with-as', 'except-as' class Scope: def __init__(self, parent=None, kind="module"): self.parent = parent self.kind = kind self.bindings: Set[str] = set() self.binding_lines: dict = {} def is_bound_outer(self, name: str) -> bool: s = self.parent while s is not None: if name in s.bindings: return True s = s.parent return False def outer_line_for(self, name: str) -> int: s = self.parent while s is not None: if name in s.bindings: return s.binding_lines.get(name, 0) s = s.parent return 0 def record(self, name: str, line: int = 0): self.bindings.add(name) if name not in self.binding_lines: self.binding_lines[name] = line def _binding_names(target) -> List[str]: """Collect names bound by an assignment target / pattern.""" out = [] if isinstance(target, ast.Name): out.append(target.id) elif isinstance(target, (ast.Tuple, ast.List)): for elt in target.elts: out.extend(_binding_names(elt)) elif isinstance(target, ast.Starred): out.extend(_binding_names(target.value)) return out class ShadowFinder(ast.NodeVisitor): def __init__(self): self.shadows: List[Shadow] = [] self.scope: Scope = Scope(kind="module") def _enter_scope(self, kind, node_line): new = Scope(parent=self.scope, kind=kind) self._scope_stack_push(new) return new def _scope_stack_push(self, s): self._prev_scope = self.scope self.scope = s def _leave_scope(self): self.scope = self._prev_scope # --- function defs create a new scope; params are bindings --- def visit_FunctionDef(self, node): # params bind in the new function scope new_scope = Scope(parent=self.scope, kind="function") old = self.scope self.scope = new_scope for arg in (*node.args.posonlyargs, *node.args.args, *node.args.kwonlyargs): if arg.arg in ("",): continue if self.scope.parent.is_bound_outer(arg.arg) or arg.arg in self.scope.parent.bindings: # shadow check against enclosing if arg.arg in self._enclosing_bindings(): self.shadows.append(Shadow(arg.arg, self.scope.outer_line_for(arg.arg), node.lineno, "function-param")) new_scope.record(arg.arg) if node.args.vararg: new_scope.record(node.args.vararg.arg) if node.args.kwarg: new_scope.record(node.args.kwarg.arg) self.generic_visit(node) self.scope = old visit_AsyncFunctionDef = visit_FunctionDef def _enclosing_bindings(self) -> Set[str]: s = self.scope.parent acc: Set[str] = set() while s is not None: acc |= s.bindings s = s.parent return acc def _outer_line(self, name: str) -> int: # find the line where name was first bound in an enclosing scope s = self.scope.parent while s is not None: # we don't store lines; approximate by searching AST? Instead store line map. s = s.parent return 0 # We need outer line. Rework: track binding lines in scope. # (handled below by patching Scope to store lines) def visit_Assign(self, node): for t in node.targets: for name in _binding_names(t): if name in self._enclosing_bindings(): self.shadows.append(Shadow(name, self.scope.outer_line_for(name), node.lineno, "assignment")) self.scope.bindings.add(name) self.generic_visit(node) def visit_AnnAssign(self, node): if isinstance(node.target, ast.Name): name = node.target.id if name in self._enclosing_bindings(): self.shadows.append(Shadow(name, self.scope.outer_line_for(name), node.lineno, "assignment")) self.scope.bindings.add(name) self.generic_visit(node) def visit_AugAssign(self, node): # aug-assign to existing name is not a new binding in our model; skip self.generic_visit(node) def visit_Import(self, node): for alias in node.names: name = alias.asname if alias.asname else alias.name.split(".")[0] if name in self._enclosing_bindings(): self.shadows.append(Shadow(name, self.scope.outer_line_for(name), node.lineno, "import")) self.scope.bindings.add(name) self.generic_visit(node) def visit_ImportFrom(self, node): for alias in node.names: if alias.name == "*": continue name = alias.asname if alias.asname else alias.name if name in self._enclosing_bindings(): self.shadows.append(Shadow(name, self.scope.outer_line_for(name), node.lineno, "import")) self.scope.bindings.add(name) self.generic_visit(node) def visit_For(self, node): for name in _binding_names(node.target): if name in self._enclosing_bindings(): self.shadows.append(Shadow(name, self.scope.outer_line_for(name), node.lineno, "loop-target")) self.scope.bindings.add(name) # body in same scope self.generic_visit(node) def visit_AsyncFor(self, node): self.visit_For(node) def visit_With(self, node): for item in node.items: if item.optional_vars is not None: for name in _binding_names(item.optional_vars): if name in self._enclosing_bindings(): self.shadows.append(Shadow(name, self.scope.outer_line_for(name), node.lineno, "with-as")) self.scope.bindings.add(name) self.generic_visit(node) def visit_ExceptHandler(self, node): if node.name: if node.name in self._enclosing_bindings(): self.shadows.append(Shadow(node.name, self.scope.outer_line_for(node.name), node.lineno, "except-as")) self.scope.bindings.add(node.name) self.generic_visit(node) # comprehensions create their own scope def visit_ListComp(self, node): self._comp_scope(node) def visit_SetComp(self, node): self._comp_scope(node) def visit_GeneratorExp(self, node): self._comp_scope(node) def visit_DictComp(self, node): self._comp_scope(node) def _comp_scope(self, node): new_scope = Scope(parent=self.scope, kind="comprehension") old = self.scope self.scope = new_scope # targets of all generators for gen in node.generators: for name in _binding_names(gen.target): if name in self._enclosing_bindings() or name in self.scope.parent.bindings: # shadow relative to enclosing (parent of comp scope) if name in self._enclosing_bindings(): self.shadows.append(Shadow(name, self.scope.outer_line_for(name), gen.target.lineno if hasattr(gen.target, "lineno") else node.lineno, "comprehension")) new_scope.bindings.add(name) # visit body with this scope # for ListComp/SetComp/GenExp: elt; DictComp: key/value if isinstance(node, ast.DictComp): self.visit(node.key) self.visit(node.value) elif isinstance(node, ast.GeneratorExp): self.visit(node.elt) else: self.visit(node.elt) for gen in node.generators: self.visit(gen.iter) # conditions for c in gen.ifs: self.visit(c) self.scope = old def find_shadows(source: str) -> List[Shadow]: tree = ast.parse(source) finder = ShadowFinder() finder.visit(tree) return finder.shadows def main(argv=None): import argparse p = argparse.ArgumentParser(description="Detect name shadowing in Python files.") p.add_argument("paths", nargs="+") args = p.parse_args(argv) rc = 0 for path in args.paths: try: src = open(path).read() except OSError as e: print(f"{path}: {e}", file=sys.stderr) rc = 1 continue sh = find_shadows(src) for s in sh: print(f"{path}:{s.inner_line}: shadowed name {s.name!r} ({s.kind})") if sh: rc = 1 return rc if __name__ == "__main__": raise SystemExit(main())