"""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: @pytest.mark.parametrize("name", [ "Document", "Corpus", "GTLevel", "TextGT", "AltoGT", "PageGT", "EntitiesGT", "ReadingOrderGT", ]) 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.evaluation.corpus import GTLevel, GT_SUFFIXES assert isinstance(GT_SUFFIXES, dict) # Chacun des 5 niveaux GT doit avoir un suffixe for level in GTLevel: assert level in GT_SUFFIXES, ( f"GT_SUFFIXES manque le niveau {level}" ) def test_gtlevel_values(self): from picarones.evaluation.corpus import GTLevel # Les 5 valeurs sont contractuelles — leur ordre/nom n'importe # pas, leur présence si. names = {member.value for member in GTLevel} assert names == {"text", "alto", "page", "entities", "reading_order"} # ────────────────────────────────────────────────────────────────────────── # 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, "execution_mode") 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: @pytest.mark.parametrize("name", [ "DocumentResult", "EngineReport", "BenchmarkResult", ]) def test_class_exists(self, name): _assert_class("picarones.evaluation.benchmark_result", name) # ────────────────────────────────────────────────────────────────────────── # 4. picarones.measurements.metrics — métriques de base # ────────────────────────────────────────────────────────────────────────── class TestMetricsApi: def test_metrics_result_class(self): _assert_class("picarones.measurements.metrics", "MetricsResult") @pytest.mark.parametrize("name", [ "compute_metrics", "aggregate_metrics", ]) def test_function_exists(self, name): _assert_function("picarones.measurements.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.measurements.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. picarones.measurements.runner — run_benchmark # ────────────────────────────────────────────────────────────────────────── class TestRunnerApi: def test_run_benchmark_exists(self): try: _assert_function("picarones.measurements.runner", "run_benchmark") except ImportError as exc: if "tqdm" in str(exc): pytest.skip("tqdm non installé en sandbox") raise def test_run_benchmark_keyword_args(self): """Les paramètres clés (corpus, engines, profile…) doivent rester accessibles. Ajout d'un argument requis = breaking change.""" try: from picarones.measurements.runner import run_benchmark except ImportError as exc: if "tqdm" in str(exc): pytest.skip("tqdm non installé") raise sig = inspect.signature(run_benchmark) params = sig.parameters # Arguments contractuels — leur présence est garantie for name in [ "corpus", "engines", "output_json", "show_progress", "char_exclude", "max_workers", "timeout_seconds", "profile", ]: assert name in params, ( f"run_benchmark : argument '{name}' a disparu (signature : {sig})" ) # ────────────────────────────────────────────────────────────────────────── # 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``). # ────────────────────────────────────────────────────────────────────────── # 7. picarones.evaluation.metric_registry — registre typé (canonique) # ────────────────────────────────────────────────────────────────────────── class TestMetricRegistryApi: def test_metric_spec_class(self): _assert_class("picarones.evaluation.metric_registry", "MetricSpec") @pytest.mark.parametrize("name", [ "register_metric", "get_metric", "all_metrics", "select_metrics", "compute_at_junction", ]) 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: @pytest.mark.parametrize("profile_name", [ "PROFILE_MINIMAL", "PROFILE_STANDARD", "PROFILE_PHILOLOGICAL", "PROFILE_DIAGNOSTICS", "PROFILE_ECONOMICS", "PROFILE_PIPELINE", "PROFILE_FULL", ]) 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 @pytest.mark.parametrize("name", [ "DocumentMetricHook", "CorpusMetricAggregator", ]) def test_class_exists(self, name): _assert_class("picarones.evaluation.metric_hooks", name) @pytest.mark.parametrize("name", [ "validate_profile", "register_document_metric", "register_corpus_aggregator", "select_document_hooks", "select_corpus_aggregators", "run_document_hooks", "run_corpus_aggregators", ]) def test_function_exists(self, name): _assert_function("picarones.evaluation.metric_hooks", name) # ────────────────────────────────────────────────────────────────────────── # 9. picarones.measurements.builtin_metrics — CER/WER/MER/WIL natifs # ────────────────────────────────────────────────────────────────────────── class TestBuiltinMetricsApi: @pytest.mark.parametrize("name", [ "cer", "wer", "mer", "wil", "text_preservation_after_reconstruction", ]) def test_function_exists(self, name): _assert_function("picarones.measurements.builtin_metrics", name) # ────────────────────────────────────────────────────────────────────────── # 10. picarones.measurements.alto_metrics — métriques (ALTO, ALTO) # ────────────────────────────────────────────────────────────────────────── class TestAltoMetricsApi: def test_extract_text_from_alto(self): _assert_function("picarones.measurements.alto_metrics", "extract_text_from_alto") @pytest.mark.parametrize("name", [ "alto_text_cer", "alto_text_wer", "alto_text_mer", "alto_text_wil", ]) def test_alto_metric_function(self, name): _assert_function("picarones.measurements.alto_metrics", name) # ────────────────────────────────────────────────────────────────────────── # 11. picarones.web.jobs — JobStore (utilisé par web/) # ────────────────────────────────────────────────────────────────────────── class TestJobsApi: def test_job_store(self): _assert_class("picarones.web.jobs", "JobStore") @pytest.mark.parametrize("name", [ "get_default_store", "reset_default_store", ]) def test_function_exists(self, name): _assert_function("picarones.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) for module in [ "picarones.evaluation.corpus", "picarones.domain.artifacts", "picarones.domain.module_protocol", "picarones.evaluation.benchmark_result", "picarones.measurements.metrics", "picarones.measurements.runner", "picarones.evaluation.metric_registry", "picarones.evaluation.metric_hooks", "picarones.measurements.builtin_metrics", "picarones.measurements.alto_metrics", "picarones.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