Files
Projektwissen/scripts/testkatalog.py
T
AndreasTharangandClaude Sonnet 5 ab416fb34c Projektwissen: Git-Vorgaben, Ticketzugriff, Tests und Einstieg
Erster Stand des Repositories AG_QGIS/Projektwissen (bisher Arbeitstitel
"Workspace", ADR 0006). Der lokale Ordner heisst weiter AG_QGIS, weil darin
auch die Plugin-Klone liegen.

Git verbindlich (ADR 0007), aus dem bisherigen Wiki-Konzept entwickelt:
- docs/leitfaden/05_git_arbeitsweise.md mit Kanal-Kette, Branch-Namen und
  Gitea-Branch-Schutz.
- Der Einstiegspunkt eines Fixes richtet sich nach dem Kanal, in dem der
  Fehler sitzt; ein Fix an testing oder stable ist erst mit der
  Rueckfuehrung nach unten abgeschlossen. Das fehlte bisher.
- Der stabile Kanal heisst stable, nicht main: der Release-Workflow in
  Repository:hidden/workflows fuehrt git checkout "$CHANNEL" aus, der
  Branchname muss also dem Kanalnamen entsprechen. Er existiert in keinem
  Plugin-Repo und ist vom Maintainer anzulegen.
- Der Mensch drueckt ab: .claude/settings.json sperrt push, merge, tag.
- scripts/hallo.py und /hallo holen den Stand aller Klone, per
  Fast-Forward wo gefahrlos; nie mergen, stashen oder verwerfen.

Ticketzugriff:
- scripts/gitea.py liest Issues, Kommentare und PRs ueber die oeffentliche
  API, ohne Token, nur GET. Schreibendes nur als Entwurf in entwuerfe/.
- Skill sn-ticket.

Tests (ADR 0009):
- docs/leitfaden/06_tests.md: Testebenen, Testpflicht, und wie eine
  Fachperson beurteilt, ob Tests fachlich sinnvoll sind.
- scripts/testkatalog.py erzeugt docs/tests/testkatalog.md per ast-Parsing,
  ohne Import und ohne QGIS. Befund: nur 100 von 332 Tests haben einen
  Docstring, sn_basis keinen einzigen von 87.
- Vorlage und erste Testkonzept-Seite mit Mermaid. Dabei gefunden und gegen
  origin geprueft: _ermittle_gebietstyp_aus_fbschl hat keinen Test, obwohl
  PR #44/#45 genau diese Funktion tolerant gemacht hat.
- docs/konzept/07_qgis_tests_plan.md: Stufenplan fuer Tests gegen echtes
  QGIS. Dabei aufgefallen: QGIS 3.40 ist seit Januar 2026 End of Life,
  qgis_min steht aber noch darauf.

Werkzeuge (ADR 0008):
- 04_werkzeuge_einrichten.md nur noch Claude Code als CLI und in VS Code.
- .vscode/ entfernt, brauchbare Einstellungen als persoenliche Vorlage.

Ausserdem: README als Einstieg neu aufgebaut, Mermaid-Diagramm in
schnittstellen.md repariert (unmaskierte Klammern, rendert seither nicht).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 11:57:26 +02:00

