Picarones / tests /test_public_api.py
Claude
refactor(core): faire de core/ un cercle 1 strict, déplacer cercle 2 vers measurements/
979f3c3 unverified
Raw
History Blame
22 kB
"""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