#!/usr/bin/env python3 """Sucht Namen, die verwendet, aber nicht importiert oder definiert sind. python3 tools/check-imports.py Warum das nötig ist: `python3 -m compileall` prüft nur die Syntax. Ein fehlender Import ist syntaktisch einwandfrei - der Fehler tritt erst auf, wenn die betroffene Zeile tatsächlich ausgeführt wird: NameError: name 'bucket_key' is not defined Genau das ist passiert: Beim Umstellen der Missbrauchsbremse suchte eine Textersetzung nach einem einzeiligen Import, der in zwei Dateien aber mehrzeilig in Klammern stand. Der Aufruf wurde eingebaut, der Import nicht - und aufgefallen ist es erst beim Anmeldeversuch im Betrieb. Arbeitet über den abstrakten Syntaxbaum (`ast`), nicht über reguläre Ausdrücke: Python bringt seinen eigenen Parser mit, also gibt es keinen Grund zu raten. Grenzen, die bewusst in Kauf genommen werden: - Namen, die nur zur Laufzeit entstehen (globals(), setattr), werden als unbekannt gemeldet - Sternchen-Importe (from x import *) können nicht aufgelöst werden """ import ast import builtins import sys from pathlib import Path ROOT = Path(__file__).resolve().parent.parent APP = ROOT / "backend" / "app" BUILTINS = set(dir(builtins)) | { "__name__", "__file__", "__doc__", "__all__", "__spec__", "__package__", "self", "cls", } class Scope: """Ein Geltungsbereich mit seinen bekannten Namen.""" def __init__(self, parent=None): self.parent = parent self.names: set[str] = set() def add(self, name: str) -> None: self.names.add(name) def knows(self, name: str) -> bool: if name in self.names: return True return self.parent.knows(name) if self.parent else False class Checker(ast.NodeVisitor): def __init__(self, path: Path): self.path = path self.module = Scope() self.problems: list[tuple[int, str]] = [] # Erst alle Namen der Modulebene einsammeln, dann prüfen - # eine Funktion darf etwas benutzen, das weiter unten steht. self.pending: list[tuple[ast.AST, Scope]] = [] # ---- Namen einsammeln ---- def collect(self, node: ast.AST, scope: Scope) -> None: for child in ast.iter_child_nodes(node): if isinstance(child, (ast.Import, ast.ImportFrom)): for alias in child.names: scope.add((alias.asname or alias.name).split(".")[0]) elif isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): scope.add(child.name) elif isinstance(child, ast.Assign): for target in child.targets: self.bind(target, scope) elif isinstance(child, (ast.AnnAssign, ast.AugAssign)): self.bind(child.target, scope) elif isinstance(child, (ast.For, ast.AsyncFor)): self.bind(child.target, scope) self.collect(child, scope) elif isinstance(child, (ast.With, ast.AsyncWith)): for item in child.items: if item.optional_vars: self.bind(item.optional_vars, scope) self.collect(child, scope) elif isinstance(child, ast.ExceptHandler): if child.name: scope.add(child.name) self.collect(child, scope) elif isinstance(child, (ast.If, ast.Try, ast.While)): self.collect(child, scope) elif isinstance(child, (ast.Global, ast.Nonlocal)): for name in child.names: scope.add(name) def bind(self, target: ast.AST, scope: Scope) -> None: if isinstance(target, ast.Name): scope.add(target.id) elif isinstance(target, (ast.Tuple, ast.List)): for element in target.elts: self.bind(element, scope) elif isinstance(target, ast.Starred): self.bind(target.value, scope) # ---- Prüfen ---- def check_function(self, node, parent: Scope) -> None: scope = Scope(parent) args = node.args for arg in (args.posonlyargs + args.args + args.kwonlyargs): scope.add(arg.arg) if args.vararg: scope.add(args.vararg.arg) if args.kwarg: scope.add(args.kwarg.arg) self.collect(node, scope) # Alles, was IRGENDWO im Rumpf gebunden wird, gilt als bekannt: # Schleifenvariablen in Comprehensions, Lambda-Parameter, späte # Importe innerhalb der Funktion, Zuweisungen in verschachtelten # Blöcken. Diese Prüfung soll fehlende Importe finden, nicht # Geltungsbereiche im Detail nachbilden - dafür wäre ein echtes # Namensauflösungsmodell nötig, und die Fehlalarme wären # zahlreicher als die Funde. bound = set(scope.names) for child in ast.walk(node): if isinstance(child, ast.Name) and isinstance(child.ctx, ast.Store): bound.add(child.id) elif isinstance(child, (ast.Import, ast.ImportFrom)): for alias in child.names: bound.add((alias.asname or alias.name).split(".")[0]) elif isinstance(child, ast.arg): bound.add(child.arg) elif isinstance(child, ast.ExceptHandler) and child.name: bound.add(child.name) elif isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): bound.add(child.name) for child in ast.walk(node): if isinstance(child, ast.Name) and isinstance(child.ctx, ast.Load): if child.id in bound or child.id in BUILTINS: continue if not scope.knows(child.id): self.problems.append((child.lineno, child.id)) # Verschachtelte Funktionen einzeln for child in ast.iter_child_nodes(node): self.walk_definitions(child, scope) def walk_definitions(self, node: ast.AST, scope: Scope) -> None: for child in ast.walk(node): if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)): self.check_function(child, scope) elif isinstance(child, ast.ClassDef): inner = Scope(scope) self.collect(child, inner) for member in ast.iter_child_nodes(child): if isinstance(member, (ast.FunctionDef, ast.AsyncFunctionDef)): self.check_function(member, inner) def run(self, tree: ast.Module) -> list[tuple[int, str]]: self.collect(tree, self.module) # Namen auf Modulebene for child in ast.iter_child_nodes(tree): if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)): self.check_function(child, self.module) elif isinstance(child, ast.ClassDef): inner = Scope(self.module) self.collect(child, inner) for member in ast.iter_child_nodes(child): if isinstance(member, (ast.FunctionDef, ast.AsyncFunctionDef)): self.check_function(member, inner) return self.problems def main() -> int: files = sorted(APP.rglob("*.py")) total = 0 for path in files: source = path.read_text(encoding="utf-8") if "import *" in source: continue try: tree = ast.parse(source) except SyntaxError as exc: print(f" {path.relative_to(ROOT)}: Syntaxfehler in Zeile {exc.lineno}") total += 1 continue problems = Checker(path).run(tree) if problems: print(f"\n {path.relative_to(ROOT)}") for line, name in sorted(set(problems)): print(f" Zeile {line}: '{name}' wird verwendet, " "aber nirgends importiert oder definiert") total += 1 if total: print(f"\n{total} nicht aufgelöste(r) Name(n)") return 1 print(f"{len(files)} Python-Dateien geprüft, alle Namen aufgelöst") return 0 if __name__ == "__main__": raise SystemExit(main())