File size: 11,416 Bytes
1b4c2d1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
"""Sprint A14-S2 — A.I.0 P0 : ``import picarones`` doit marcher avec
seulement les dépendances obligatoires.

Avant ce sprint, l'import du package au top-level chaînait des
``import`` par effet de bord (cf. ``picarones/__init__.py:91`` :
``import picarones.measurements as _trigger_metric_registration``)
qui exigeaient au moment du chargement initial des modules
théoriquement optionnels.  Conséquence : un ``pip install picarones``
sur un environnement où, par exemple, ``defusedxml`` n'était pas
résolu (Python 3.13 alpha, mirrors PyPI partiels, etc.) faisait
crasher tout import du package — y compris ``from picarones import
Document`` qui n'a logiquement pas besoin d'XML.

Ce module vérifie deux invariants critiques :

1. **Import OK avec seulement les deps obligatoires** —
   l'API publique du Cercle 1 doit s'importer sans nécessiter
   ``[web]``, ``[ner]``, ``[stats]``, ``[pero]``, ``[hf]``, ``[llm]``,
   ``[ocr-cloud]``, ``[kraken]``.

2. **Les deps obligatoires sont effectivement déclarées** dans
   ``pyproject.toml`` (cohérence entre le code et la spec
   d'installation).

Note d'environnement : ce test ne crée pas un venv vierge en
sous-processus (trop coûteux pour la CI à chaque commit).  Il
vérifie ce qu'on peut vérifier dans le venv courant — la vraie
validation "venv neuf" est faite par la matrice CI (cf.
``.github/workflows/ci.yml``).
"""

from __future__ import annotations

import importlib
import importlib.util
import sys
from pathlib import Path



# ──────────────────────────────────────────────────────────────────────
# 1. Smoke test de l'API publique
# ──────────────────────────────────────────────────────────────────────


PUBLIC_API_NAMES = (
    "Corpus",
    "Document",
    "GTLevel",
    "TextGT",
    "AltoGT",
    "PageGT",
    "EntitiesGT",
    "ReadingOrderGT",
    "load_corpus_from_directory",
    "ArtifactType",
    "BaseModule",
    "BenchmarkResult",
    "DocumentResult",
    "EngineReport",
    "MetricsResult",
    "aggregate_metrics",
    "DetectorRegistry",
    "Fact",
    "FactImportance",
    "FactType",
    "PipelineResult",
    "PipelineRunner",
    "PipelineSpec",
    "PipelineStep",
    "StepResult",
    "MetricSpec",
    "compute_at_junction",
    "register_metric",
    "select_metrics",
)


def test_import_picarones_exposes_public_api() -> None:
    """Tous les noms documentés dans le ``__all__`` du package
    racine doivent être effectivement importables."""
    import picarones

    for name in PUBLIC_API_NAMES:
        assert hasattr(picarones, name), (
            f"``picarones.{name}`` annoncé dans ``__all__`` mais absent "
            "du namespace au moment de l'import."
        )


def test_picarones_all_matches_imports() -> None:
    """``__all__`` ne doit pas mentir."""
    import picarones

    declared = set(picarones.__all__)
    expected = set(PUBLIC_API_NAMES) | {"__version__", "__author__"}
    missing = expected - declared
    assert not missing, (
        f"``__all__`` n'expose pas tous les noms attendus : {missing}"
    )


def test_version_is_set() -> None:
    """``picarones.__version__`` doit être une string non vide."""
    import picarones

    assert isinstance(picarones.__version__, str)
    assert picarones.__version__.strip() != ""


# ──────────────────────────────────────────────────────────────────────
# 2. Cohérence entre les imports top-level et pyproject.toml
# ──────────────────────────────────────────────────────────────────────


def _project_root() -> Path:
    return Path(__file__).resolve().parents[1]


def _read_pyproject_dependencies() -> list[str]:
    """Liste des noms de package des deps obligatoires.

    Volontairement permissif : on garde uniquement le nom (avant
    ``>=``, ``==``, ``[``, etc.) puisque c'est ce qui permet
    ``importlib.util.find_spec``.  Les noms PyPI utilisent ``-``
    mais les modules importés utilisent ``_`` (et ce n'est pas
    toujours symétrique : ``Pillow`` → ``PIL``, ``pyyaml`` →
    ``yaml``).  On gère explicitement le mapping ci-dessous.
    """
    pyproject = _project_root() / "pyproject.toml"
    text = pyproject.read_text(encoding="utf-8")
    # Parser TOML léger : on cible juste le bloc ``dependencies = [...]``
    # de [project].  Pour rester sans dépendance externe, on parse à la
    # main une fois la section trouvée.
    in_deps = False
    out: list[str] = []
    for line in text.splitlines():
        stripped = line.strip()
        if stripped.startswith("dependencies"):
            in_deps = True
            continue
        if in_deps:
            if stripped.startswith("]"):
                break
            if stripped.startswith("#") or not stripped:
                continue
            # ``    "click>=8.1.0",``  →  ``click``
            raw = stripped.strip(",").strip().strip('"').strip("'")
            # Coupe à la première occurrence d'un opérateur de version
            # ou d'un crochet d'extra.
            for sep in (">=", "==", "<=", ">", "<", "~=", "[", ";"):
                idx = raw.find(sep)
                if idx >= 0:
                    raw = raw[:idx]
                    break
            raw = raw.strip()
            if raw:
                out.append(raw)
    return out


