Spaces:
Running
Running
Claude
refactor(core): faire de core/ un cercle 1 strict, déplacer cercle 2 vers measurements/
979f3c3 unverified | """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: | |
| 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: | |
| 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") | |
| 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: | |
| def test_class_exists(self, name): | |
| _assert_class("picarones.core.pipeline", name) | |
| class TestPipelineBenchmarkApi: | |
| def test_class_exists(self, name): | |
| _assert_class("picarones.measurements.pipeline_benchmark", name) | |
| 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) | |
| 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") | |
| 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: | |
| 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 | |
| def test_class_exists(self, name): | |
| _assert_class("picarones.core.metric_hooks", name) | |
| 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: | |
| 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") | |
| 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") | |
| 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 | |