Lernreise 1 — Code als Daten (AST)

Was du nach dieser Reise kannst

Du verstehst, dass Quelltext eine Baumstruktur ist — nicht nur Text — und du kannst diesen Baum mit Python selbst durchwandern. Damit baust du statische Wächter, die ganze Klassen von Bugs vor der Ausführung erkennen: in 300 Millisekunden, ohne dass deine App jemals startet.

Konkret nach Tier 3 dieser Reise: Du schreibst eigenständig einen AST-Wächter für eine Klasse von Bugs, die in deinem Code wiederkehrt, und integrierst ihn als Pre-Commit-Prüfung.

Anker — der Bug, den dieses Wissen verhindert hätte

In der iMenu-Entwicklung wurde im SessionsOverlay eine Methode umbenannt:

PYTHON
# Vorher
def _render(self) -> None:
    self.query_one("#sessions-list").update(self._format_list())

# Nachher
def _update_display(self) -> None:
    self.query_one("#sessions-list").update(self._format_list())

Sechs Aufrufstellen wurden mitgezogen — eine wurde übersehen:

PYTHON
def on_mount(self) -> None:
    self.set_interval(2.0, self._render)   # ← der vergessene Aufruf

Zur Laufzeit, zwei Sekunden nach dem Öffnen des Overlays:

TXT
AttributeError: 'SessionsOverlay' object has no attribute '_render'

Diese Bug-Klasse — „undefiniertes self.method()" — ist mit einem 30-Zeilen-AST-Wächter in 0,3 Sekunden findbar. Du kannst sie nie wieder committen, ohne dass eine CI-Stufe sie meldet. Genau das bauen wir in dieser Reise.


Tier 1 · Erkennen — Code als Daten

Lies, sieh, erkenne die Struktur.

Python liest Python

Pythons Standardbibliothek enthält das Modul ast — Abstract Syntax Tree. Es parst Quelltext nicht in Text, sondern in eine Baumstruktur aus Python-Objekten.

PYTHON
import ast
tree = ast.parse("self.foo()")
print(ast.dump(tree, indent=2))

Ergibt:

TXT
Module(
  body=[
    Expr(
      value=Call(
        func=Attribute(
          value=Name(id='self'),
          attr='foo'),
        args=[],
        keywords=[]))],
  type_ignores=[])

Lies das Stück für Stück. Der Ausdruck self.foo() ist:

  • ein Call (Funktionsaufruf)
  • dessen func ein Attribute ist (Punkt-Zugriff)
  • dessen value ein Name mit id='self' ist
  • und dessen attr='foo' der Methodenname ist

Wenn du das siehst, hast du den entscheidenden Begriff verstanden: Quellcode ist bereits eine Datenstruktur — und Python kann sie dir geben.

Die Knotentypen, die du jetzt brauchst

Knoten Was er repräsentiert
ast.Module die ganze Datei
ast.ClassDef eine class Foo:-Deklaration
ast.FunctionDef eine def bar(...):-Deklaration
ast.Call ein Funktionsaufruf wie foo()
ast.Attribute ein Punkt-Zugriff wie self.foo
ast.Name ein Bezeichner wie self, Foo, x

ast.walk(tree) liefert dir alle Knoten in beliebiger Reihenfolge — perfekt für „finde alle X" Fragen.

Die Vererbungsfalle

Diese Erkenntnis ist die teure: Methoden auf Elternklassen sind in deiner Datei nicht sichtbar.

PYTHON
class MyOverlay(ModalScreen):
    def compose(self):
        yield Static("hello")

    def on_mount(self):
        self.dismiss()   # ← `dismiss` ist auf `ModalScreen` definiert, nicht hier

Ein naiver Wächter würde self.dismiss() als „undefiniert" melden — Falschmeldung. Du brauchst:

  • entweder eine Allowlist von Framework-Methoden (TEXTUAL_ACTIONS, MODAL_SCREEN_METHODS, …),
  • oder eine Vererbungsauflösung: parse die Eltern, sammle ihre Methoden, prüfe gegen die Vereinigung.

In der iMenu-Suite wurde beides kombiniert.


Tier 2 · Üben — Bau deinen ersten Wächter

Schreib das jetzt mit. Datei: tools/guard_undefined_self.py.

Schritt 1 — Methoden einer Klasse einsammeln

