Picarones / scripts /gen_readme_tables.py
Claude
post-rewrite wiring audit: Phases 1-5 (sécurité, méthodologie, moteurs, zombie, naming)
5e48c0b unverified
Raw
History Blame
13.7 kB
"""Génère les tableaux Markdown du README depuis le code réel.
Sprint A13 (item M-22 / M-23 / M-25 / M-26 du plan de remédiation).
Ce script remplace les listes manuelles qui dérivaient silencieusement
(le bug typique : un nouvel engine ajouté → README pas mis à jour →
``test_readme_consistency`` casse au prochain CI).
Trois tableaux sont produits :
1. **Engines** : un par fichier ``picarones/engines/*.py`` (hors base /
factory / __init__).
2. **CLI commands** : depuis ``picarones --help``.
3. **API endpoints** : depuis ``app.openapi()["paths"]``.
Le script écrit chaque tableau dans le README entre des balises HTML
``<!-- generated:engines -->`` … ``<!-- /generated:engines -->`` (idem
``cli`` et ``endpoints``). En CI, un job re-exécute ce script et
échoue si le diff Git est non vide — garantissant l'absence de dérive.
Usage :
.. code-block:: bash
python scripts/gen_readme_tables.py # met à jour README.md
python scripts/gen_readme_tables.py --check # CI : exit 1 si diff
"""
from __future__ import annotations
import argparse
import re
import subprocess
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
README = REPO_ROOT / "README.md"
#: Fichiers où ``N tests`` / ``N passed`` est mentionné en prose et
#: doit converger vers le compte réel. L'audit doc S60 avait
#: identifié 5 chiffres divergents dans 5 docs (1072 / 1244 / 3354 /
#: ~3600 / ~5030). Liste explicite plutôt qu'un glob — un mainteneur
#: qui ajoute un nouveau doc doit l'inscrire ici consciemment.
TEST_COUNT_FILES: tuple[Path, ...] = (
README,
REPO_ROOT / "CLAUDE.md",
REPO_ROOT / "GOVERNANCE.md",
REPO_ROOT / "docs" / "developer" / "index.md",
REPO_ROOT / "docs" / "developer" / "index.en.md",
)
# Permet l'invocation du script en subprocess sans avoir besoin
# d'un ``pip install -e .`` préalable (cas CI / test pytest).
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
# ---------------------------------------------------------------------------
# Engines
# ---------------------------------------------------------------------------
_ENGINE_DESCRIPTIONS: dict[str, tuple[str, str, str]] = {
# name → (display_name, type, install_hint)
"tesseract": ("Tesseract 5", "Local CLI", "`pip install pytesseract` + system binary"),
"pero_ocr": ("Pero OCR", "Local Python", "`pip install -e .[pero]`"),
"kraken": ("Kraken HTR", "Local Python", "`pip install -e .[kraken]` + modèle `.mlmodel`"),
"calamari": ("Calamari OCR", "Local Python", "`pip install -e .[calamari]` + checkpoint"),
"mistral_ocr": ("Mistral OCR", "Cloud API", "`MISTRAL_API_KEY` env var"),
"google_vision": ("Google Vision", "Cloud API", "`GOOGLE_APPLICATION_CREDENTIALS` env var"),
"azure_doc_intel": ("Azure Doc Intelligence", "Cloud API", "`AZURE_DOC_INTEL_ENDPOINT` + `AZURE_DOC_INTEL_KEY`"),
}
def _engine_files() -> list[str]:
"""Retourne la liste triée des modules d'OCR engines (sans helpers).
Sprint H.2.d (2026-05) : ``picarones/adapters/legacy_engines/`` a été
supprimé, le canonique est ``picarones/adapters/ocr/``. On filtre
aussi les modules helpers (``confidences``, ``precomputed``) qui ne
sont pas des engines OCR à proprement parler.
"""
out: list[str] = []
engines_dir = REPO_ROOT / "picarones" / "adapters" / "ocr"
skip = {"__init__", "base", "factory", "confidences", "precomputed"}
for path in sorted(engines_dir.glob("*.py")):
name = path.stem
if name in skip:
continue
out.append(name)
return out
def build_engines_table() -> str:
rows = [
"| Engine | Type | Installation |",
"|--------|------|-------------|",
]
for name in _engine_files():
display, kind, install = _ENGINE_DESCRIPTIONS.get(
name,
(name, "Unknown", "—"),
)
rows.append(f"| **{display}** | {kind} | {install} |")
return "\n".join(rows)
# ---------------------------------------------------------------------------
# CLI commands
# ---------------------------------------------------------------------------
_CLI_DESCRIPTIONS: dict[str, str] = {
"run": "Run a full benchmark on a corpus",
"report": "Generate an HTML report from JSON results",
"demo": "Generate a demo report with synthetic data (no engine required)",
"metrics": "Compute CER/WER between two text files",
"engines": "List available OCR engines and LLM adapters",
"info": "Display version and system information",
"serve": "Launch the FastAPI web interface",
"history": "Query longitudinal benchmark history (SQLite)",
"robustness": "Run robustness analysis with degraded images",
"import": "Import a corpus from a remote source (IIIF, HF, HTR-United)",
"compare": "Compare two benchmark JSON runs and flag regressions (Sprint 28)",
"pipeline": "Run / compare composed pipelines from a YAML spec (Sprint 70)",
"diagnose": "Pre-wired workflow: bench + improvement levers + factual recommendations",
"economics": "Pre-wired workflow: bench + effective throughput + cost projection",
"edition": "Pre-wired workflow: bench + philological metrics for critical editing",
}
def build_cli_table() -> str:
from picarones.interfaces.cli import cli
rows = [
"| Command | Description |",
"|---------|-------------|",
]
for name in sorted(cli.commands.keys()):
desc = _CLI_DESCRIPTIONS.get(name, "—")
rows.append(f"| `picarones {name}` | {desc} |")
return "\n".join(rows)
# ---------------------------------------------------------------------------
# API endpoints
# ---------------------------------------------------------------------------
def build_endpoints_table() -> str:
from picarones.interfaces.web.app import app
spec = app.openapi()
rows = [
"| Method | Endpoint | Summary |",
"|--------|----------|---------|",
]
for path in sorted(spec.get("paths", {})):
methods = spec["paths"][path]
for method, definition in sorted(methods.items()):
if method.upper() not in ("GET", "POST", "PUT", "DELETE", "PATCH"):
continue
summary = (
definition.get("summary")
or (definition.get("description", "") or "—").split("\n")[0]
)
# Tronque à 60 caractères pour le tableau.
if len(summary) > 80:
summary = summary[:77] + "…"
rows.append(
f"| `{method.upper()}` | `{path}` | {summary} |"
)
return "\n".join(rows)
# ---------------------------------------------------------------------------
# Test count
# ---------------------------------------------------------------------------
def collect_test_count() -> int | None:
"""Lance ``pytest --collect-only`` et extrait le compteur."""
try:
result = subprocess.run(
[
sys.executable,
"-m",
"pytest",
"--collect-only",
"-q",
"--no-cov",
"-p",
"no:cacheprovider",
"tests/",
],
capture_output=True,
text=True,
cwd=REPO_ROOT,
timeout=60,
)
except subprocess.TimeoutExpired:
return None
for line in reversed(result.stdout.strip().split("\n")):
m = re.search(r"(\d+)\s+tests?\s+collected", line)
if m:
return int(m.group(1))
return None
# ---------------------------------------------------------------------------
# Insertion dans le README
# ---------------------------------------------------------------------------
def _replace_section(text: str, marker: str, content: str) -> str:
"""Remplace le contenu entre ``<!-- generated:<marker> -->`` et
``<!-- /generated:<marker> -->`` ; conserve le reste du fichier
intact. Si les balises sont absentes, retourne le texte inchangé
(le README doit être mis à jour avec les balises au moins une fois
manuellement avant que ce script puisse opérer)."""
pattern = re.compile(
rf"(<!--\s*generated:{marker}\s*-->)(.*?)(<!--\s*/generated:{marker}\s*-->)",
re.DOTALL,
)
replacement = f"\\1\n\n{content}\n\n\\3"
new_text, n = pattern.subn(replacement, text)
if n == 0:
sys.stderr.write(
f"[gen_readme_tables] Marqueurs <!-- generated:{marker} --> "
f"absents du README — section non mise à jour.\n"
)
return text
return new_text
def _replace_test_count(text: str, count: int) -> str:
"""Remplace les mentions ``N tests`` ou ``N passed`` qui citent un
nombre dans la fenêtre [count*0.5, count*2]. Garde la formulation
exacte (espace, ponctuation) intacte.
Le count est **arrondi à la cinquantaine inférieure** pour rendre
le résultat OS-déterministe : selon les binaires système (tesseract,
pero-ocr) installés sur le runner, certains modules de test sont
skipés au niveau ``pytest.skip(allow_module_level=True)`` — ce qui
soustrait le fichier entier de la collection. Exemple observé en
S8.7 : Linux CI (avec tesseract) collecte 4510 tests, dev local
(sans tesseract) en collecte 4509. Avec un floor à 10 ces deux
valeurs divergent (4510 vs 4500) ; avec un floor à 50, elles
convergent toutes deux vers 4500.
Note : utilise ``(count // 50) * 50`` plutôt que
``round(count, -1)``. Le ``round()`` Python applique le
"banker's rounding" (round half to even) qui n'est pas
monotone. Le floor à 50 garde la propriété de monotonie (un
ajout de tests ne fait jamais reculer le compteur) tout en
absorbant les écarts de ±49 tests entre environnements.
"""
rounded_count = (count // 50) * 50
def _sub(match: re.Match) -> str:
cited = int(match.group(1))
# Ne touche pas si le nombre cité est complètement hors plage —
# c'est probablement une autre référence (un chiffre dans une
# phrase qui parle d'autre chose).
if cited < count * 0.5 or cited > count * 2:
return match.group(0)
return match.group(0).replace(str(cited), str(rounded_count))
return re.sub(r"(\d{3,5})\s+(?:tests|passed)\b", _sub, text)
def render_readme(check_only: bool = False) -> int:
"""Met à jour les sections générées du README. Retourne 0 ou 1."""
if not README.exists():
sys.stderr.write(f"README absent : {README}\n")
return 1
original = README.read_text(encoding="utf-8")
text = original
text = _replace_section(text, "engines", build_engines_table())
text = _replace_section(text, "cli", build_cli_table())
text = _replace_section(text, "endpoints", build_endpoints_table())
count = collect_test_count()
if count is not None:
text = _replace_test_count(text, count)
if check_only:
if text != original:
sys.stderr.write(
"[gen_readme_tables] README divergent du code généré. "
"Lancer ``python scripts/gen_readme_tables.py`` puis "
"committer.\n"
)
return 1
return 0
if text != original:
README.write_text(text, encoding="utf-8")
print(f"[gen_readme_tables] README mis à jour ({len(text)} octets).")
else:
print("[gen_readme_tables] README déjà à jour.")
return 0
def render_test_counts(check_only: bool = False) -> int:
"""Synchronise le compte de tests dans tous les ``TEST_COUNT_FILES``.
Audit doc S60 : 5 chiffres divergents (1072 / 1244 / 3354 /
~3600 / ~5030) selon les docs. Cette fonction lit le compte
réel via ``pytest --collect-only`` et l'injecte dans chaque
fichier de la liste.
Returns
-------
int
0 si tout est synchronisé, 1 si divergence (en mode check)
ou erreur d'écriture.
"""
count = collect_test_count()
if count is None:
# ``pytest --collect-only`` indisponible (env CI minimal,
# virtualenv dégradé). On ne casse pas le build pour ça.
sys.stderr.write(
"[gen_readme_tables] collect_test_count indisponible — "
"skip mise à jour des compteurs de tests.\n",
)
return 0
divergent = False
for path in TEST_COUNT_FILES:
if not path.exists():
continue
original = path.read_text(encoding="utf-8")
updated = _replace_test_count(original, count)
if updated == original:
continue
divergent = True
if check_only:
sys.stderr.write(
f"[gen_readme_tables] {path.relative_to(REPO_ROOT)} "
"diverge du compteur de tests réel.\n",
)
else:
path.write_text(updated, encoding="utf-8")
print(
f"[gen_readme_tables] {path.relative_to(REPO_ROOT)} "
"test count mis à jour.",
)
if check_only and divergent:
return 1
return 0
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--check",
action="store_true",
help="N'écrit rien ; sort 1 si le README diverge du code généré.",
)
args = parser.parse_args()
rc_readme = render_readme(check_only=args.check)
rc_counts = render_test_counts(check_only=args.check)
return rc_readme or rc_counts
if __name__ == "__main__":
sys.exit(main())