# Mapping nom PyPI → nom du module Python à importer.
# Source : https://packaging.python.org/en/latest/discussions/...
# Ne lister que les paires asymétriques.
_NAME_OVERRIDES: dict[str, str] = {
    "Pillow": "PIL",
    "pyyaml": "yaml",
    "PyYAML": "yaml",
    "python-multipart": "multipart",
    "pyaml": "yaml",
}


def _import_name(pypi_name: str) -> str:
    return _NAME_OVERRIDES.get(pypi_name, pypi_name.replace("-", "_"))


def test_required_deps_are_importable() -> None:
    """Toutes les deps déclarées dans ``[project.dependencies]`` doivent
    être effectivement installables/importables.  Garde-fou contre une
    typo ou un nom de package PyPI mal copié."""
    declared = _read_pyproject_dependencies()
    assert declared, (
        "Aucune dépendance obligatoire trouvée dans pyproject.toml — "
        "le parser maison s'est cassé sur le format actuel."
    )
    missing: list[tuple[str, str]] = []
    for pypi in declared:
        mod = _import_name(pypi)
        if importlib.util.find_spec(mod) is None:
            missing.append((pypi, mod))
    assert not missing, (
        "Deps obligatoires déclarées mais introuvables dans le venv "
        "courant.  En CI institutionnelle, c'est un échec dur — un "
        "``pip install picarones`` produit un package qui crashera à "
        f"l'import sur ces noms : {missing}.  Vérifier le mapping "
        "PyPI → module dans ``_NAME_OVERRIDES``."
    )


def test_top_level_externals_are_declared() -> None:
    """Tout package externe chargé par ``import picarones`` doit être
    listé dans ``[project.dependencies]``.

    Garde-fou contre le scénario opposé : on ajoute un ``import foo``
    quelque part dans ``picarones/__init__.py`` (ou dans un module
    chargé par effet de bord depuis ``__init__.py``) sans déclarer
    ``foo`` dans ``pyproject.toml``.  Sur un install propre, le
    package crash.
    """
    # Capture des modules chargés avant et après ``import picarones``.
    before = set(sys.modules)
    importlib.import_module("picarones")
    after = set(sys.modules)

    # On ne garde que les top-level (pas de ``foo.bar``) qui ne sont
    # pas des modules picarones et qui ne sont pas stdlib.
    stdlib_names = set(getattr(sys, "stdlib_module_names", ()))
    candidates = {
        m.split(".")[0] for m in (after - before)
        if "." not in m
    }
    candidates -= {m for m in candidates if m.startswith("_")}
    candidates -= stdlib_names
    candidates -= {"picarones"}
    # Modules implicitement amenés par d'autres déjà déclarés (ex :
    # rapidfuzz vient avec jiwer ; pydantic_core vient avec pydantic ;
    # cython_runtime vient avec rapidfuzz ; pyexpat est en stdlib mais
    # pas toujours dans stdlib_module_names selon la version).
    transitive_allowed = {
        "rapidfuzz",
        "cython_runtime",
        "pyexpat",
        "annotated_types",
        "pydantic",
        "pydantic_core",
        "typing_extensions",
        "typing_inspection",
        "annotated_doc",
        "tomli",  # TOML stdlib uniquement à partir de 3.11 (tomllib)
        "tomllib",
    }
    candidates -= transitive_allowed

    declared = {_import_name(d) for d in _read_pyproject_dependencies()}

    undeclared = candidates - declared
    assert not undeclared, (
        f"Modules externes chargés à ``import picarones`` mais non "
        f"déclarés dans ``[project.dependencies]`` : {sorted(undeclared)}.\n"
        "Soit ajouter ces deps à pyproject.toml, soit déplacer leur "
        "import en lazy load (à l'intérieur d'une fonction qui n'est "
        "pas appelée au top-level)."
    )


# ──────────────────────────────────────────────────────────────────────
# 3. Garde-fou : pas de crash silencieux sur deps optionnelles absentes
# ──────────────────────────────────────────────────────────────────────


def test_optional_deps_not_required_at_top_level() -> None:
    """Les modules dépendant de deps optionnelles doivent s'importer
    en mode dégradé silencieux quand ces deps manquent.

    Exemple : ``picarones.engines.tesseract`` ne doit pas crasher
    l'import si ``pytesseract`` n'est pas installé — il doit échouer
    plus tard, au moment du ``run()``.  Idem pour Pero, Mistral OCR,
    Google Vision, Azure DI.

    On vérifie ici que les modules existent et s'importent même
    quand on n'a pas les engines installés.
    """
    # Liste des modules engines qu'on doit pouvoir au moins charger
    # (pas exécuter) sans planter.
    optional_engine_modules = (
        "picarones.engines.tesseract",
        "picarones.engines.pero_ocr",
        "picarones.engines.mistral_ocr",
        "picarones.engines.google_vision",
        "picarones.engines.azure_doc_intel",
    )
    failed: list[tuple[str, str]] = []
    for mod_name in optional_engine_modules:
        try:
            importlib.import_module(mod_name)
        except ImportError as exc:
            failed.append((mod_name, str(exc)))
    assert not failed, (
        "Modules engines qui plantent à l'import simple — ils doivent "
        "tomber en mode dégradé (warning + fallback) plutôt que de "
        "lever ImportError au top-level.  C'est ce qui permet à un "
        f"installeur minimal d'utiliser le CLI : {failed}"
    )