216 lines
8.1 KiB
Python
216 lines
8.1 KiB
Python
#!/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())
|