File size: 12,759 Bytes
acad3da
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d0a3fab
 
 
 
 
 
 
 
 
 
 
 
 
162c559
 
 
 
 
acad3da
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4b174f5
 
 
 
 
acad3da
4b174f5
 
acad3da
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e071a2c
 
 
 
 
 
 
 
 
 
 
acad3da
 
 
 
 
 
 
e071a2c
acad3da
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d0a3fab
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
acad3da
 
 
 
 
 
 
 
d0a3fab
 
 
acad3da
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
"""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]`"),
    "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'engines (sans base / factory).

    Lot E (2026-05) : ``picarones/engines/`` a été retiré, son canonique
    est ``picarones/adapters/legacy_engines/``.
    """
    out: list[str] = []
    engines_dir = REPO_ROOT / "picarones" / "adapters" / "legacy_engines"
    for path in sorted(engines_dir.glob("*.py")):
        name = path.stem
        if name in {"__init__", "base", "factory"}:
            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.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.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 dizaine** pour rendre le résultat
    OS-déterministe : sur Windows certains tests POSIX-only sont
    skipés (cf. ``pytest.importorskip``) ce qui décale le compteur
    de quelques unités.  L'arrondi absorbe ces écarts mineurs sans
    masquer une vraie évolution (le seuil de tolérance des tests
    consistency reste à ±5 %).
    """
    rounded_count = round(count, -1)  # -1 = arrondi à la dizaine

    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())