Spaces:
Sleeping
Sleeping
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}" | |
| ) | |