Spaces:
Sleeping
Sleeping
File size: 15,549 Bytes
3bffe86 f54bb20 3bffe86 5e48c0b 3bffe86 de9192c 3bffe86 de9192c 3bffe86 12acb53 3bffe86 12acb53 3bffe86 12acb53 3bffe86 12acb53 3bffe86 | 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 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 | """Tests de cohérence du ``README.md`` avec le code réel.
Sprint A2 (items M-19, M-23, M-24, M-25, M-26 de l'audit
``institutional-readiness-2026-05.md``).
**Contrat** : tout élément vérifiable annoncé dans le README doit
correspondre à un artefact du code. La direction est unidirectionnelle :
on vérifie que ce qui est *annoncé* existe, **pas** que tout ce qui
existe est annoncé (cette deuxième garantie sera posée en A13 lors de
la refonte complète du README).
Vérifications appliquées :
1. **Moteurs OCR listés** dans le tableau « Supported Engines » ⇒
un fichier ``picarones/engines/{nom}.py`` doit exister, ou la ligne
est annotée par un commentaire HTML
``<!-- doc-check: skip-engine -->``.
2. **Commandes CLI listées** dans le tableau « CLI Commands » ⇒ la
commande doit apparaître dans ``picarones --help``.
3. **Endpoints API web listés** ⇒ doivent exister dans
``app.openapi()["paths"]`` (ou être annotés skip).
4. **Compteur de tests** : les phrases du type « pytest tests/ →
N passed » doivent correspondre au baseline collecté par
``pytest --collect-only``, à 5 % près (tolérance pour PR en cours).
**Mécanisme d'exception** : pour autoriser une ligne temporairement
non vérifiable (par exemple un moteur en cours d'ajout dans le même
PR que le README), insérer un commentaire HTML
``<!-- doc-check: skip-<kind> -->`` à la fin de la ligne du tableau.
À utiliser avec modération — tout skip est inspecté en revue.
"""
from __future__ import annotations
import re
from pathlib import Path
import pytest
# ---------------------------------------------------------------------------
# Constantes
# ---------------------------------------------------------------------------
REPO_ROOT = Path(__file__).resolve().parents[2]
README_PATH = REPO_ROOT / "README.md"
ENGINES_DIR = REPO_ROOT / "picarones" / "adapters" / "ocr"
#: Marqueur HTML qui désactive un check sur la ligne. Format :
#: ``<!-- doc-check: skip-engine -->``, ``skip-cli``, ``skip-endpoint``.
SKIP_PATTERN = re.compile(r"<!--\s*doc-check:\s*skip-([a-z]+)\s*-->")
#: Préfixes de "moteurs" du tableau qui ne sont *pas* des moteurs OCR
#: (ce sont des LLMs/VLMs utilisés via les pipelines). Ils sont
#: tolérés en attendant la refonte A13 qui scindera le tableau.
#: La comparaison est sur préfixe pour accepter les annotations comme
#: ``GPT-4o (VLM)`` ou ``Claude Sonnet (VLM)``.
NOT_OCR_ENGINES_TOLERATED_PREFIXES = (
"GPT-4",
"GPT-3",
"Claude",
"Mistral Large",
"Mistral Small",
"Mistral 7B",
"Ministral",
"Ollama",
"Llama",
"Custom engine",
)
# ---------------------------------------------------------------------------
# Helpers de parsing
# ---------------------------------------------------------------------------
def _read_readme() -> str:
return README_PATH.read_text(encoding="utf-8")
def _normalize_engine_name(name: str) -> str:
"""Normalise un nom de moteur en chemin de module candidat.
Exemples :
- ``"Tesseract 5"`` → ``"tesseract"``
- ``"Pero OCR"`` → ``"pero_ocr"``
- ``"Google Vision"`` → ``"google_vision"``
- ``"Azure Doc Intelligence"`` → ``"azure_doc_intel"``
"""
n = name.lower().strip()
# Retire emphasis markdown (**Tesseract 5**)
n = n.replace("**", "").strip()
# Retire les versions ("tesseract 5" → "tesseract")
n = re.sub(r"\s+\d+(\.\d+)*$", "", n)
# Mappings explicites (alias historiques)
aliases = {
"azure document intelligence": "azure_doc_intel",
"azure doc intelligence": "azure_doc_intel",
# Phase 3 du chantier post-rewrite : kraken/calamari sont
# listés dans le README avec leur nom commercial complet,
# mais leur module Python s'appelle juste ``kraken.py`` /
# ``calamari.py`` (cohérent avec ``pero_ocr.py`` qui ne
# s'appelle pas ``pero_ocr_htr.py``).
"kraken htr": "kraken",
"calamari ocr": "calamari",
}
if n in aliases:
return aliases[n]
# Default : remplace espaces par underscores
return n.replace(" ", "_").replace("-", "_")
def _has_engine_adapter(engine_name: str) -> bool:
"""Vrai si ``picarones/engines/{normalized}.py`` existe."""
candidate = _normalize_engine_name(engine_name)
return (ENGINES_DIR / f"{candidate}.py").exists()
def _parse_markdown_tables(text: str) -> list[dict]:
"""Parse toutes les tables Markdown du document.
Retourne une liste de dicts ``{"headers": [...], "rows": [[...], ...],
"raw_rows": [str, ...]}``. ``raw_rows`` conserve la ligne brute pour
permettre la détection des marqueurs ``<!-- doc-check: skip-... -->``.
"""
tables: list[dict] = []
lines = text.split("\n")
i = 0
while i < len(lines):
line = lines[i].strip()
# Heuristique : une table commence par une ligne d'en-tête en | ... |
# suivie par une ligne de séparation |---|...|
if line.startswith("|") and i + 1 < len(lines):
sep = lines[i + 1].strip()
if re.match(r"^\|[\s:|-]+\|$", sep):
headers = [h.strip() for h in line.strip("|").split("|")]
rows: list[list[str]] = []
raw_rows: list[str] = []
j = i + 2
while j < len(lines) and lines[j].strip().startswith("|"):
raw = lines[j]
cells = [c.strip() for c in raw.strip().strip("|").split("|")]
rows.append(cells)
raw_rows.append(raw)
j += 1
tables.append({"headers": headers, "rows": rows, "raw_rows": raw_rows})
i = j
continue
i += 1
return tables
def _has_skip_marker(raw_row: str, kind: str) -> bool:
"""Vrai si la ligne contient ``<!-- doc-check: skip-<kind> -->``."""
for match in SKIP_PATTERN.finditer(raw_row):
if match.group(1) == kind:
return True
return False
def _find_table_by_header(tables: list[dict], required_columns: set[str]) -> dict | None:
"""Retourne la première table dont les en-têtes contiennent ``required_columns``."""
for table in tables:
normalized = {h.lower().strip() for h in table["headers"]}
if required_columns.issubset(normalized):
return table
return None
# ---------------------------------------------------------------------------
# 1. Moteurs OCR listés (M-23)
# ---------------------------------------------------------------------------
def test_engines_table_present() -> None:
"""Le README doit contenir une table des moteurs supportés."""
tables = _parse_markdown_tables(_read_readme())
table = _find_table_by_header(tables, {"engine", "type"})
assert table is not None, (
"Aucune table 'Supported Engines' trouvée dans le README "
"(en-têtes 'Engine' + 'Type' attendus)."
)
assert len(table["rows"]) >= 1, "Table moteurs vide."
def test_listed_engines_have_adapter() -> None:
"""Tout moteur OCR listé dans le README doit avoir un adapter dans
``picarones/engines/``, sauf annotation explicite ``skip-engine``.
Tolérance : les LLMs/VLMs (GPT-4o, Claude, etc.) sont tolérés tant que
A13 (refonte README) n'a pas scindé le tableau en 'OCR engines' et
'LLM/VLM adapters'. La tolérance est pilotée par la frozenset
``NOT_OCR_ENGINES_TOLERATED`` ci-dessus.
"""
tables = _parse_markdown_tables(_read_readme())
table = _find_table_by_header(tables, {"engine", "type"})
assert table is not None, "Table moteurs absente (pré-requis : test_engines_table_present)"
missing: list[str] = []
for row, raw in zip(table["rows"], table["raw_rows"], strict=True):
if not row or not row[0].strip():
continue
engine_name = row[0].replace("**", "").strip()
# Skip marker explicite
if _has_skip_marker(raw, "engine"):
continue
# LLM/VLM tolérés (à scinder en A13). Comparaison sur préfixe
# pour accepter "GPT-4o (VLM)", "Claude Sonnet (VLM)", etc.
if any(engine_name.startswith(p) for p in NOT_OCR_ENGINES_TOLERATED_PREFIXES):
continue
if not _has_engine_adapter(engine_name):
missing.append(engine_name)
assert not missing, (
f"Moteurs annoncés dans le README sans adapter dans "
f"picarones/engines/ : {missing}. "
f"Soit créer l'adapter, soit retirer la ligne, soit annoter "
f"avec <!-- doc-check: skip-engine -->."
)
# ---------------------------------------------------------------------------
# 2. Commandes CLI (M-25)
# ---------------------------------------------------------------------------
def _real_cli_commands() -> set[str]:
"""Retourne l'ensemble des commandes effectivement exposées."""
from picarones.interfaces.cli import cli
return set(cli.commands.keys())
def test_listed_cli_commands_exist() -> None:
"""Toute commande ``picarones <X>`` listée dans le README doit exister."""
real = _real_cli_commands()
text = _read_readme()
tables = _parse_markdown_tables(text)
# Une table CLI a typiquement les colonnes "Command" + "Description".
table = _find_table_by_header(tables, {"command", "description"})
if table is None:
pytest.skip("Pas de tableau CLI explicite dans le README")
missing: list[str] = []
for row, raw in zip(table["rows"], table["raw_rows"], strict=True):
if not row or not row[0].strip():
continue
cell = row[0]
if _has_skip_marker(raw, "cli"):
continue
# Extraction robuste : "`picarones run`" → "run", "picarones import iiif" → "import"
m = re.search(r"picarones\s+([a-z][a-z_-]*)", cell)
if not m:
continue
cmd = m.group(1)
if cmd not in real:
missing.append(cmd)
assert not missing, (
f"Commandes annoncées dans le tableau CLI du README mais "
f"absentes de `picarones --help` : {missing}. "
f"Disponibles : {sorted(real)}."
)
# ---------------------------------------------------------------------------
# 3. Endpoints API web (M-26)
# ---------------------------------------------------------------------------
def _real_api_endpoints() -> set[str]:
"""Retourne l'ensemble des chemins exposés par l'app FastAPI."""
try:
from picarones.interfaces.web.app import app
except Exception as exc: # pragma: no cover — défense en profondeur
pytest.skip(f"FastAPI app non importable : {exc}")
spec = app.openapi()
return set(spec.get("paths", {}).keys())
def _normalize_path(path: str) -> str:
"""Normalise un path en remplaçant les variables ``{xxx}`` ou ``:xxx``
par des wildcards comparables."""
path = path.strip().strip("`")
# ``{job_id}`` et ``{filename}`` représentent la même chose côté code
return re.sub(r"\{[^}]+\}", "{}", path)
def test_listed_endpoints_exist() -> None:
"""Tout endpoint listé dans le README (avec son chemin commençant par
``/api/`` ou ``/`` ou ``/reports/``) doit exister dans l'API FastAPI."""
real = {_normalize_path(p) for p in _real_api_endpoints()}
text = _read_readme()
tables = _parse_markdown_tables(text)
# Trouver une table d'endpoints (en-têtes "Endpoint" + "Method").
table = _find_table_by_header(tables, {"endpoint", "method"})
if table is None:
pytest.skip("Pas de tableau API endpoints dans le README")
missing: list[str] = []
for row, raw in zip(table["rows"], table["raw_rows"], strict=True):
if not row or not row[0].strip():
continue
if _has_skip_marker(raw, "endpoint"):
continue
path = _normalize_path(row[0])
if not path.startswith("/"):
continue
if path not in real:
missing.append(path)
assert not missing, (
f"Endpoints annoncés dans le README mais absents de "
f"app.openapi()['paths'] : {missing}. "
f"Disponibles ({len(real)}) : {sorted(real)[:10]}…"
)
# ---------------------------------------------------------------------------
# 4. Compteur de tests — le README ne pin plus un nombre exact
# ---------------------------------------------------------------------------
#
# Historique : ce test lançait ``subprocess.run([..., "pytest",
# "--collect-only", ...])`` pour comparer le compteur cité au nombre
# réel. Pytest-dans-pytest avec ``--cov`` cause un deadlock sur le
# lock ``.coverage`` (le commentaire ``-p no:cacheprovider`` + ``--no-cov``
# documente déjà ce risque). La stratégie actuelle élimine la classe
# d'erreur : le README dit ``5000+ tests``, sans nombre figé, et le
# chiffre exact vit dans le badge CI.
#
# Ce test ne fait plus que vérifier qu'aucun compteur exact n'a été
# réintroduit en prose.
def test_readme_does_not_pin_exact_test_count() -> None:
"""Le README ne doit plus citer un nombre exact (``5150 tests``,
``5159 passed``, etc.). La formulation canonique est ``N+ tests``
(ex. ``5000+ tests``) pour absorber la dérive OS-dépendante du
compteur (4509 vs 4510 selon que tesseract est installé)."""
text = _read_readme()
# On accepte ``5000+ tests``, ``5000+ passed`` (avec ou sans
# caractères de mise en forme markdown autour). On refuse
# ``5150 tests``, ``~5150 tests``, ``5150 passed``.
forbidden_pattern = re.compile(
r"(?<!\+)\b(\d{4,5})\s+(?:tests|passed)\b",
re.IGNORECASE,
)
offenders = forbidden_pattern.findall(text)
assert not offenders, (
f"README cite des compteurs de tests exacts : {offenders}. "
"Reformuler en ``N+ tests`` (ex. ``5000+ tests``) — le chiffre "
"exact dérive selon l'OS / les binaires installés et vit dans "
"le badge CI."
)
# ---------------------------------------------------------------------------
# 5. Variables d'environnement (M-24)
# ---------------------------------------------------------------------------
def test_env_vars_with_adapter_or_marker() -> None:
"""Les variables ``AWS_*`` (et autres orphelines) ne doivent pas être
documentées dans le README sans adapter correspondant.
Vérification : pour ``AWS_*``, vérifier qu'un adapter
``picarones/engines/aws_*.py`` ou ``picarones/engines/textract*.py``
existe. Si non, la mention dans le README est un mensonge → fail.
"""
text = _read_readme()
if "AWS_ACCESS_KEY_ID" not in text and "AWS_SECRET_ACCESS_KEY" not in text:
# README propre, rien à vérifier.
return
# Si la mention est présente, elle doit être marquée skip ou un adapter
# AWS existant doit la justifier.
aws_adapters = list(ENGINES_DIR.glob("aws*.py")) + list(ENGINES_DIR.glob("textract*.py"))
if aws_adapters:
return # adapter existe
# Cas tolérance : la ligne est dans un commentaire HTML
# ``<!-- doc-check: skip-env -->``
skip_lines = [
line
for line in text.split("\n")
if "AWS_ACCESS_KEY_ID" in line and _has_skip_marker(line, "env")
]
assert skip_lines, (
"AWS_* environment variables documentées dans le README mais "
"aucun adapter Textract n'existe dans picarones/engines/. "
"Soit implémenter, soit retirer ces 3 lignes, soit annoter "
"avec <!-- doc-check: skip-env -->."
)
|