"""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.core.corpus — modèle Document/Corpus + GT multi-niveaux # ────────────────────────────────────────────────────────────────────────── class TestCorpusApi: @pytest.mark.parametrize("name", [ "Document", "Corpus", "GTLevel", "TextGT", "AltoGT", "PageGT", "EntitiesGT", "ReadingOrderGT", ]) def test_class_exists(self, name): _assert_class("picarones.core.corpus", name) def test_load_corpus_from_directory_exists(self): _assert_function("picarones.core.corpus", "load_corpus_from_directory") def test_gt_suffixes_constant(self): from picarones.core.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.core.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.core.modules — BaseModule + ArtifactType # ────────────────────────────────────────────────────────────────────────── class TestModulesApi: def test_artifact_type_values(self): from picarones.core.modules import ArtifactType names = {member.value for member in ArtifactType} # ``IMAGE`` + 5 niveaux GT assert names == {"image", "text", "alto", "page", "entities", "reading_order"} def test_basemodule_is_abstract(self): cls = _assert_class("picarones.core.modules", "BaseModule") # Doit avoir `process` abstrait assert "process" in cls.__abstractmethods__ or hasattr(cls, "process") def test_basemodule_class_attributes(self): from picarones.core.modules 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.core.results — modèles de résultats # ────────────────────────────────────────────────────────────────────────── class TestResultsApi: @pytest.mark.parametrize("name", [ "DocumentResult", "EngineReport", "BenchmarkResult", ]) def test_class_exists(self, name): _assert_class("picarones.core.results", 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. picarones.core.pipeline — banc d'essai pipelines # ────────────────────────────────────────────────────────────────────────── class TestPipelineRunnerApi: @pytest.mark.parametrize("name", [ "PipelineStep", "PipelineSpec", "StepResult", "PipelineResult", "PipelineRunner", ]) def test_class_exists(self, name): _assert_class("picarones.core.pipeline", name) class TestPipelineBenchmarkApi: @pytest.mark.parametrize("name", [ "StepAggregate", "PipelineBenchmarkResult", ]) def test_class_exists(self, name): _assert_class("picarones.measurements.pipeline_benchmark", name) @pytest.mark.parametrize("name", [ "default_initial_inputs", "run_pipeline_benchmark", ]) def test_function_exists(self, name): _assert_function("picarones.measurements.pipeline_benchmark", name) class TestPipelineComparisonApi: def test_pipeline_comparison_result(self): _assert_class( "picarones.measurements.pipeline_comparison", "PipelineComparisonResult", ) def test_compare_pipelines(self): _assert_function( "picarones.measurements.pipeline_comparison", "compare_pipelines", ) class TestPipelineSpecLoaderApi: def test_pipeline_spec_load_error(self): cls = _assert_class( "picarones.measurements.pipeline_spec_loader", "PipelineSpecLoadError", ) assert issubclass(cls, ValueError) @pytest.mark.parametrize("name", [ "load_pipeline_spec_from_yaml", "load_pipeline_spec_from_dict", "load_comparison_specs_from_yaml", "load_comparison_specs_from_dict", ]) def test_function_exists(self, name): _assert_function("picarones.measurements.pipeline_spec_loader", name) # ────────────────────────────────────────────────────────────────────────── # 7. picarones.core.metric_registry — registre typé # ────────────────────────────────────────────────────────────────────────── class TestMetricRegistryApi: def test_metric_spec_class(self): _assert_class("picarones.core.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.core.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.core.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.core.metric_hooks — profils + registre de hooks # ────────────────────────────────────────────────────────────────────────── 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.core 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.core.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.core.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.core.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 = { "corpus.py", "facts.py", "metric_hooks.py", "metric_registry.py", "modules.py", "pipeline.py", "results.py", } def test_cercle1_files_lean(self): from pathlib import Path repo = Path(__file__).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 path = Path(__file__).parent.parent / "docs" / "api-stable.md" assert path.exists(), "docs/api-stable.md manquant" content = path.read_text(encoding="utf-8") # Présence des 14 sections (1 par module) for module in [ "picarones.core.corpus", "picarones.core.modules", "picarones.core.results", "picarones.measurements.metrics", "picarones.measurements.runner", "picarones.core.pipeline", "picarones.measurements.pipeline_benchmark", "picarones.measurements.pipeline_comparison", "picarones.measurements.pipeline_spec_loader", "picarones.core.metric_registry", "picarones.core.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 / "docs" / "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