Spaces:
Running
Running
| """Test de stabilité de l'API publique de Picarones (Cercle 1). | |
| Phase D du chantier de refonte en 3 cercles. Ce test est le **filet de | |
| sécurité contractuel** documenté dans :doc:`docs/api-stable.md` : il | |
| échoue dès qu'un nom listé dans le contrat de stabilité du Cercle 1 | |
| disparaît, change de type (class ↔ function), ou perd un argument | |
| attendu. | |
| Discipline | |
| ---------- | |
| Toute modification d'un test ici doit être accompagnée d'une mise à | |
| jour de ``docs/api-stable.md`` et **justifiée par une RFC** si elle | |
| casse la rétrocompat. Ce test est la traduction technique d'un | |
| engagement public. | |
| Si une PR doit ajouter un nom à l'API publique, suivre dans l'ordre : | |
| 1. Documenter le nom dans ``docs/api-stable.md``. | |
| 2. Ajouter le test correspondant ici. | |
| 3. Implémenter / exposer le nom. | |
| Si une PR doit casser un nom de l'API publique : | |
| 1. RFC + bump majeur (``2.0.0``). | |
| 2. Mise à jour de ``docs/api-stable.md`` (suppression). | |
| 3. Mise à jour des tests ici. | |
| Les noms historiques rétrocompat (Cercle 2 / Cercle 3 via shims) ne | |
| sont **pas** couverts par ce test — ils ont leurs propres tests dans | |
| ``tests/test_phaseA_migration.py``, ``test_phaseB_migration.py``, etc. | |
| """ | |
| from __future__ import annotations | |
| import importlib | |
| import inspect | |
| import pytest | |
| # ────────────────────────────────────────────────────────────────────────── | |
| # Helpers | |
| # ────────────────────────────────────────────────────────────────────────── | |
| def _get_attr(module_path: str, name: str): | |
| mod = importlib.import_module(module_path) | |
| assert hasattr(mod, name), ( | |
| f"API publique cassée : {module_path}.{name} a disparu" | |
| ) | |
| return getattr(mod, name) | |
| def _assert_class(module_path: str, name: str, *, abstract: bool = False): | |
| obj = _get_attr(module_path, name) | |
| assert inspect.isclass(obj), ( | |
| f"{module_path}.{name} : attendu class, obtenu {type(obj).__name__}" | |
| ) | |
| if abstract: | |
| assert inspect.isabstract(obj) or hasattr(obj, "__abstractmethods__"), ( | |
| f"{module_path}.{name} : attendu classe abstraite" | |
| ) | |
| return obj | |
| def _assert_function(module_path: str, name: str): | |
| obj = _get_attr(module_path, name) | |
| assert callable(obj), ( | |
| f"{module_path}.{name} : attendu callable, obtenu {type(obj).__name__}" | |
| ) | |
| return obj | |
| # ────────────────────────────────────────────────────────────────────────── | |
| # 1. picarones.evaluation.corpus — modèle Document/Corpus + GT multi-niveaux (canonique) | |
| # ────────────────────────────────────────────────────────────────────────── | |
| class TestCorpusApi: | |
| def test_class_exists(self, name): | |
| _assert_class("picarones.evaluation.corpus", name) | |
| def test_load_corpus_from_directory_exists(self): | |
| _assert_function("picarones.evaluation.corpus", "load_corpus_from_directory") | |
| def test_gt_suffixes_constant(self): | |
| from picarones.domain.artifacts import ArtifactType | |
| from picarones.evaluation.corpus import GT_SUFFIXES | |
| assert isinstance(GT_SUFFIXES, dict) | |
| # Chacun des 5 niveaux GT (ArtifactType) doit avoir un suffixe | |
| for level in ( | |
| ArtifactType.RAW_TEXT, | |
| ArtifactType.ALTO_XML, | |
| ArtifactType.PAGE_XML, | |
| ArtifactType.ENTITIES, | |
| ArtifactType.READING_ORDER, | |
| ): | |
| assert level in GT_SUFFIXES, ( | |
| f"GT_SUFFIXES manque le niveau {level}" | |
| ) | |
| # ────────────────────────────────────────────────────────────────────────── | |
| # 2. picarones.domain — BaseModule + ArtifactType (canoniques) | |
| # ────────────────────────────────────────────────────────────────────────── | |
| class TestModulesApi: | |
| def test_artifact_type_values(self): | |
| from picarones.domain.artifacts import ArtifactType | |
| names = {member.value for member in ArtifactType} | |
| # Phase 4-bis : ``ArtifactType`` canonique (``domain.artifacts``) | |
| # — 10 valeurs. L'ancien set legacy (``image, text, alto, page, | |
| # entities, reading_order``) reste accessible via les aliases | |
| # ``TEXT``/``ALTO``/``PAGE`` qui pointent vers les valeurs | |
| # canoniques ``raw_text``/``alto_xml``/``page_xml``. Les | |
| # aliases n'apparaissent pas dans cette itération (Python | |
| # masque les membres aliasés dans ``__members__`` itérable). | |
| assert names == { | |
| "image", | |
| "raw_text", | |
| "corrected_text", | |
| "alto_xml", | |
| "page_xml", | |
| "canonical_document", | |
| "entities", | |
| "reading_order", | |
| "alignment", | |
| "confidences", | |
| } | |
| def test_basemodule_is_abstract(self): | |
| cls = _assert_class("picarones.domain.module_protocol", "BaseModule") | |
| # Doit avoir `process` abstrait | |
| assert "process" in cls.__abstractmethods__ or hasattr(cls, "process") | |
| def test_basemodule_class_attributes(self): | |
| from picarones.domain.module_protocol import BaseModule | |
| # Contrat : ces attributs de classe sont lisibles depuis la base | |
| assert hasattr(BaseModule, "input_types") | |
| assert hasattr(BaseModule, "output_types") | |
| assert hasattr(BaseModule, "validate_inputs") | |
| assert hasattr(BaseModule, "validate_outputs") | |
| assert hasattr(BaseModule, "metadata") | |
| # ────────────────────────────────────────────────────────────────────────── | |
| # 3. picarones.evaluation.benchmark_result — modèles de résultats (canonique) | |
| # ────────────────────────────────────────────────────────────────────────── | |
| class TestResultsApi: | |
| def test_class_exists(self, name): | |
| _assert_class("picarones.evaluation.benchmark_result", name) | |
| # ────────────────────────────────────────────────────────────────────────── | |
| # 4. picarones.evaluation.metrics.text_metrics — métriques de base | |
| # ────────────────────────────────────────────────────────────────────────── | |
| class TestMetricsApi: | |
| def test_metrics_result_class(self): | |
| _assert_class("picarones.evaluation.metrics.text_metrics", "MetricsResult") | |
| def test_function_exists(self, name): | |
| _assert_function("picarones.evaluation.metrics.text_metrics", name) | |
| def test_compute_metrics_signature(self): | |
| """``compute_metrics(reference, hypothesis, char_exclude=None)`` est | |
| contractuel — les 2 premiers args sont positionnels, le 3ᵉ keyword.""" | |
| from picarones.evaluation.metrics.text_metrics import compute_metrics | |
| sig = inspect.signature(compute_metrics) | |
| params = list(sig.parameters.values()) | |
| # Au moins 2 paramètres positionnels (reference, hypothesis) | |
| positional = [p for p in params | |
| if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD) | |
| and p.default is p.empty] | |
| assert len(positional) >= 2, ( | |
| f"compute_metrics doit accepter >= 2 args positionnels — " | |
| f"signature actuelle : {sig}" | |
| ) | |
| # ────────────────────────────────────────────────────────────────────────── | |
| # 5. (anciennement) ``picarones.app.services.benchmark_runner`` — | |
| # supprimé en Phase B3-final (mai 2026, migration Option B). | |
| # ────────────────────────────────────────────────────────────────────────── | |
| # Le module ``benchmark_runner.py`` portait l'entry point legacy | |
| # ``run_benchmark_via_service`` qui a été remplacé par | |
| # ``picarones.RunOrchestrator`` (consommant un ``RunSpec`` Pydantic | |
| # ou des objets domain pré-construits via ``execute_preset()``). | |
| # Le contract test du legacy a été supprimé avec le module. Voir | |
| # ``TestRunOrchestratorApi`` ci-dessous pour le contrat de | |
| # l'entry point canonique actuel. | |
| # ────────────────────────────────────────────────────────────────────────── | |
| # 6. (anciennement) ``picarones.pipeline.legacy_*`` — supprimé en Phase 7.D | |
| # ────────────────────────────────────────────────────────────────────────── | |
| # Les modules ``pipeline.legacy_runner``, ``legacy_pipeline_benchmark``, | |
| # ``legacy_pipeline_comparison`` et ``measurements.pipeline_spec_loader`` | |
| # ont été supprimés en Phase 7.D (mai 2026). L'API canonique vit dans | |
| # ``picarones.pipeline.executor`` (``PipelineExecutor``) et | |
| # ``picarones.domain.pipeline_spec`` (``PipelineSpec``, ``PipelineStep``). | |
| # ────────────────────────────────────────────────────────────────────────── | |
| # 6.bis. picarones.app.services — RunOrchestrator (Phase B3 migration Option B) | |
| # ────────────────────────────────────────────────────────────────────────── | |
| class TestRunOrchestratorApi: | |
| """Phase B3 — l'entry-point canonique pour lancer un benchmark est | |
| désormais ``picarones.RunOrchestrator`` (consomme un ``RunSpec``). | |
| ``run_benchmark_via_service`` reste exporté mais émet une | |
| ``DeprecationWarning`` à l'appel. Retrait prévu Phase B8. | |
| """ | |
| def test_run_orchestrator_class_exposed_at_root(self): | |
| """``RunOrchestrator`` est accessible depuis le namespace racine.""" | |
| import picarones | |
| assert hasattr(picarones, "RunOrchestrator"), ( | |
| "RunOrchestrator devrait être exporté depuis picarones (Phase B3)" | |
| ) | |
| from picarones import RunOrchestrator | |
| assert inspect.isclass(RunOrchestrator) | |
| def test_run_spec_class_exposed_at_root(self): | |
| """``RunSpec`` Pydantic est accessible depuis le namespace racine.""" | |
| import picarones | |
| assert hasattr(picarones, "RunSpec") | |
| from picarones import RunSpec | |
| assert inspect.isclass(RunSpec) | |
| def test_all_new_exports_present(self, name): | |
| """Les 5 symboles ajoutés en B3 sont tous dans __all__.""" | |
| import picarones | |
| assert name in picarones.__all__, ( | |
| f"Phase B3 — '{name}' devrait être dans picarones.__all__" | |
| ) | |
| def test_prepare_preset_args_exposed_at_root(self): | |
| """Phase B3-final — ``prepare_preset_args`` est l'API | |
| publique pour les callers Python qui instancient leurs adapters | |
| en mémoire (par opposition au chargement YAML via ``RunSpec``). | |
| """ | |
| from picarones.app.services import ( | |
| PresetArgs, | |
| prepare_preset_args, | |
| run_result_to_benchmark_result, | |
| ) | |
| assert callable(prepare_preset_args) | |
| assert callable(run_result_to_benchmark_result) | |
| assert inspect.isclass(PresetArgs) | |
| # ────────────────────────────────────────────────────────────────────────── | |
| # 7. picarones.evaluation.metric_registry — registre typé (canonique) | |
| # ────────────────────────────────────────────────────────────────────────── | |
| class TestMetricRegistryApi: | |
| def test_metric_spec_class(self): | |
| _assert_class("picarones.evaluation.metric_registry", "MetricSpec") | |
| def test_function_exists(self, name): | |
| _assert_function("picarones.evaluation.metric_registry", name) | |
| def test_register_metric_keyword_only(self): | |
| """``register_metric`` est exclusivement keyword-only sur ``name``, | |
| ``input_types`` etc. — décorateur factory.""" | |
| from picarones.evaluation.metric_registry import register_metric | |
| sig = inspect.signature(register_metric) | |
| for name in ["name", "input_types", "description"]: | |
| assert name in sig.parameters, ( | |
| f"register_metric : keyword '{name}' manquant" | |
| ) | |
| # ────────────────────────────────────────────────────────────────────────── | |
| # 8. picarones.evaluation.metric_hooks — profils + registre de hooks (canonique) | |
| # ────────────────────────────────────────────────────────────────────────── | |
| class TestMetricHooksApi: | |
| def test_profile_constant_exists(self, profile_name): | |
| from picarones.evaluation import metric_hooks | |
| assert hasattr(metric_hooks, profile_name), ( | |
| f"Profil {profile_name} disparu" | |
| ) | |
| assert isinstance(getattr(metric_hooks, profile_name), str) | |
| def test_known_profiles_set(self): | |
| from picarones.evaluation.metric_hooks import KNOWN_PROFILES | |
| assert isinstance(KNOWN_PROFILES, frozenset) | |
| # Les 7 profils contractuels | |
| assert len(KNOWN_PROFILES) == 7 | |
| def test_class_exists(self, name): | |
| _assert_class("picarones.evaluation.metric_hooks", name) | |
| def test_function_exists(self, name): | |
| _assert_function("picarones.evaluation.metric_hooks", name) | |
| # ────────────────────────────────────────────────────────────────────────── | |
| # 9. picarones.evaluation.metrics.builtin_metrics — CER/WER/MER/WIL natifs | |
| # ────────────────────────────────────────────────────────────────────────── | |
| class TestBuiltinMetricsApi: | |
| def test_function_exists(self, name): | |
| _assert_function("picarones.evaluation.metrics.builtin_metrics", name) | |
| # ────────────────────────────────────────────────────────────────────────── | |
| # 10. picarones.evaluation.metrics.alto_metrics — métriques (ALTO, ALTO) | |
| # ────────────────────────────────────────────────────────────────────────── | |
| class TestAltoMetricsApi: | |
| def test_extract_text_from_alto(self): | |
| _assert_function("picarones.evaluation.metrics.alto_metrics", "extract_text_from_alto") | |
| def test_alto_metric_function(self, name): | |
| _assert_function("picarones.evaluation.metrics.alto_metrics", name) | |
| # ────────────────────────────────────────────────────────────────────────── | |
| # 11. picarones.interfaces.web.jobs — JobStore (utilisé par web/) | |
| # ────────────────────────────────────────────────────────────────────────── | |
| class TestJobsApi: | |
| def test_job_store(self): | |
| _assert_class("picarones.interfaces.web.jobs", "JobStore") | |
| def test_function_exists(self, name): | |
| _assert_function("picarones.interfaces.web.jobs", name) | |
| # ────────────────────────────────────────────────────────────────────────── | |
| # 12. Anti-régression : aucune fuite de Cercle 2/3 dans le Cercle 1 | |
| # ────────────────────────────────────────────────────────────────────────── | |
| class TestCercle1IsLean: | |
| """``picarones/core/`` ne doit contenir que les modules Cercle 1 réels | |
| (les autres sont des shims). Ce test garde-fou empêche un module | |
| métrique d'être réintroduit dans le cœur sans RFC.""" | |
| # Modules Cercle 1 — abstractions pures (corpus, contrats, registres). | |
| # Tout module avec de la logique métier (calcul, orchestration) | |
| # appartient au Cercle 2 (``measurements/``) ou au Cercle 3 | |
| # (``extras/``, ``report/``). | |
| EXPECTED_CERCLE1: set[str] = set() | |
| # Phase 1 du retrait du legacy a déplacé `facts.py`, | |
| # `diff_utils.py` et `xml_utils.py` vers leurs canoniques | |
| # (`domain/facts.py`, `evaluation/_diff_utils.py`, | |
| # `formats/_xml_utils.py`). Les fichiers `core/X.py` | |
| # restent comme shims re-export avec DeprecationWarning | |
| # (< 30 lignes), donc ne comptent plus comme "real_modules" | |
| # au sens de ce test. | |
| # Phase 4-bis a fait pareil pour `modules.py` (canonique : | |
| # `domain/module_protocol.py` + `domain/artifacts.py`). | |
| # Phase 4-ter a fait pareil pour `metric_registry.py`, | |
| # `metric_hooks.py` (canonique : `evaluation/metric_*.py`), | |
| # `metrics.py` (canonique : `evaluation/metric_result.py`) | |
| # et `results.py` (canonique : | |
| # `evaluation/benchmark_result.py`). | |
| # Phase 4-quater a fait pareil pour `corpus.py` | |
| # (canonique : `evaluation/corpus.py`). | |
| # Phase 5.C.batch7 a fait pareil pour `pipeline.py` | |
| # (canonique : `evaluation/pipeline.py`). Désormais | |
| # ``core/`` ne contient plus que des shims < 30 lignes. | |
| def test_cercle1_files_lean(self): | |
| from pathlib import Path | |
| repo = Path(__file__).parent.parent.parent | |
| core_dir = repo / "picarones" / "core" | |
| real_modules = set() | |
| for path in core_dir.glob("*.py"): | |
| content = path.read_text(encoding="utf-8") | |
| n_lines = len( | |
| [line for line in content.splitlines() if line.strip()], | |
| ) | |
| # Un shim a < 30 lignes ; un module Cercle 1 a > 30 lignes | |
| if n_lines > 30: | |
| real_modules.add(path.name) | |
| unexpected = real_modules - self.EXPECTED_CERCLE1 | |
| assert not unexpected, ( | |
| f"Modules non-Cercle 1 réintroduits dans core/ : {unexpected}. " | |
| "Soit les déplacer dans measurements/ (Cercle 2) ou extras/ " | |
| "(Cercle 3), soit ajouter à EXPECTED_CERCLE1 + api-stable.md " | |
| "via RFC." | |
| ) | |
| missing = self.EXPECTED_CERCLE1 - real_modules | |
| assert not missing, ( | |
| f"Modules Cercle 1 manquants : {missing}. Restaurer ou retirer " | |
| "de EXPECTED_CERCLE1." | |
| ) | |
| # ────────────────────────────────────────────────────────────────────────── | |
| # 13. Doc api-stable.md présente et complète | |
| # ────────────────────────────────────────────────────────────────────────── | |
| class TestApiStableDoc: | |
| def test_doc_exists(self): | |
| from pathlib import Path | |
| # S60 — la doc a migré sous ``docs/reference/`` (Diataxis). | |
| path = ( | |
| Path(__file__).parent.parent.parent | |
| / "docs" | |
| / "reference" | |
| / "api-stable.md" | |
| ) | |
| assert path.exists(), "docs/reference/api-stable.md manquant" | |
| content = path.read_text(encoding="utf-8") | |
| # Présence des sections (1 par module canonique). | |
| # Phase B3-final (mai 2026) — ``picarones.app.services.benchmark_runner`` | |
| # supprimé après la migration Option B ; remplacé dans la | |
| # liste par ``picarones.app.services`` (entry point moderne | |
| # via RunOrchestrator + prepare_preset_args). | |
| for module in [ | |
| "picarones.evaluation.corpus", | |
| "picarones.domain.artifacts", | |
| "picarones.domain.module_protocol", | |
| "picarones.evaluation.benchmark_result", | |
| "picarones.evaluation.metrics.text_metrics", | |
| "picarones.app.services", | |
| "picarones.evaluation.metric_registry", | |
| "picarones.evaluation.metric_hooks", | |
| "picarones.evaluation.metrics.builtin_metrics", | |
| "picarones.evaluation.metrics.alto_metrics", | |
| "picarones.interfaces.web.jobs", | |
| ]: | |
| assert module in content, ( | |
| f"docs/api-stable.md ne mentionne pas {module}" | |
| ) | |
| def test_doc_mentions_stability_policy(self): | |
| from pathlib import Path | |
| path = ( | |
| Path(__file__).parent.parent.parent | |
| / "docs" | |
| / "reference" | |
| / "api-stable.md" | |
| ) | |
| content = path.read_text(encoding="utf-8") | |
| # Les sections clés du contrat | |
| assert "Politique de stabilité" in content | |
| assert "Ce que nous garantissons" in content | |
| assert "Bump majeur" in content | |