Root-Auflösung ist importzeit-gebunden: chemenu kann nur den Checkout bedienen, in dem es selbst liegt #31

Closed
opened 2026-09-01 19:59:23 +00:00 by torben · 3 comments
Owner

Befund

config.ROOT wird aus dem Ort der Datei abgeleitet, und alle Pfade darunter werden
zur Importzeit gebunden:

# tools/chemenu/config.py:15-22
ROOT = Path(__file__).resolve().parents[2]
RAW_DIR = ROOT / "raw"
KB_DIR  = ROOT / "kb"

60 Verwendungen von config.ROOT über 19 Module (Tests ausgenommen). Damit kann jeder
In-Process-Konsument nur genau den Checkout bedienen, in dem das Paket selbst liegt.

Die Umschaltung zur Laufzeit ist heute nur halb möglich, und das ist der
gefährlichere Teil: monkeypatch.setattr(config, "ROOT", tmp_path)
(tools/chemenu/tests/conftest.py:115) repointet ROOT, aber nicht KB_DIR/RAW_DIR
— die sind längst gebunden. Die Testsuite kommt damit durch, weil das kb_dir-Fixture
den Pfad zusätzlich explizit durchreicht. Wer sich auf ROOT allein verlässt, baut
etwas, das scheinbar auf einem Zielbaum arbeitet und in Wahrheit aus dem
Entwickler-Checkout antwortet.

Diese Fehlerklasse ist im Repo schon einmal aufgetreten und dokumentiert: das
raw_dir-Fixture (conftest.py:96-120) existiert genau deshalb, nachdem ein Test
monatelang nur grün war, weil dieser Checkout zufällig ein raw/documents/ hatte.

Zweiter Teil desselben Problems: die Bibliotheksgrenze kennt keine Exceptions

_util.fail() (tools/chemenu/commands/_util.py:41-47) wirft typer.Exit und setzt
ein modulglobales _declined. Für die CLI ist das richtig. Für einen
In-Process-Aufrufer heißt es: ein Validierungsfehler kommt als Exit-Code statt als
Exception an, und globaler Zustand überlebt zwischen Aufrufen.

Warum das eigenständig zählt

