Picarones / tests /test_minimal_install.py
Claude
feat(sprint-H.2.c-d)!: suppression complète de adapters/legacy_engines/ et adapters/legacy_pipelines/
f54bb20 unverified
"""Sprint A14-S2 — A.I.0 P0 : ``import picarones`` doit marcher avec
seulement les dépendances obligatoires.
Avant ce sprint, l'import du package au top-level chaînait des
``import`` par effet de bord (cf. ``picarones/__init__.py:91`` :
``import picarones.measurements as _trigger_metric_registration``)
qui exigeaient au moment du chargement initial des modules
théoriquement optionnels. Conséquence : un ``pip install picarones``
sur un environnement où, par exemple, ``defusedxml`` n'était pas
résolu (Python 3.13 alpha, mirrors PyPI partiels, etc.) faisait
crasher tout import du package — y compris ``from picarones import
Document`` qui n'a logiquement pas besoin d'XML.
Ce module vérifie deux invariants critiques :
1. **Import OK avec seulement les deps obligatoires** —
l'API publique du Cercle 1 doit s'importer sans nécessiter
``[web]``, ``[ner]``, ``[stats]``, ``[pero]``, ``[hf]``, ``[llm]``,
``[ocr-cloud]``, ``[kraken]``.
2. **Les deps obligatoires sont effectivement déclarées** dans
``pyproject.toml`` (cohérence entre le code et la spec
d'installation).
Note d'environnement : ce test ne crée pas un venv vierge en
sous-processus (trop coûteux pour la CI à chaque commit). Il
vérifie ce qu'on peut vérifier dans le venv courant — la vraie
validation "venv neuf" est faite par la matrice CI (cf.
``.github/workflows/ci.yml``).
"""
from __future__ import annotations
import importlib
import importlib.util
import sys
from pathlib import Path
# ──────────────────────────────────────────────────────────────────────
# 1. Smoke test de l'API publique
# ──────────────────────────────────────────────────────────────────────
PUBLIC_API_NAMES = (
"Corpus",
"Document",
"TextGT",
"AltoGT",
"PageGT",
"EntitiesGT",
"ReadingOrderGT",
"load_corpus_from_directory",
"ArtifactType",
"BaseModule",
"BenchmarkResult",
"DocumentResult",
"EngineReport",
"MetricsResult",
"aggregate_metrics",
"DetectorRegistry",
"Fact",
"FactImportance",
"FactType",
"MetricSpec",
"compute_at_junction",
"register_metric",
"select_metrics",
)
def test_import_picarones_exposes_public_api() -> None:
"""Tous les noms documentés dans le ``__all__`` du package
racine doivent être effectivement importables."""
import picarones
for name in PUBLIC_API_NAMES:
assert hasattr(picarones, name), (
f"``picarones.{name}`` annoncé dans ``__all__`` mais absent "
"du namespace au moment de l'import."
)
def test_picarones_all_matches_imports() -> None:
"""``__all__`` ne doit pas mentir."""
import picarones
declared = set(picarones.__all__)
expected = set(PUBLIC_API_NAMES) | {"__version__", "__author__"}
missing = expected - declared
assert not missing, (
f"``__all__`` n'expose pas tous les noms attendus : {missing}"
)
def test_version_is_set() -> None:
"""``picarones.__version__`` doit être une string non vide."""
import picarones
assert isinstance(picarones.__version__, str)
assert picarones.__version__.strip() != ""
# ──────────────────────────────────────────────────────────────────────
# 2. Cohérence entre les imports top-level et pyproject.toml
# ──────────────────────────────────────────────────────────────────────
def _project_root() -> Path:
return Path(__file__).resolve().parents[1]
def _read_pyproject_dependencies() -> list[str]:
"""Liste des noms de package des deps obligatoires.
Volontairement permissif : on garde uniquement le nom (avant
``>=``, ``==``, ``[``, etc.) puisque c'est ce qui permet
``importlib.util.find_spec``. Les noms PyPI utilisent ``-``
mais les modules importés utilisent ``_`` (et ce n'est pas
toujours symétrique : ``Pillow`` → ``PIL``, ``pyyaml`` →
``yaml``). On gère explicitement le mapping ci-dessous.
"""
pyproject = _project_root() / "pyproject.toml"
text = pyproject.read_text(encoding="utf-8")
# Parser TOML léger : on cible juste le bloc ``dependencies = [...]``
# de [project]. Pour rester sans dépendance externe, on parse à la
# main une fois la section trouvée.
in_deps = False
out: list[str] = []
for line in text.splitlines():
stripped = line.strip()
if stripped.startswith("dependencies"):
in_deps = True
continue
if in_deps:
if stripped.startswith("]"):
break
if stripped.startswith("#") or not stripped:
continue
# `` "click>=8.1.0",`` → ``click``
raw = stripped.strip(",").strip().strip('"').strip("'")
# Coupe à la première occurrence d'un opérateur de version
# ou d'un crochet d'extra.
for sep in (">=", "==", "<=", ">", "<", "~=", "[", ";"):
idx = raw.find(sep)
if idx >= 0:
raw = raw[:idx]
break
raw = raw.strip()
if raw:
out.append(raw)
return out
# Mapping nom PyPI → nom du module Python à importer.
# Source : https://packaging.python.org/en/latest/discussions/...
# Ne lister que les paires asymétriques.
_NAME_OVERRIDES: dict[str, str] = {
"Pillow": "PIL",
"pyyaml": "yaml",
"PyYAML": "yaml",
"python-multipart": "multipart",
"pyaml": "yaml",
}
def _import_name(pypi_name: str) -> str:
return _NAME_OVERRIDES.get(pypi_name, pypi_name.replace("-", "_"))
def test_required_deps_are_importable() -> None:
"""Toutes les deps déclarées dans ``[project.dependencies]`` doivent
être effectivement installables/importables. Garde-fou contre une
typo ou un nom de package PyPI mal copié."""
declared = _read_pyproject_dependencies()
assert declared, (
"Aucune dépendance obligatoire trouvée dans pyproject.toml — "
"le parser maison s'est cassé sur le format actuel."
)
missing: list[tuple[str, str]] = []
for pypi in declared:
mod = _import_name(pypi)
if importlib.util.find_spec(mod) is None:
missing.append((pypi, mod))
assert not missing, (
"Deps obligatoires déclarées mais introuvables dans le venv "
"courant. En CI institutionnelle, c'est un échec dur — un "
"``pip install picarones`` produit un package qui crashera à "
f"l'import sur ces noms : {missing}. Vérifier le mapping "
"PyPI → module dans ``_NAME_OVERRIDES``."
)
def test_top_level_externals_are_declared() -> None:
"""Tout package externe chargé par ``import picarones`` doit être
listé dans ``[project.dependencies]``.
Garde-fou contre le scénario opposé : on ajoute un ``import foo``
quelque part dans ``picarones/__init__.py`` (ou dans un module
chargé par effet de bord depuis ``__init__.py``) sans déclarer
``foo`` dans ``pyproject.toml``. Sur un install propre, le
package crash.
"""
# Capture des modules chargés avant et après ``import picarones``.
before = set(sys.modules)
importlib.import_module("picarones")
after = set(sys.modules)
# On ne garde que les top-level (pas de ``foo.bar``) qui ne sont
# pas des modules picarones et qui ne sont pas stdlib.
stdlib_names = set(getattr(sys, "stdlib_module_names", ()))
candidates = {
m.split(".")[0] for m in (after - before)
if "." not in m
}
candidates -= {m for m in candidates if m.startswith("_")}
candidates -= stdlib_names
candidates -= {"picarones"}
# Modules implicitement amenés par d'autres déjà déclarés (ex :
# rapidfuzz vient avec jiwer ; pydantic_core vient avec pydantic ;
# cython_runtime vient avec rapidfuzz ; pyexpat est en stdlib mais
# pas toujours dans stdlib_module_names selon la version).
transitive_allowed = {
"rapidfuzz",
"cython_runtime",
"pyexpat",
"annotated_types",
"pydantic",
"pydantic_core",
"typing_extensions",
"typing_inspection",
"annotated_doc",
"tomli", # TOML stdlib uniquement à partir de 3.11 (tomllib)
"tomllib",
}
candidates -= transitive_allowed
declared = {_import_name(d) for d in _read_pyproject_dependencies()}
undeclared = candidates - declared
assert not undeclared, (
f"Modules externes chargés à ``import picarones`` mais non "
f"déclarés dans ``[project.dependencies]`` : {sorted(undeclared)}.\n"
"Soit ajouter ces deps à pyproject.toml, soit déplacer leur "
"import en lazy load (à l'intérieur d'une fonction qui n'est "
"pas appelée au top-level)."
)
# ──────────────────────────────────────────────────────────────────────
# 3. Garde-fou : pas de crash silencieux sur deps optionnelles absentes
# ──────────────────────────────────────────────────────────────────────
def test_optional_deps_not_required_at_top_level() -> None:
"""Les modules dépendant de deps optionnelles doivent s'importer
en mode dégradé silencieux quand ces deps manquent.
Exemple : ``picarones.engines.tesseract`` ne doit pas crasher
l'import si ``pytesseract`` n'est pas installé — il doit échouer
plus tard, au moment du ``run()``. Idem pour Pero, Mistral OCR,
Google Vision, Azure DI.
On vérifie ici que les modules existent et s'importent même
quand on n'a pas les engines installés.
"""
# Sprint H.2.d — chemins canoniques (les modules legacy
# ``picarones.adapters.legacy_engines.*`` ont été supprimés).
optional_engine_modules = (
"picarones.adapters.ocr.tesseract",
"picarones.adapters.ocr.pero_ocr",
"picarones.adapters.ocr.mistral_ocr",
"picarones.adapters.ocr.google_vision",
"picarones.adapters.ocr.azure_doc_intel",
)
failed: list[tuple[str, str]] = []
for mod_name in optional_engine_modules:
try:
importlib.import_module(mod_name)
except ImportError as exc:
failed.append((mod_name, str(exc)))
assert not failed, (
"Modules engines qui plantent à l'import simple — ils doivent "
"tomber en mode dégradé (warning + fallback) plutôt que de "
"lever ImportError au top-level. C'est ce qui permet à un "
f"installeur minimal d'utiliser le CLI : {failed}"
)