PYTHON
import ast
from pathlib import Path

def methods_of(class_node: ast.ClassDef) -> set[str]:
    return {
        node.name
        for node in ast.walk(class_node)
        if isinstance(node, ast.FunctionDef)
    }

Schritt 2 — self.X()-Aufrufe einsammeln

PYTHON
def self_calls_in(class_node: ast.ClassDef) -> set[str]:
    calls = set()
    for node in ast.walk(class_node):
        if (
            isinstance(node, ast.Call)
            and isinstance(node.func, ast.Attribute)
            and isinstance(node.func.value, ast.Name)
            and node.func.value.id == "self"
        ):
            calls.add(node.func.attr)
    return calls

Schritt 3 — Allowlist für Framework-Methoden

PYTHON
TEXTUAL_ACTIONS = {
    "dismiss", "push_screen", "pop_screen", "switch_screen",
    "query_one", "query", "set_interval", "set_timer",
    "mount", "remove", "refresh", "notify",
}

Schritt 4 — Den Wächter laufen lassen

PYTHON
def check_file(path: Path) -> list[str]:
    findings = []
    tree = ast.parse(path.read_text())
    for cls in ast.walk(tree):
        if not isinstance(cls, ast.ClassDef):
            continue
        defined = methods_of(cls)
        used = self_calls_in(cls)
        undefined = used - defined - TEXTUAL_ACTIONS
        for name in undefined:
            findings.append(f"{path}:{cls.name}: self.{name}() ist undefiniert")
    return findings

if __name__ == "__main__":
    import sys
    for arg in sys.argv[1:]:
        for finding in check_file(Path(arg)):
            print(finding)

Schritt 5 — Probelauf

Lege eine Datei demo.py an:

PYTHON
class SessionsOverlay:
    def on_mount(self):
        self.set_interval(2.0, self._render)  # bewusster Bug

    def _update_display(self):
        pass

Lauf:

BASH
python tools/guard_undefined_self.py demo.py

Erwartete Ausgabe:

TXT
demo.py:SessionsOverlay: self._render() ist undefiniert

Du hast jetzt einen Wächter, der genau die Bug-Klasse erkennt, die in iMenu eingeführt wurde. Dauer: ~0,3 Sekunden. Vor jedem Commit aufrufbar. Nie wieder dieser Bug.


Tier 3 · Übertragen — Eigene Strukturen prüfen

Such dir in deinem eigenen Code eine strukturelle Invariante — eine Regel, die sich rein durch Code-Form ausdrücken lässt, ohne den Code zu laufen.

Beispiele aus realen Projekten:

Bug-Klasse Strukturelle Invariante
Doppelte compose()-IDs In einer compose-Methode taucht id="x" zweimal auf
Verwaister watch_<feld> Eine Methode watch_foo existiert, aber keine reactive foo
Toter Binding-Handler Binding(...) zeigt auf action_x, aber action_x fehlt
Vergessenes super().__init__() __init__ einer Subklasse ruft nicht super().__init__(...)
Verwaiste Imports from x import y, aber y taucht im Body nie auf

Deine Übung: Wähle eine. Schreibe sie als AST-Wächter. Lass ihn auf deinem Repo laufen. Berichte, was du findest.

Bonus — als Pre-Commit-Hook einhängen:

BASH
# .git/hooks/pre-commit
#!/bin/sh
files=$(git diff --cached --name-only --diff-filter=ACM | grep '\.py$')
[ -z "$files" ] && exit 0
python tools/guard_undefined_self.py $files || exit 1

Was du jetzt weißt

  • Quellcode IST eine Datenstruktur. Du kannst sie inspizieren, ohne sie auszuführen.
  • Eine Klasse von Bugs ist eine strukturelle Eigenschaft. Wenn du sie als Code-Muster ausdrücken kannst, kannst du sie statisch prüfen.
  • Statische Wächter sind die billigste Test-Stufe. Schneller als jeder Unit-Test, früher als jeder CI-Run.
  • Vererbung ist die teure Falle. Plane sie ein.

Weiterführende Reisen

Quellen & Anker

  • Python-Dokumentation: ast — Abstract Syntax Trees
  • iMenu-Repo: tests/test_static_guards.py (5 Wächter im Einsatz)
  • Anker-Commit: die _render → _update_display-Umbenennung, die diese Lernreise auslöste