515 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Erzeugt aus den unittest-Suiten der sn_*-Plugins einen lesbaren Testkatalog.
Der Katalog (docs/tests/testkatalog.md) beantwortet die Frage "Was prüft die
Testsuite eigentlich?" – auch für Fachleute ohne Programmierkenntnisse. Er
listet je Testdatei jeden Testfall mit seiner Beschreibung (erste Docstring-
Zeile) und seinem Status auf und weist am Ende alle Tests ohne Beschreibung als
Arbeitsliste aus.
Die Testdateien werden mit dem ast-Modul der Standardbibliothek *geparst* und
bewusst NICHT importiert: sie importieren sn_basis und Geschwisterpakete, was
ohne den Verlinkungs-Trick von scripts/run_tests.py fehlschlägt, und optionale
Fremdpakete (z. B. openpyxl) sind nicht überall installiert. Dadurch läuft
dieses Skript mit jedem Python >= 3.11 ohne jede Abhängigkeit, auch ohne QGIS.
Der Status je Testfall stammt aus docs/status/testbaseline.json; dort stehen
unter <paket>.rot Einträge der Form "<methode> (<modulpfad>.<Klasse>.<methode>)".
Determinismus: Die erzeugte Datei wird versioniert, darf also bei unverändertem
Eingang keine Diffs erzeugen. Deshalb steht im Kopf weder Uhrzeit noch
Erzeugungsdatum – auch ein reines Tagesdatum würde die Datei bei jedem Lauf an
einem anderen Tag ändern, obwohl sich inhaltlich nichts geändert hat. Statt
dessen weist die Zeile "Stand der Baseline" die verwendete Baseline-Datei über
ihre SHA-256-Kurzprüfsumme aus: Sie ändert sich genau dann, wenn sich der
Eingang ändert. Sortiert wird nach der Reihenfolge in workspace.toml (Pakete),
nach Dateinamen (Testdateien) und nach der Reihenfolge im Quelltext (Klassen
und Testfälle).
Aufrufe (aus dem Workspace-Root):
python scripts/testkatalog.py # docs/tests/testkatalog.md neu schreiben
python scripts/testkatalog.py sn_basis # nur ein Paket
python scripts/testkatalog.py --pruefen # nur prüfen, ob die Datei aktuell ist (CI, vor dem Commit)
python scripts/testkatalog.py --ausgabe /tmp/k.md # anderes Ziel
Exit-Code: 0 = erfolgreich (mit --pruefen: Datei ist aktuell), 1 = Datei veraltet
oder fehlt, 2 = Aufruffehler.
"""
from __future__ import annotations
import argparse
import ast
import difflib
import hashlib
import json
import re
import tomllib
from dataclasses import dataclass, field
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
MANIFEST = ROOT / "workspace.toml"
BASELINE_DEFAULT = ROOT / "docs" / "status" / "testbaseline.json"
AUSGABE_DEFAULT = ROOT / "docs" / "tests" / "testkatalog.md"
FEHLT = "— (Docstring fehlt)"
SPHINX_ROLLE = re.compile(r":(?:mod|func|class|meth|attr|data|obj|ref):`~?([^`]+)`")
SKIP_DEKORATOREN = {"skip", "skipIf", "skipUnless"}
ROLLENTEXT = {
"plattform": "Plattform-Plugin",
"fachplugin": "Fachplugin",
"geruest": "Gerüst",
"platzhalter": "Platzhalter",
"infrastruktur": "Infrastruktur",
"daten": "Daten",
}
@dataclass
class Testfall:
"""Eine einzelne Testmethode (test_*) einer TestCase-Klasse."""
klasse: str
name: str
beschreibung: str
uebersprungen: bool = False
grund: str = ""
erwarteter_fehlschlag: bool = False
status: str = "grün"
@property
def bezeichnung(self) -> str:
return f"{self.klasse}.{self.name}"
@dataclass
class Testgruppe:
"""Eine TestCase-Klasse mit ihren Testfällen."""
name: str
beschreibung: str
faelle: list[Testfall] = field(default_factory=list)
@dataclass
class Testdatei:
"""Eine Datei tests/test_*.py mit allen darin gefundenen Testgruppen."""
pfad: Path
anzeigepfad: str
modulpfad: str
beschreibung: str
gruppen: list[Testgruppe] = field(default_factory=list)
@property
def faelle(self) -> list[Testfall]:
return [f for g in self.gruppen for f in g.faelle]
@dataclass
class Paketkatalog:
"""Alle Testdateien eines Pakets samt Kennzahlen."""
paket: str
ordner: str
rolle: str
dateien: list[Testdatei] = field(default_factory=list)
@property
def faelle(self) -> list[Testfall]:
return [f for d in self.dateien for f in d.faelle]
def lade_manifest() -> dict:
with MANIFEST.open("rb") as f:
return tomllib.load(f)
def erste_zeile(docstring: str | None, dateiname: str = "") -> str:
"""Erste aussagekräftige Zeile eines Docstrings, sonst leer.
Viele Testdateien beginnen ihren Modul-Docstring mit dem eigenen Dateipfad
("sn_basis/tests/test_x.py"). Diese Zeile beschreibt nichts und wird
übersprungen.
"""
if not docstring:
return ""
for zeile in docstring.splitlines():
text = " ".join(zeile.split())
if not text:
continue
if dateiname and text.endswith(".py") and text.split("/")[-1] == dateiname:
continue
# Sphinx-Rollen wie :mod:`sn_basis.x` lesen sich für Fachleute schlecht.
return SPHINX_ROLLE.sub(r"`\1`", text)
return ""
def dekorator_name(knoten: ast.expr) -> str:
"""Punktierter Name eines Dekorators, z. B. 'unittest.skipUnless'."""
ziel = knoten.func if isinstance(knoten, ast.Call) else knoten
teile: list[str] = []
while isinstance(ziel, ast.Attribute):
teile.append(ziel.attr)
ziel = ziel.value
if isinstance(ziel, ast.Name):
teile.append(ziel.id)
return ".".join(reversed(teile))
def lies_skip(dekoratoren: list[ast.expr]) -> tuple[bool, str, bool]:
"""Wertet skip-Dekoratoren aus: (übersprungen, Grund, erwarteter Fehlschlag)."""
uebersprungen = False
erwartet = False
grund = ""
for dek in dekoratoren:
kurz = dekorator_name(dek).rsplit(".", 1)[-1]
if kurz == "expectedFailure":
erwartet = True
elif kurz in SKIP_DEKORATOREN:
uebersprungen = True
argumente = dek.args if isinstance(dek, ast.Call) else []
texte = [a.value for a in argumente if isinstance(a, ast.Constant) and isinstance(a.value, str)]
if texte and not grund:
grund = " ".join(texte[0].split())
if not grund:
grund = f"@unittest.{kurz}"
return uebersprungen, grund, erwartet
def ist_testcase(klasse: ast.ClassDef) -> bool:
"""True für Klassen, die als unittest-TestCase gelten.
Erkannt werden Basisklassen, deren Name auf 'TestCase' endet (unittest.TestCase,
eigene Basisklassen), sowie – als Auffangnetz – Klassen mit Namen 'Test…', die
mindestens eine test_*-Methode haben.
"""
for basis in klasse.bases:
name = dekorator_name(basis)
if name.rsplit(".", 1)[-1].endswith("TestCase"):
return True
hat_tests = any(
isinstance(m, (ast.FunctionDef, ast.AsyncFunctionDef)) and m.name.startswith("test")
for m in klasse.body
)
return klasse.name.startswith("Test") and hat_tests
def lies_testdatei(pfad: Path, paket: str) -> Testdatei:
"""Parst eine Testdatei per ast (ohne sie zu importieren)."""
quelle = pfad.read_text(encoding="utf-8")
baum = ast.parse(quelle, filename=str(pfad))
datei = Testdatei(
pfad=pfad,
anzeigepfad=pfad.relative_to(ROOT).as_posix(),
modulpfad=f"{paket}.tests.{pfad.stem}",
beschreibung=erste_zeile(ast.get_docstring(baum), pfad.name),
)
for knoten in baum.body:
if not isinstance(knoten, ast.ClassDef) or not ist_testcase(knoten):
continue
klassen_skip, klassen_grund, klassen_erwartet = lies_skip(knoten.decorator_list)
gruppe = Testgruppe(name=knoten.name, beschreibung=erste_zeile(ast.get_docstring(knoten)))
for glied in knoten.body:
if not isinstance(glied, (ast.FunctionDef, ast.AsyncFunctionDef)):
continue
if not glied.name.startswith("test"):
continue
skip, grund, erwartet = lies_skip(glied.decorator_list)
gruppe.faelle.append(
Testfall(
klasse=knoten.name,
name=glied.name,
beschreibung=erste_zeile(ast.get_docstring(glied)),
uebersprungen=skip or klassen_skip,
grund=grund or (klassen_grund if klassen_skip else ""),
erwarteter_fehlschlag=erwartet or klassen_erwartet,
)
)
if gruppe.faelle:
datei.gruppen.append(gruppe)
return datei
def sammle_paket(paket: str, repo: dict) -> Paketkatalog | None:
"""Liest alle tests/test_*.py eines Pakets; None, wenn kein tests/-Ordner da ist."""
ordner = ROOT / repo["name"] / "tests"
if not ordner.is_dir():
return None
katalog = Paketkatalog(paket=paket, ordner=repo["name"], rolle=repo.get("rolle", ""))
for pfad in sorted(ordner.glob("test_*.py"), key=lambda p: p.name):
katalog.dateien.append(lies_testdatei(pfad, paket))
return katalog
def lies_baseline(pfad: Path) -> tuple[dict[str, set[str]], str]:
"""Rote Tests je Paket als Menge '<modulpfad>.<Klasse>.<methode>' plus Kurzprüfsumme."""
if not pfad.exists():
return {}, ""
rohdaten = pfad.read_bytes()
kurz = hashlib.sha256(rohdaten).hexdigest()[:12]
daten = json.loads(rohdaten.decode("utf-8"))
rot: dict[str, set[str]] = {}
muster = re.compile(r"^(?P<methode>\S+)\s+\((?P<pfad>[\w.]+)\)$")
for paket, eintrag in daten.items():
schluessel: set[str] = set()
for zeile in eintrag.get("rot", []):
treffer = muster.match(zeile.strip())
schluessel.add(treffer.group("pfad") if treffer else zeile.strip())
rot[paket] = schluessel
return rot, kurz
def setze_status(katalog: Paketkatalog, rot: set[str]) -> None:
for datei in katalog.dateien:
for fall in datei.faelle:
voll = f"{datei.modulpfad}.{fall.bezeichnung}"
if fall.uebersprungen:
fall.status = "übersprungen"
elif fall.erwarteter_fehlschlag:
fall.status = "erwarteter Fehlschlag"
elif voll in rot:
fall.status = "rot (Baseline)"
else:
fall.status = "grün"
def zaehle(faelle: list[Testfall]) -> dict[str, int]:
return {
"gesamt": len(faelle),
"gruen": sum(1 for f in faelle if f.status == "grün"),
"rot": sum(1 for f in faelle if f.status == "rot (Baseline)"),
"uebersprungen": sum(1 for f in faelle if f.status in ("übersprungen", "erwarteter Fehlschlag")),
"erwartet": sum(1 for f in faelle if f.status == "erwarteter Fehlschlag"),
"mit_docstring": sum(1 for f in faelle if f.beschreibung),
}
def anteil(teil: int, ganz: int) -> str:
if not ganz:
return "0 von 0 (–)"
return f"{teil} von {ganz} ({round(100 * teil / ganz)} %)"
def zelle(text: str) -> str:
"""Macht einen Text tabellensicher (Pipes maskieren, Zeilenumbrüche entfernen)."""
return " ".join(text.split()).replace("|", "\\|") or FEHLT
def status_text(fall: Testfall) -> str:
if fall.status == "übersprungen" and fall.grund:
return f"übersprungen – {zelle(fall.grund)}"
return fall.status
def erzeuge_markdown(kataloge: list[Paketkatalog], baseline_pfad: Path, baseline_kurz: str) -> str:
alle = [f for k in kataloge for f in k.faelle]
summe = zaehle(alle)
baseline_anzeige = baseline_pfad.relative_to(ROOT).as_posix() if baseline_pfad.is_relative_to(ROOT) else str(baseline_pfad)
z: list[str] = []
z.append("# Testkatalog der sn_*-Plugins")
z.append("")
z.append("> **Automatisch erzeugt von `scripts/testkatalog.py` – nicht von Hand bearbeiten.**")
z.append("> Neu erzeugen: `python scripts/testkatalog.py`. Prüfen (CI, vor dem Commit): `python scripts/testkatalog.py --pruefen`.")
z.append("")
z.append("Dieser Katalog zeigt ohne Blick in den Quelltext, **was** die Testsuite prüft. Er ist die Grundlage")
z.append("für die fachliche Frage, ob die Abdeckung sinnvoll ist. Die Angaben stammen aus den Testdateien")
z.append("selbst (Docstrings) und aus der Testbaseline.")
z.append("")
if baseline_kurz:
z.append(f"Stand der Baseline: `{baseline_anzeige}` (SHA-256 `{baseline_kurz}`)")
else:
z.append(f"Stand der Baseline: `{baseline_anzeige}` **nicht gefunden** – alle Testfälle gelten als grün.")
z.append("")
z.append(
f"Gesamt: {len(kataloge)} Pakete, {sum(len(k.dateien) for k in kataloge)} Testdateien, "
f"{summe['gesamt']} Testfälle – {summe['gruen']} grün, {summe['rot']} rot (Baseline), "
f"{summe['uebersprungen']} übersprungen."
)
z.append(f"Mit Beschreibung (Docstring): {anteil(summe['mit_docstring'], summe['gesamt'])}.")
z.append("")
z.append("## Zusammenfassung")
z.append("")
z.append("| Paket | Testdateien | Testfälle | grün | rot (Baseline) | übersprungen | mit Beschreibung |")
z.append("| --- | ---: | ---: | ---: | ---: | ---: | ---: |")
for katalog in kataloge:
k = zaehle(katalog.faelle)
z.append(
f"| `{katalog.paket}` | {len(katalog.dateien)} | {k['gesamt']} | {k['gruen']} | "
f"{k['rot']} | {k['uebersprungen']} | {anteil(k['mit_docstring'], k['gesamt'])} |"
)
z.append(
f"| **Summe** | **{sum(len(k.dateien) for k in kataloge)}** | **{summe['gesamt']}** | "
f"**{summe['gruen']}** | **{summe['rot']}** | **{summe['uebersprungen']}** | "
f"**{anteil(summe['mit_docstring'], summe['gesamt'])}** |"
)
z.append("")
if summe["erwartet"]:
z.append(f"In Spalte „übersprungen“ sind {summe['erwartet']} Testfälle mit `@unittest.expectedFailure` enthalten.")
z.append("")
z.append("### Legende")
z.append("")
z.append("- **prüft** – erste Zeile des Docstrings des Testfalls. Fehlt sie, steht dort `" + FEHLT + "`.")
z.append("- **grün** – der Testfall lief im Baseline-Lauf durch.")
z.append(f"- **rot (Baseline)** – der Testfall schlägt bereits fehl und ist in `{baseline_anzeige}` vermerkt; er darf nicht ignoriert, aber auch nicht als neuer Fehler gewertet werden.")
z.append("- **übersprungen** – per `@unittest.skip`/`skipIf`/`skipUnless` abgeschaltet; der Grund steht dabei.")
z.append("- Ein Testfall heißt `Testgruppe.test_name`; die Testgruppe fasst zusammengehörige Testfälle einer Datei zusammen.")
z.append("")
for katalog in kataloge:
k = zaehle(katalog.faelle)
rolle = ROLLENTEXT.get(katalog.rolle, katalog.rolle or "Plugin")
z.append(f"## Paket `{katalog.paket}`")
z.append("")
z.append(
f"{rolle}, Ordner `{katalog.ordner}` – {len(katalog.dateien)} Testdateien mit {k['gesamt']} Testfällen "
f"({k['gruen']} grün, {k['rot']} rot, {k['uebersprungen']} übersprungen)."
)
z.append("")
if not katalog.dateien:
z.append("Keine Testdateien gefunden.")
z.append("")
continue
for datei in katalog.dateien:
z.append(f"### `{datei.anzeigepfad}`")
z.append("")
z.append(f"Worum es in der Datei geht: {zelle(datei.beschreibung)}")
z.append("")
if not datei.gruppen:
z.append("Keine Testfälle: Die Datei enthält keine `unittest.TestCase`-Klasse – sie ist eine reine "
"Hilfsdatei, die beim Testlauf nur importiert wird.")
z.append("")
continue
z.append("Testgruppen:")
z.append("")
for gruppe in datei.gruppen:
z.append(f"- `{gruppe.name}` ({len(gruppe.faelle)} Testfälle): {zelle(gruppe.beschreibung)}")
z.append("")
z.append("| Testfall | prüft | Status |")
z.append("| --- | --- | --- |")
for fall in datei.faelle:
z.append(f"| `{fall.bezeichnung}` | {zelle(fall.beschreibung)} | {status_text(fall)} |")
z.append("")
z.append("## Lücken")
z.append("")
z.append("Arbeitsliste: Diese Testfälle haben keinen Docstring; was sie fachlich prüfen, lässt sich nur")
z.append("aus dem Quelltext erschließen. Jeder Eintrag braucht eine einzeilige Beschreibung im Testfall,")
z.append("dann verschwindet er hier von selbst.")
z.append("")
for katalog in kataloge:
k = zaehle(katalog.faelle)
offen = k["gesamt"] - k["mit_docstring"]
z.append(f"### `{katalog.paket}` – {offen} von {k['gesamt']} Testfällen ohne Beschreibung")
z.append("")
if not offen:
z.append("Keine Lücken.")
z.append("")
continue
for datei in katalog.dateien:
luecken = [f for f in datei.faelle if not f.beschreibung]
if not luecken:
continue
hinweis = "" if datei.beschreibung else " – auch der Modul-Docstring fehlt"
z.append(f"#### `{datei.anzeigepfad}` ({len(luecken)} von {len(datei.faelle)}){hinweis}")
z.append("")
for fall in luecken:
z.append(f"- `{fall.bezeichnung}`")
z.append("")
return "\n".join(z) + "\n"
def melde_unterschied(vorhanden: bytes, erzeugt: str, ziel: str) -> None:
"""Kurzmeldung, worin sich Datei und erzeugter Stand unterscheiden."""
alt = vorhanden.decode("utf-8", errors="replace").splitlines()
neu = erzeugt.splitlines()
hinzu = entfernt = 0
erste = ""
for zeile in difflib.unified_diff(alt, neu, lineterm="", n=0):
if zeile.startswith("+++") or zeile.startswith("---") or zeile.startswith("@@"):
continue
if zeile.startswith("+"):
hinzu += 1
elif zeile.startswith("-"):
entfernt += 1
if not erste:
erste = zeile[:160]
print(f"{ziel} ist nicht aktuell: {hinzu} Zeilen neu, {entfernt} Zeilen entfallen.")
if erste:
print(f" erste Abweichung: {erste}")
print(" Neu erzeugen mit: python scripts/testkatalog.py")
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("pakete", nargs="*", help="Paketnamen (Standard: alle mit tests=true)")
ap.add_argument("--pruefen", action="store_true",
help="nichts schreiben; Exit-Code 1, wenn die vorhandene Datei nicht dem erzeugten Stand entspricht")
ap.add_argument("--ausgabe", type=Path, default=AUSGABE_DEFAULT, help="Zieldatei (Standard: docs/tests/testkatalog.md)")
ap.add_argument("--baseline-datei", type=Path, default=BASELINE_DEFAULT)
args = ap.parse_args()
manifest = lade_manifest()
repos = {r["paket"]: r for r in manifest["repos"] if r.get("paket")}
testbare = [p for p, r in repos.items() if r.get("tests")]
gewuenscht = args.pakete or testbare
unbekannt = [p for p in gewuenscht if p not in repos]
if unbekannt:
print(f"Unbekannte Pakete: {unbekannt}. Bekannt: {sorted(repos)}")
return 2
# Reihenfolge aus workspace.toml übernehmen – das hält die Ausgabe stabil.
reihenfolge = [p for p in repos if p in set(gewuenscht)]
rot_je_paket, baseline_kurz = lies_baseline(args.baseline_datei)
if not baseline_kurz:
print(f"Keine Baseline unter {args.baseline_datei} – alle Testfälle werden als grün geführt.")
kataloge: list[Paketkatalog] = []
for paket in reihenfolge:
if not repos[paket].get("tests"):
print(f"[{paket}] laut workspace.toml ohne Tests – übersprungen")
continue
katalog = sammle_paket(paket, repos[paket])
if katalog is None:
print(f"[{paket}] kein Ordner {repos[paket]['name']}/tests – übersprungen")
continue
setze_status(katalog, rot_je_paket.get(paket, set()))
k = zaehle(katalog.faelle)
print(f"[{paket}] {len(katalog.dateien)} Testdateien, {k['gesamt']} Testfälle "
f"({k['gruen']} grün, {k['rot']} rot, {k['uebersprungen']} übersprungen, "
f"{k['mit_docstring']} mit Docstring)")
if not katalog.dateien:
print(f" Keine Datei tests/test_*.py gefunden.")
kataloge.append(katalog)
if not kataloge:
print("Keine Pakete mit Testdateien gefunden – nichts zu tun.")
return 2
text = erzeuge_markdown(kataloge, args.baseline_datei, baseline_kurz)
ziel = args.ausgabe
anzeige = ziel.relative_to(ROOT).as_posix() if ziel.is_relative_to(ROOT) else str(ziel)
if args.pruefen:
if not ziel.exists():
print(f"{anzeige} fehlt. Neu erzeugen mit: python scripts/testkatalog.py")
return 1
if ziel.read_bytes() == text.encode("utf-8"):
print(f"{anzeige} ist aktuell.")
return 0
melde_unterschied(ziel.read_bytes(), text, anzeige)
return 1
ziel.parent.mkdir(parents=True, exist_ok=True)
ziel.write_text(text, encoding="utf-8", newline="\n")
print(f"Testkatalog geschrieben: {anzeige} ({len(text.splitlines())} Zeilen)")
return 0
if __name__ == "__main__":
raise SystemExit(main())