Nicht bloß Vorarbeit für den MCP-Server (#19). Es ist der Schritt, der chemenu von
einem Skript, das in seinem eigenen Verzeichnis lebt, zu einer Bibliothek macht, die man
auf einen Korpus zeigen kann. Nutzen unabhängig von MCP:

  • mehrere Korpora aus einem Checkout bedienen
  • Tests, die den Zielbaum nicht mehr auf zwei Wegen gleichzeitig einstellen müssen
  • dist/migrate-Werkzeuge, die auf eine Zielinstanz zeigen können statt auf sich selbst

Skizze

  • Root-Auflösung als Funktion statt als Konstante. Reihenfolge: expliziter Parameter →
    Env-Var (CHEMENU_ROOT) → Walk-up wie bisher. Der Walk-up bleibt Default, damit
    tools/wikitool sich unverändert verhält.
  • Abgeleitete Pfade nicht mehr zur Importzeit binden.
  • An der Bibliotheksgrenze Exceptions statt typer.Exit; der CLI-Adapter übersetzt sie
    weiterhin in fail() → ERROR-Zeile, Exit 1, Budget-Refund. _declined ist danach nur
    noch für den CLI-Pfad zuständig.
  • Die neue Env-Var muss nach _WIKITOOL_ENV — siehe #23 und
    instructions/dev/testing-conventions.md Schritt 4.

Akzeptanzkriterien

  • Ein In-Process-Aufruf von run_search/run_lint lässt sich auf einen beliebigen
    Korpusbaum richten, ohne dass irgendein Pfad des Entwickler-Checkouts gelesen wird
  • Ein Test beweist genau das (heute liefe er über KB_DIR auf den echten Baum)
  • CHEMENU_ROOT in _WIKITOOL_ENV registriert, #23-konform
  • Kernfunktionen melden Validierungsfehler als Exception; CLI-Verhalten (ERROR-Zeile,
    Exit 1, Budget-Refund) unverändert
  • Ohne gesetzte Env-Var verhält sich tools/wikitool unverändert
  • Version-Bump --minor (neue Fähigkeit, rückwärtskompatibel), Changelog-Eintrag
## Befund `config.ROOT` wird aus dem Ort der Datei abgeleitet, und alle Pfade darunter werden **zur Importzeit** gebunden: ```python # tools/chemenu/config.py:15-22 ROOT = Path(__file__).resolve().parents[2] RAW_DIR = ROOT / "raw" KB_DIR = ROOT / "kb" ``` 60 Verwendungen von `config.ROOT` über 19 Module (Tests ausgenommen). Damit kann jeder In-Process-Konsument nur genau den Checkout bedienen, in dem das Paket selbst liegt. Die Umschaltung zur Laufzeit ist heute nur **halb** möglich, und das ist der gefährlichere Teil: `monkeypatch.setattr(config, "ROOT", tmp_path)` (`tools/chemenu/tests/conftest.py:115`) repointet `ROOT`, aber nicht `KB_DIR`/`RAW_DIR` — die sind längst gebunden. Die Testsuite kommt damit durch, weil das `kb_dir`-Fixture den Pfad zusätzlich explizit durchreicht. Wer sich auf `ROOT` allein verlässt, baut etwas, das scheinbar auf einem Zielbaum arbeitet und in Wahrheit aus dem Entwickler-Checkout antwortet. Diese Fehlerklasse ist im Repo schon einmal aufgetreten und dokumentiert: das `raw_dir`-Fixture (`conftest.py:96-120`) existiert genau deshalb, nachdem ein Test monatelang nur grün war, weil dieser Checkout zufällig ein `raw/documents/` hatte. ## Zweiter Teil desselben Problems: die Bibliotheksgrenze kennt keine Exceptions `_util.fail()` (`tools/chemenu/commands/_util.py:41-47`) wirft `typer.Exit` und setzt ein modulglobales `_declined`. Für die CLI ist das richtig. Für einen In-Process-Aufrufer heißt es: ein Validierungsfehler kommt als Exit-Code statt als Exception an, und globaler Zustand überlebt zwischen Aufrufen. ## Warum das eigenständig zählt Nicht bloß Vorarbeit für den MCP-Server (#19). Es ist der Schritt, der `chemenu` von einem Skript, das in seinem eigenen Verzeichnis lebt, zu einer Bibliothek macht, die man auf einen Korpus **zeigen** kann. Nutzen unabhängig von MCP: - mehrere Korpora aus einem Checkout bedienen - Tests, die den Zielbaum nicht mehr auf zwei Wegen gleichzeitig einstellen müssen - `dist`/`migrate`-Werkzeuge, die auf eine Zielinstanz zeigen können statt auf sich selbst ## Skizze - Root-Auflösung als Funktion statt als Konstante. Reihenfolge: expliziter Parameter → Env-Var (`CHEMENU_ROOT`) → Walk-up wie bisher. Der Walk-up bleibt Default, damit `tools/wikitool` sich unverändert verhält. - Abgeleitete Pfade nicht mehr zur Importzeit binden. - An der Bibliotheksgrenze Exceptions statt `typer.Exit`; der CLI-Adapter übersetzt sie weiterhin in `fail()` → ERROR-Zeile, Exit 1, Budget-Refund. `_declined` ist danach nur noch für den CLI-Pfad zuständig. - Die neue Env-Var muss nach `_WIKITOOL_ENV` — siehe #23 und `instructions/dev/testing-conventions.md` Schritt 4. ## Akzeptanzkriterien - [ ] Ein In-Process-Aufruf von `run_search`/`run_lint` lässt sich auf einen beliebigen Korpusbaum richten, ohne dass irgendein Pfad des Entwickler-Checkouts gelesen wird - [ ] Ein Test beweist genau das (heute liefe er über `KB_DIR` auf den echten Baum) - [ ] `CHEMENU_ROOT` in `_WIKITOOL_ENV` registriert, #23-konform - [ ] Kernfunktionen melden Validierungsfehler als Exception; CLI-Verhalten (ERROR-Zeile, Exit 1, Budget-Refund) unverändert - [ ] Ohne gesetzte Env-Var verhält sich `tools/wikitool` unverändert - [ ] Version-Bump `--minor` (neue Fähigkeit, rückwärtskompatibel), Changelog-Eintrag
torben added the prio/plannedsize/S labels 2026-09-01 19:59:23 +00:00
Author
Owner

Bestätigt und auf dem kritischen Pfad, Stand 2026-09-01.

Als ich dieses Issue anlegte, hatte ich angemerkt, dass es an Wert verlöre, falls wikitool nach
Go migriert wird — die Bibliotheksgrenze in Python zu ziehen wäre dann weggeworfene Arbeit. Diese
Frage ist entschieden: Python bleibt, Go ist vom Tisch (Begründung im Kommentar zu #19). Der
Vorbehalt entfällt damit ersatzlos.

Zwei Präzisierungen aus der Zwischenzeit:

Der Umfang ist kleiner, als der Issue-Text vermuten lässt. Der reine Lese-Kern
(search/, page.py, kb_scan.py, frontmatter_io.py, type_resolver.py, config.py,
kb_collections.py) importiert an Fremdcode genau zwei Pakete — yaml und jsonschema. Alles
andere ist Standardbibliothek. Die „~14 transitiven Pakete" sind ein pip list-Artefakt des
CLI-Kopfs, nicht der Import-Graph.

Aber die Naht ist noch nicht gezogen. run_search() und run_lint() liegen in Modulen, die
typer auf Modulebene importieren (commands/search.py:26, commands/lint.py:17,
commands/types_cmd.py:16); über _util kommt rich dazu. Wer die Funktionen heute in-process
aufruft, zieht den CLI-Kopf mit. Genau das ist der zweite Teil dieses Issues, und er ist die
Voraussetzung dafür, dass die Zweier-Bilanz oben für den MCP-Server aus #19 tatsächlich gilt.

Reihenfolge: dieses Issue kommt nach #33 (die Härtung fasst dieselben Dateien an, aber
oberflächlich — sie zuerst zu machen spart einen Konflikt) und vor #19.

Bestätigt und auf dem kritischen Pfad, Stand 2026-09-01. Als ich dieses Issue anlegte, hatte ich angemerkt, dass es an Wert verlöre, falls `wikitool` nach Go migriert wird — die Bibliotheksgrenze in Python zu ziehen wäre dann weggeworfene Arbeit. Diese Frage ist entschieden: **Python bleibt, Go ist vom Tisch** (Begründung im Kommentar zu #19). Der Vorbehalt entfällt damit ersatzlos. Zwei Präzisierungen aus der Zwischenzeit: **Der Umfang ist kleiner, als der Issue-Text vermuten lässt.** Der reine Lese-Kern (`search/`, `page.py`, `kb_scan.py`, `frontmatter_io.py`, `type_resolver.py`, `config.py`, `kb_collections.py`) importiert an Fremdcode genau zwei Pakete — `yaml` und `jsonschema`. Alles andere ist Standardbibliothek. Die „~14 transitiven Pakete" sind ein `pip list`-Artefakt des CLI-Kopfs, nicht der Import-Graph. **Aber die Naht ist noch nicht gezogen.** `run_search()` und `run_lint()` liegen in Modulen, die `typer` auf Modulebene importieren (`commands/search.py:26`, `commands/lint.py:17`, `commands/types_cmd.py:16`); über `_util` kommt `rich` dazu. Wer die Funktionen heute in-process aufruft, zieht den CLI-Kopf mit. Genau das ist der zweite Teil dieses Issues, und er ist die Voraussetzung dafür, dass die Zweier-Bilanz oben für den MCP-Server aus #19 tatsächlich gilt. Reihenfolge: dieses Issue kommt **nach** #33 (die Härtung fasst dieselben Dateien an, aber oberflächlich — sie zuerst zu machen spart einen Konflikt) und **vor** #19.
Author
Owner

Teil der Sequenz in #36 (Master: Weg zum MCP-Leseserver) — dort Schritt 2 von 3, zwischen
#33 und #19.

Teil der Sequenz in **#36** (Master: Weg zum MCP-Leseserver) — dort Schritt 2 von 3, zwischen #33 und #19.
Author
Owner

Umgesetzt in 2.3.0. Alle Akzeptanzkriterien erfüllt.

Root-Auflösung nach Präzedenz. config.resolve_root(): expliziter Parameter →
$CHEMENU_ROOT → Walk-up. Der Walk-up bleibt Default, tools/wikitool verhält sich ohne die
Variable unverändert (gegengeprüft).

Nichts ist mehr zur Importzeit gebunden. ROOT und alle abgeleiteten Pfade werden bei jedem
Zugriff aufgelöst (PEP 562 __getattr__ auf dem Modul). Damit folgen KB_DIR/RAW_DIR dem
aktuellen ROOT — auch einem zugewiesenen — und der halb-umgezeigte Zustand ist nicht mehr
konstruierbar. Genau die Fehlerklasse, die schlimmer war als die Einschränkung selbst.

Eine Stolperfalle, die dabei sichtbar wurde und dokumentiert gehört: monkeypatch merkt sich
den alten Wert, indem es ihn liest — also auflöst — und schreibt ihn beim Aufräumen als echtes
Attribut zurück. Der Cleanup baute die stale Bindung damit wieder auf, und der nächste Test erbte
ein KB_DIR, das auf den vorigen Baum zeigte. Deshalb config.reset(), von der autouse-Fixture
auf beiden Seiten gerufen.

Bibliotheksgrenze gezogen. Der reine Kern liegt jetzt in search/service.py, lint_core.py
und types_core.py — kein typer, kein rich. Die Module unter commands/ sind die
Terminal-Adapter darüber und re-exportieren die Namen, damit kein bestehender Import bricht. Ein
Test importiert chemenu.api in einem frischen Interpreter und prüft sys.modules: weder
chemenu.commands.* noch typer/rich/click.

chemenu.api.Corpus als In-Process-Einstieg: nimmt einen Root, liefert exakt die
--json-Formen der CLI, stempelt jede Antwort mit Commit-SHA und Zeitstempel.

Der Beweis-Test patcht Path.read_text und Path.rglob und lässt jeden Zugriff unterhalb
des Entwickler-Checkouts durchfallen, während ein fremder Root gesetzt ist. Vor der lazy
Auflösung hätte er nicht grün werden können.

Exceptions statt typer.Exit. chemenu/errors.py mit ChemenuError
ValidationError / BackendError; PredicateError, FrontmatterError, UnknownBackend und
die beiden Ripgrep* hängen darunter. ValidationError erbt zusätzlich ValueError, weil
PredicateError vorher eines war und Aufrufer es so fangen. CLI-Verhalten unverändert:
ERROR-Zeile, Exit 1, Budget-Refund.

CHEMENU_ROOT ist in _WIKITOOL_ENV registriert, #23-konform — aber von Hand. Die fehlende
Erzwingung bleibt genau das, was #23 beschreibt; dieses Issue hat sie nicht mit gelöst.


Was der Umbau nebenbei aufgedeckt hat — zwei Abhängigkeiten, die nur durch Zufall hielten:

  1. TypeResolver.repo_root war beim Import gebunden. Jetzt folgt er ROOT — womit Fixtures, die
    ROOT auf einen tmp-Baum zeigen, die mitgelieferten Type-Specs ausdrücklich benennen
    müssen (use_shipped_type_specs). Vorher zeigte TYPES_DIR einfach weiter auf diesen
    Checkout, und die Tests liefen auf einer Abhängigkeit, die niemand aufgeschrieben hatte —
    dieselbe Form wie das Loch, für das raw_dir geschrieben wurde, eine Ebene tiefer.
  2. resolve() reichte den Root nicht ans Backend durch: ein Aufrufer konnte run_search einen
    Korpus übergeben, während RipgrepBackend weiter config.KB_DIR durchlief. Die Anfrage aus
    dem einen Baum beantwortet, die Seiten aus dem anderen gelesen, ohne dass irgendetwas das
    gesagt hätte. Behoben.

Eine Ehrlichkeit zur Thread-Sicherheit: config.rooted() ist prozessweit, solange der Block
offen ist, und damit nicht thread-sicher — ein Aufrufer, der mehrere Korpora gleichzeitig bedient,
hält das Lock. Nötig, weil nicht alles einen Root als Argument nimmt; der modulglobale
TypeResolver muss types/ finden. Steht so im Docstring, statt still zu bleiben.

Umgesetzt in **2.3.0**. Alle Akzeptanzkriterien erfüllt. **Root-Auflösung nach Präzedenz.** `config.resolve_root()`: expliziter Parameter → `$CHEMENU_ROOT` → Walk-up. Der Walk-up bleibt Default, `tools/wikitool` verhält sich ohne die Variable unverändert (gegengeprüft). **Nichts ist mehr zur Importzeit gebunden.** `ROOT` und alle abgeleiteten Pfade werden bei jedem Zugriff aufgelöst (PEP 562 `__getattr__` auf dem Modul). Damit folgen `KB_DIR`/`RAW_DIR` dem aktuellen `ROOT` — auch einem zugewiesenen — und der halb-umgezeigte Zustand ist nicht mehr konstruierbar. Genau die Fehlerklasse, die schlimmer war als die Einschränkung selbst. **Eine Stolperfalle, die dabei sichtbar wurde und dokumentiert gehört:** `monkeypatch` merkt sich den alten Wert, indem es ihn *liest* — also auflöst — und schreibt ihn beim Aufräumen als echtes Attribut zurück. Der Cleanup baute die stale Bindung damit wieder auf, und der nächste Test erbte ein `KB_DIR`, das auf den vorigen Baum zeigte. Deshalb `config.reset()`, von der autouse-Fixture auf beiden Seiten gerufen. **Bibliotheksgrenze gezogen.** Der reine Kern liegt jetzt in `search/service.py`, `lint_core.py` und `types_core.py` — kein `typer`, kein `rich`. Die Module unter `commands/` sind die Terminal-Adapter darüber und re-exportieren die Namen, damit kein bestehender Import bricht. Ein Test importiert `chemenu.api` in einem frischen Interpreter und prüft `sys.modules`: weder `chemenu.commands.*` noch `typer`/`rich`/`click`. **`chemenu.api.Corpus`** als In-Process-Einstieg: nimmt einen Root, liefert exakt die `--json`-Formen der CLI, stempelt jede Antwort mit Commit-SHA und Zeitstempel. **Der Beweis-Test** patcht `Path.read_text` und `Path.rglob` und lässt jeden Zugriff unterhalb des Entwickler-Checkouts durchfallen, während ein fremder Root gesetzt ist. Vor der lazy Auflösung hätte er nicht grün werden können. **Exceptions statt `typer.Exit`.** `chemenu/errors.py` mit `ChemenuError` → `ValidationError` / `BackendError`; `PredicateError`, `FrontmatterError`, `UnknownBackend` und die beiden `Ripgrep*` hängen darunter. `ValidationError` erbt zusätzlich `ValueError`, weil `PredicateError` vorher eines war und Aufrufer es so fangen. CLI-Verhalten unverändert: `ERROR`-Zeile, Exit 1, Budget-Refund. **`CHEMENU_ROOT` ist in `_WIKITOOL_ENV` registriert**, #23-konform — aber von Hand. Die fehlende *Erzwingung* bleibt genau das, was #23 beschreibt; dieses Issue hat sie nicht mit gelöst. --- **Was der Umbau nebenbei aufgedeckt hat** — zwei Abhängigkeiten, die nur durch Zufall hielten: 1. `TypeResolver.repo_root` war beim Import gebunden. Jetzt folgt er `ROOT` — womit Fixtures, die `ROOT` auf einen tmp-Baum zeigen, die mitgelieferten Type-Specs **ausdrücklich** benennen müssen (`use_shipped_type_specs`). Vorher zeigte `TYPES_DIR` einfach weiter auf diesen Checkout, und die Tests liefen auf einer Abhängigkeit, die niemand aufgeschrieben hatte — dieselbe Form wie das Loch, für das `raw_dir` geschrieben wurde, eine Ebene tiefer. 2. `resolve()` reichte den Root nicht ans Backend durch: ein Aufrufer konnte `run_search` einen Korpus übergeben, während `RipgrepBackend` weiter `config.KB_DIR` durchlief. Die Anfrage aus dem einen Baum beantwortet, die Seiten aus dem anderen gelesen, ohne dass irgendetwas das gesagt hätte. Behoben. **Eine Ehrlichkeit zur Thread-Sicherheit:** `config.rooted()` ist prozessweit, solange der Block offen ist, und damit nicht thread-sicher — ein Aufrufer, der mehrere Korpora gleichzeitig bedient, hält das Lock. Nötig, weil nicht alles einen Root als Argument nimmt; der modulglobale `TypeResolver` muss `types/` finden. Steht so im Docstring, statt still zu bleiben.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: torben/chemenu#31