From fa64cbc4a401adf079e155531cb218a100250bf0 Mon Sep 17 00:00:00 2001 From: Marco Morath Date: Thu, 13 Aug 2026 20:58:47 +0200 Subject: [PATCH] =?UTF-8?q?Korrektur=20Pr=C3=BCfskript=20f=C3=BCr=20Missbr?= =?UTF-8?q?auchserkennung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/routers/admin.py | 7 +- backend/app/routers/auth.py | 1 + backend/app/routers/sharing.py | 1 + tools/check-all.sh | 1 + tools/check-env.sh | 0 tools/check-imports.py | 215 +++++++++++++++++++++++++++++++++ tools/smoke-test.sh | 0 7 files changed, 224 insertions(+), 1 deletion(-) mode change 100755 => 100644 tools/check-all.sh mode change 100755 => 100644 tools/check-env.sh create mode 100644 tools/check-imports.py mode change 100755 => 100644 tools/smoke-test.sh diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 821017f..fbd6e30 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -26,7 +26,12 @@ from app.mail import ( from app.maintenance import describe, run_cleanup from app.models import EmailChange, User from app.schemas import MailCheckOut, MailTestIn, MessageOut -from app.runtime_settings import BY_KEY, describe_durations, set_duration +from app.runtime_settings import ( + BY_KEY, + describe_durations, + get_duration, + set_duration, +) from app.schemas_admin import ( AdminSettingsIn, AdminSettingsOut, diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 8002bc5..5d07996 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -31,6 +31,7 @@ from app.schemas import ( from app.runtime_settings import get_duration from app.users import apply_if_complete from app.security import ( + bucket_key, check_rate_limit, hash_password, hash_token, diff --git a/backend/app/routers/sharing.py b/backend/app/routers/sharing.py index 153fb77..405a6da 100644 --- a/backend/app/routers/sharing.py +++ b/backend/app/routers/sharing.py @@ -31,6 +31,7 @@ from app.schemas_shopping import ( TransferOwnershipIn, ) from app.security import ( + bucket_key, check_rate_limit, hash_token, new_token, diff --git a/tools/check-all.sh b/tools/check-all.sh old mode 100755 new mode 100644 index 5e23c32..5fa9fec --- a/tools/check-all.sh +++ b/tools/check-all.sh @@ -35,6 +35,7 @@ run "nginx-Konfiguration" python3 tools/check-nginx.py web run "JavaScript-Module" node tools/check-js.mjs run "Strichcode-Decoder" node tools/test-barcode.mjs run "Python-Syntax" sh -c 'cd backend && python3 -m compileall -q app alembic && echo "in Ordnung"' +run "Python-Namen" python3 tools/check-imports.py printf '\n' if [ "$failed" -eq 0 ]; then diff --git a/tools/check-env.sh b/tools/check-env.sh old mode 100755 new mode 100644 diff --git a/tools/check-imports.py b/tools/check-imports.py new file mode 100644 index 0000000..0ad1587 --- /dev/null +++ b/tools/check-imports.py @@ -0,0 +1,215 @@ +#!/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()) diff --git a/tools/smoke-test.sh b/tools/smoke-test.sh old mode 100755 new mode 100644