"""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 ````. 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 ```` à 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 : #: ````, ``skip-cli``, ``skip-endpoint``. SKIP_PATTERN = re.compile(r"") #: 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 ````. """ 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 ````.""" 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 ." ) # --------------------------------------------------------------------------- # 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 `` 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"(? 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 # ```` 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 ." )