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 astAbstract 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