Claude commited on
Commit
c602474
·
unverified ·
1 Parent(s): 74646e0

feat(sprint-H.2.b): factory canonique ocr_adapter_from_name

Browse files

Sprint H.2.b du plan v2.0 — première brique de la migration des
callers legacy_engines vers les adapters canoniques.

Pourquoi
--------
``picarones.adapters.legacy_engines.factory.engine_from_name``
retourne des ``BaseOCREngine`` (legacy, ``run(image_path)``)
qu'il faut wrapper via ``LegacyOCREngineExecutor`` avant de les
brancher au ``PipelineExecutor``. La factory canonique
retourne directement des ``BaseOCRAdapter`` qui implémentent le
protocole ``StepExecutor`` natif — pas de wrapping nécessaire.

Cette factory est le building block qui permettra la migration
progressive des callers (CLI/web ``_legacy/``,
``_legacy_runner_adapter``) sans toucher la classe ``BaseOCREngine``
elle-même (qui sera supprimée en H.2.d).

Modifications
-------------
- ``picarones/adapters/ocr/factory.py`` (nouveau, ~175 LOC) :
``ocr_adapter_from_name(name, **kwargs) → BaseOCRAdapter``.
Supporte les 6 adapters canoniques (tesseract, pero_ocr,
mistral_ocr, google_vision, azure_doc_intel, precomputed)
avec alias courts (``tess``, ``pero``, ``mistral``,
``google``/``gv``, ``azure``/``adi``). Insensible à la casse.
- ``picarones/adapters/ocr/__init__.py`` : exporte
``ocr_adapter_from_name``.
- ``picarones/adapters/legacy_engines/factory.py`` : docstring
mise à jour pour pointer vers la canonique et préciser le
calendrier de retrait (H.2.d avec ``BaseOCREngine``).

Tests
-----
- ``tests/adapters/ocr/test_factory.py`` (nouveau, 13 tests) :
- ``TestTesseract`` (6) : nom canonique, alias ``tess``,
casse normalisée, kwargs propagés, faute de frappe lève
``TypeError`` (pas masqué), psm invalide lève
``OCRAdapterError``.
- ``TestPrecomputed`` (1) : sans dep optionnelle.
- ``TestCloudAdapters`` (3) : mistral/google/azure
instanciables sans credentials (résolution paresseuse à
``execute()``).
- ``TestPeroOCR`` (1) : tolérant à l'absence de ``pero-ocr``
avec message d'erreur explicite.
- ``TestUnknownName`` (2) : nom inconnu liste les supportés
+ alias ; nom vide lève.

Comparaison avec la factory legacy
----------------------------------
| Aspect | legacy_engines.engine_from_name | ocr.ocr_adapter_from_name |
|--------|--------------------------------|---------------------------|
| Retour | ``BaseOCREngine`` (legacy) | ``BaseOCRAdapter`` (canonique) |
| Protocole | ``run(image_path) → EngineResult`` | ``execute(inputs, params, ctx)`` |
| Wrapping | ``LegacyOCREngineExecutor`` requis | ✅ direct |
| Adapters | tesseract, pero_ocr | tesseract, pero_ocr, mistral_ocr, google_vision, azure_doc_intel, precomputed |
| Alias | aucun | tess, pero, mistral, google/gv, azure/adi |
| kwargs | ``lang``, ``psm`` (positionnels) | tous les kwargs du constructeur |

Tests : 4663 passed, 9 skipped, 24 deselected.

Reste pour v2.0
---------------
- H.2.b suite : migrer les callers de ``engine_from_name`` (CLI
``_workflows.py``, ``_robustness.py`` ; web
``benchmark_utils.py``) à utiliser ``ocr_adapter_from_name``.
- H.2.c : suppression ``OCRLLMPipeline`` (callers basculent à
``make_ocr_llm_pipeline_spec`` directement).
- H.2.d : suppression ``BaseOCREngine`` + ``adapters/legacy_engines/``.
- H.4 : refonte interfaces/{cli,web}/_legacy/.
- H.6 : bump version + tag v2.0.0.

https://claude.ai/code/session_01NxyVKqg2SowXLZdM4H1ZDE

CLAUDE.md CHANGED
@@ -123,7 +123,7 @@ picarones/
123
 
124
  ## État des tests et bugs historiques
125
 
126
- `pytest tests/` → **4680 passed, 12 skipped, 8 deselected, 0 failed**
127
  (post-S59). Les deselected sont les markers `live` (5 tests d'intégration
128
  contre vraie API/binaire) + `network` (3 tests qui hit le réseau réel),
129
  opt-in en local via `pytest -m live` ou `pytest -m network`. Le
@@ -252,7 +252,7 @@ Résumé express :
252
 
253
  1. `git branch --show-current` → `claude/repo-analysis-cukvm`.
254
  2. `git status` → working tree clean.
255
- 3. `pytest tests/ -q --no-header --tb=line` → 4680 passed.
256
  4. `git log -1 --format=%B` → décrit la prochaine sub-phase.
257
 
258
  **Règles d'architecture critiques** (apprises à la dure) :
@@ -340,7 +340,7 @@ détecte, arbitre, rend.
340
  ## Contexte développement
341
 
342
  - **Environnement** : GitHub Codespaces, Python 3.11+
343
- - **Tests** : `pytest tests/ -q` → 4680 passed, 12 skipped, 24
344
  deselected, 0 failed (au moment de la pause de session).
345
  - **Plan d'évolution actif** : [`docs/roadmap/evolution-2026.md`](docs/roadmap/evolution-2026.md).
346
  - **Plan retrait du legacy (maître)** : [`docs/migration/legacy-retirement-plan.md`](docs/migration/legacy-retirement-plan.md).
 
123
 
124
  ## État des tests et bugs historiques
125
 
126
+ `pytest tests/` → **4690 passed, 12 skipped, 8 deselected, 0 failed**
127
  (post-S59). Les deselected sont les markers `live` (5 tests d'intégration
128
  contre vraie API/binaire) + `network` (3 tests qui hit le réseau réel),
129
  opt-in en local via `pytest -m live` ou `pytest -m network`. Le
 
252
 
253
  1. `git branch --show-current` → `claude/repo-analysis-cukvm`.
254
  2. `git status` → working tree clean.
255
+ 3. `pytest tests/ -q --no-header --tb=line` → 4690 passed.
256
  4. `git log -1 --format=%B` → décrit la prochaine sub-phase.
257
 
258
  **Règles d'architecture critiques** (apprises à la dure) :
 
340
  ## Contexte développement
341
 
342
  - **Environnement** : GitHub Codespaces, Python 3.11+
343
+ - **Tests** : `pytest tests/ -q` → 4690 passed, 12 skipped, 24
344
  deselected, 0 failed (au moment de la pause de session).
345
  - **Plan d'évolution actif** : [`docs/roadmap/evolution-2026.md`](docs/roadmap/evolution-2026.md).
346
  - **Plan retrait du legacy (maître)** : [`docs/migration/legacy-retirement-plan.md`](docs/migration/legacy-retirement-plan.md).
README.md CHANGED
@@ -395,7 +395,7 @@ ruff check picarones/ tests/
395
  python -m mypy picarones/core/
396
  ```
397
 
398
- **Test suite**: ~4680 tests, ~3 min on a modern laptop. Coverage
399
  floor at 85% (currently ~87%). The `network` marker excludes tests
400
  requiring live HTTP. A handful of tests depend on optional engines
401
  (`pero-ocr`, `pytesseract`) and are skipped/fail gracefully when
 
395
  python -m mypy picarones/core/
396
  ```
397
 
398
+ **Test suite**: ~4690 tests, ~3 min on a modern laptop. Coverage
399
  floor at 85% (currently ~87%). The `network` marker excludes tests
400
  requiring live HTTP. A handful of tests depend on optional engines
401
  (`pero-ocr`, `pytesseract`) and are skipped/fail gracefully when
picarones/adapters/legacy_engines/factory.py CHANGED
@@ -1,23 +1,18 @@
1
- """Factory : instancier un moteur OCR à partir de son nom court.
2
 
3
  Phase 7.A — module relocalisé depuis ``picarones.engines.factory``
4
- vers ``picarones.adapters.legacy_engines.factory``. Le chemin legacy
5
- reste disponible via un shim avec ``DeprecationWarning`` ;
6
- suppression prévue en 2.0.
7
 
8
- Vit en cercle 2 (``picarones.adapters.legacy_engines``) parce que c'est de la logique de
9
- catalogue OCR — le CLI (cercle 3) et l'API web (cercle 3) la consomment
10
- tous les deux. Auparavant ce helper était défini dans
11
- ``picarones.cli`` puis importé par ``picarones.web.benchmark_utils``
12
- violation de la règle d'imports inward-only.
13
-
14
- Cette factory ne dépend d'aucune brique cercle 3 (pas de ``click``,
15
- pas de FastAPI). Les erreurs sont signalées via ``ValueError``, le CLI
16
- les retraduit en ``click.BadParameter`` et l'API web les convertit en
17
- warning utilisateur.
18
 
19
  Discipline : ne pas importer ``click`` ici, sous peine de remonter une
20
- dépendance cercle 3 dans cercle 2.
21
  """
22
 
23
  from __future__ import annotations
 
1
+ """Factory legacy : instancier un ``BaseOCREngine`` à partir de son nom court.
2
 
3
  Phase 7.A — module relocalisé depuis ``picarones.engines.factory``
4
+ vers ``picarones.adapters.legacy_engines.factory``.
 
 
5
 
6
+ Sprint H.2.b du plan v2.0 équivalent canonique disponible :
7
+ ``picarones.adapters.ocr.factory.ocr_adapter_from_name`` retourne
8
+ des ``BaseOCRAdapter`` (StepExecutor Protocol) directement
9
+ consommables par ``PipelineExecutor`` sans ``LegacyOCREngineExecutor``.
10
+ Les nouveaux callers doivent utiliser la factory canonique. Cette
11
+ factory ne sera supprimée qu'avec ``BaseOCREngine`` lui-même
12
+ (H.2.d).
 
 
 
13
 
14
  Discipline : ne pas importer ``click`` ici, sous peine de remonter une
15
+ dépendance interfaces dans la couche adapters.
16
  """
17
 
18
  from __future__ import annotations
picarones/adapters/ocr/__init__.py CHANGED
@@ -21,6 +21,7 @@ from __future__ import annotations
21
 
22
  from picarones.adapters.ocr.azure_doc_intel import AzureDocIntelAdapter
23
  from picarones.adapters.ocr.base import BaseOCRAdapter, OCRAdapterError
 
24
  from picarones.adapters.ocr.google_vision import GoogleVisionAdapter
25
  from picarones.adapters.ocr.mistral_ocr import MistralOCRAdapter
26
  from picarones.adapters.ocr.pero_ocr import PeroOCRAdapter
@@ -36,4 +37,5 @@ __all__ = [
36
  "PeroOCRAdapter",
37
  "PrecomputedTextAdapter",
38
  "TesseractAdapter",
 
39
  ]
 
21
 
22
  from picarones.adapters.ocr.azure_doc_intel import AzureDocIntelAdapter
23
  from picarones.adapters.ocr.base import BaseOCRAdapter, OCRAdapterError
24
+ from picarones.adapters.ocr.factory import ocr_adapter_from_name
25
  from picarones.adapters.ocr.google_vision import GoogleVisionAdapter
26
  from picarones.adapters.ocr.mistral_ocr import MistralOCRAdapter
27
  from picarones.adapters.ocr.pero_ocr import PeroOCRAdapter
 
37
  "PeroOCRAdapter",
38
  "PrecomputedTextAdapter",
39
  "TesseractAdapter",
40
+ "ocr_adapter_from_name",
41
  ]
picarones/adapters/ocr/factory.py ADDED
@@ -0,0 +1,184 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Factory canonique : instancier un ``BaseOCRAdapter`` par nom court.
2
+
3
+ Sprint H.2.b du plan v2.0 — équivalent canonique de
4
+ ``picarones.adapters.legacy_engines.factory.engine_from_name`` qui
5
+ retournait des ``BaseOCREngine`` (legacy, ``run(image_path) →
6
+ EngineResult``). Cette factory retourne des ``BaseOCRAdapter``
7
+ (rewrite, ``StepExecutor`` Protocol, ``execute(inputs, params,
8
+ context) → dict[ArtifactType, Artifact]``).
9
+
10
+ Pourquoi ici
11
+ ------------
12
+ Vit en couche 5 (``picarones.adapters.ocr``) plutôt qu'en
13
+ ``app/`` parce que c'est de la logique de catalogue OCR — la CLI
14
+ (couche 8) et la web API (couche 8) la consomment toutes les deux.
15
+ Cette factory ne dépend d'aucune brique de couche supérieure
16
+ (pas de ``click``, pas de FastAPI).
17
+
18
+ Migration depuis le legacy
19
+ --------------------------
20
+ Code legacy ::
21
+
22
+ from picarones.adapters.legacy_engines.factory import engine_from_name
23
+ engine = engine_from_name("tesseract", lang="fra", psm=6)
24
+ # engine est un BaseOCREngine, à wrapper via LegacyOCREngineExecutor
25
+ # avant de pouvoir être consommé par PipelineExecutor.
26
+
27
+ Code canonique équivalent ::
28
+
29
+ from picarones.adapters.ocr.factory import ocr_adapter_from_name
30
+ adapter = ocr_adapter_from_name("tesseract", lang="fra", psm=6)
31
+ # adapter est un BaseOCRAdapter — déjà un StepExecutor, peut
32
+ # être directement enregistré dans un adapter_resolver et
33
+ # consommé par PipelineExecutor sans wrapping.
34
+
35
+ Alias supportés
36
+ ---------------
37
+ - ``tesseract`` / ``tess``
38
+ - ``pero_ocr`` / ``pero``
39
+ - ``mistral_ocr`` / ``mistral``
40
+ - ``google_vision`` / ``google`` / ``gv``
41
+ - ``azure_doc_intel`` / ``azure`` / ``adi``
42
+ - ``precomputed``
43
+ """
44
+
45
+ from __future__ import annotations
46
+
47
+ from typing import Any
48
+
49
+ from picarones.adapters.ocr.base import BaseOCRAdapter
50
+
51
+ #: Mapping ``alias → nom canonique`` pour les noms abrégés.
52
+ _ALIASES: dict[str, str] = {
53
+ "tess": "tesseract",
54
+ "pero": "pero_ocr",
55
+ "mistral": "mistral_ocr",
56
+ "google": "google_vision",
57
+ "gv": "google_vision",
58
+ "azure": "azure_doc_intel",
59
+ "adi": "azure_doc_intel",
60
+ }
61
+
62
+ #: Liste des noms canoniques supportés pour les messages d'erreur.
63
+ _SUPPORTED: tuple[str, ...] = (
64
+ "tesseract",
65
+ "pero_ocr",
66
+ "mistral_ocr",
67
+ "google_vision",
68
+ "azure_doc_intel",
69
+ "precomputed",
70
+ )
71
+
72
+
73
+ def ocr_adapter_from_name(
74
+ name: str, **kwargs: Any,
75
+ ) -> BaseOCRAdapter:
76
+ """Instancie un ``BaseOCRAdapter`` canonique par son nom court.
77
+
78
+ Parameters
79
+ ----------
80
+ name:
81
+ Identifiant court du moteur (cf. liste des alias dans le
82
+ docstring du module). Insensible à la casse.
83
+ **kwargs:
84
+ Arguments propagés au constructeur de l'adapter cible.
85
+ Les kwargs non reconnus par le constructeur lèveront un
86
+ ``TypeError`` — c'est intentionnel, on ne masque pas les
87
+ fautes de frappe.
88
+
89
+ Returns
90
+ -------
91
+ BaseOCRAdapter
92
+ Instance prête à être enregistrée dans un
93
+ ``adapter_resolver`` et consommée par ``PipelineExecutor``.
94
+
95
+ Raises
96
+ ------
97
+ ValueError
98
+ Si ``name`` est inconnu, ou si l'adapter cible nécessite
99
+ une dépendance optionnelle non installée (ex : Pero OCR
100
+ sans ``pero-ocr``). Le message d'erreur inclut la liste
101
+ des moteurs effectivement supportés.
102
+
103
+ Examples
104
+ --------
105
+ >>> adapter = ocr_adapter_from_name("tesseract", lang="fra")
106
+ >>> adapter.name
107
+ 'tesseract'
108
+
109
+ >>> adapter = ocr_adapter_from_name("tess") # alias
110
+ >>> adapter.name
111
+ 'tesseract'
112
+
113
+ >>> adapter = ocr_adapter_from_name(
114
+ ... "precomputed", source_label="bnf_jean_zay",
115
+ ... )
116
+ >>> adapter.name
117
+ 'precomputed:bnf_jean_zay'
118
+ """
119
+ canonical = _ALIASES.get(name.lower(), name.lower())
120
+
121
+ if canonical == "tesseract":
122
+ from picarones.adapters.ocr.tesseract import TesseractAdapter
123
+ return TesseractAdapter(**kwargs)
124
+
125
+ if canonical == "pero_ocr":
126
+ try:
127
+ from picarones.adapters.ocr.pero_ocr import PeroOCRAdapter
128
+ except ImportError as exc:
129
+ raise ValueError(
130
+ f"Adapter 'pero_ocr' indisponible : {exc}. "
131
+ "Installer la dépendance optionnelle ``pero-ocr``."
132
+ ) from exc
133
+ return PeroOCRAdapter(**kwargs)
134
+
135
+ if canonical == "mistral_ocr":
136
+ try:
137
+ from picarones.adapters.ocr.mistral_ocr import MistralOCRAdapter
138
+ except ImportError as exc:
139
+ raise ValueError(
140
+ f"Adapter 'mistral_ocr' indisponible : {exc}. "
141
+ "Installer la dépendance optionnelle ``mistralai``."
142
+ ) from exc
143
+ return MistralOCRAdapter(**kwargs)
144
+
145
+ if canonical == "google_vision":
146
+ try:
147
+ from picarones.adapters.ocr.google_vision import (
148
+ GoogleVisionAdapter,
149
+ )
150
+ except ImportError as exc:
151
+ raise ValueError(
152
+ f"Adapter 'google_vision' indisponible : {exc}. "
153
+ "Installer la dépendance optionnelle "
154
+ "``google-cloud-vision``."
155
+ ) from exc
156
+ return GoogleVisionAdapter(**kwargs)
157
+
158
+ if canonical == "azure_doc_intel":
159
+ try:
160
+ from picarones.adapters.ocr.azure_doc_intel import (
161
+ AzureDocIntelAdapter,
162
+ )
163
+ except ImportError as exc:
164
+ raise ValueError(
165
+ f"Adapter 'azure_doc_intel' indisponible : {exc}. "
166
+ "Installer la dépendance optionnelle "
167
+ "``azure-ai-formrecognizer``."
168
+ ) from exc
169
+ return AzureDocIntelAdapter(**kwargs)
170
+
171
+ if canonical == "precomputed":
172
+ from picarones.adapters.ocr.precomputed import (
173
+ PrecomputedTextAdapter,
174
+ )
175
+ return PrecomputedTextAdapter(**kwargs)
176
+
177
+ raise ValueError(
178
+ f"Moteur OCR inconnu : {name!r}. Valeurs supportées : "
179
+ f"{', '.join(_SUPPORTED)} (alias : "
180
+ f"{', '.join(sorted(_ALIASES))}).",
181
+ )
182
+
183
+
184
+ __all__ = ["ocr_adapter_from_name"]
tests/adapters/ocr/test_factory.py ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Sprint H.2.b — factory canonique ``ocr_adapter_from_name``.
2
+
3
+ Vérifie l'équivalent canonique de
4
+ ``picarones.adapters.legacy_engines.factory.engine_from_name`` :
5
+
6
+ - Résolution des alias (``tess`` → ``tesseract``, etc.) ;
7
+ - Construction effective des 6 adapters supportés (1 sans deps,
8
+ 4 cloud avec deps optionnelles, 1 precomputed) ;
9
+ - ``ValueError`` propre sur nom inconnu / dépendance absente,
10
+ avec message d'erreur listant les moteurs supportés ;
11
+ - Insensibilité à la casse du nom.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import pytest
17
+
18
+ from picarones.adapters.ocr import ocr_adapter_from_name
19
+ from picarones.adapters.ocr.base import BaseOCRAdapter
20
+ from picarones.adapters.ocr.tesseract import TesseractAdapter
21
+
22
+
23
+ class TestTesseract:
24
+ def test_canonical_name(self) -> None:
25
+ adapter = ocr_adapter_from_name("tesseract")
26
+ assert isinstance(adapter, TesseractAdapter)
27
+ assert adapter.name == "tesseract"
28
+
29
+ def test_alias_tess(self) -> None:
30
+ adapter = ocr_adapter_from_name("tess")
31
+ assert isinstance(adapter, TesseractAdapter)
32
+ # L'alias normalise vers le nom canonique "tesseract".
33
+ assert adapter.name == "tesseract"
34
+
35
+ def test_uppercase_name_normalized(self) -> None:
36
+ adapter = ocr_adapter_from_name("Tesseract")
37
+ assert isinstance(adapter, TesseractAdapter)
38
+
39
+ def test_kwargs_propagate(self) -> None:
40
+ adapter = ocr_adapter_from_name(
41
+ "tesseract", lang="eng", psm=3,
42
+ )
43
+ assert adapter.lang == "eng"
44
+
45
+ def test_invalid_kwarg_raises_typeerror(self) -> None:
46
+ # Pas de masquage des fautes de frappe.
47
+ with pytest.raises(TypeError):
48
+ ocr_adapter_from_name("tesseract", langg="fra")
49
+
50
+ def test_invalid_psm_raises_ocr_adapter_error(self) -> None:
51
+ from picarones.adapters.ocr.base import OCRAdapterError
52
+ with pytest.raises(OCRAdapterError, match="psm"):
53
+ ocr_adapter_from_name("tesseract", psm=99)
54
+
55
+
56
+ class TestPrecomputed:
57
+ """``precomputed`` n'a pas de dépendance optionnelle — doit
58
+ toujours être instanciable."""
59
+
60
+ def test_canonical_name(self) -> None:
61
+ adapter = ocr_adapter_from_name(
62
+ "precomputed", source_label="bnf",
63
+ )
64
+ assert isinstance(adapter, BaseOCRAdapter)
65
+ assert "bnf" in adapter.name
66
+
67
+
68
+ class TestCloudAdapters:
69
+ """Les adapters cloud sont importables sans la dépendance
70
+ système (pas de credentials nécessaires à l'instanciation —
71
+ la lib client est résolue paresseusement à execute())."""
72
+
73
+ def test_mistral_ocr_via_alias(self) -> None:
74
+ adapter = ocr_adapter_from_name(
75
+ "mistral", model="mistral-ocr-latest", api_key="fake",
76
+ )
77
+ assert isinstance(adapter, BaseOCRAdapter)
78
+ assert adapter.name == "mistral_ocr"
79
+
80
+ def test_google_vision_via_alias(self) -> None:
81
+ adapter = ocr_adapter_from_name(
82
+ "google", api_key="fake",
83
+ )
84
+ assert isinstance(adapter, BaseOCRAdapter)
85
+ assert adapter.name == "google_vision"
86
+
87
+ def test_azure_doc_intel_via_alias(self) -> None:
88
+ adapter = ocr_adapter_from_name(
89
+ "azure", endpoint="https://x.com", api_key="fake",
90
+ )
91
+ assert isinstance(adapter, BaseOCRAdapter)
92
+ assert adapter.name == "azure_doc_intel"
93
+
94
+
95
+ class TestPeroOCR:
96
+ """Pero OCR a une dépendance optionnelle ``pero-ocr`` — peut
97
+ être absent dans l'environnement de test."""
98
+
99
+ def test_canonical_or_helpful_error(self, tmp_path) -> None:
100
+ cfg = tmp_path / "fake_pero.ini"
101
+ cfg.write_text("# fake config", encoding="utf-8")
102
+ try:
103
+ adapter = ocr_adapter_from_name(
104
+ "pero_ocr", config_path=str(cfg),
105
+ )
106
+ assert isinstance(adapter, BaseOCRAdapter)
107
+ except ValueError as exc:
108
+ # Si ``pero-ocr`` n'est pas installé, on attend un
109
+ # message d'erreur qui explique comment l'installer.
110
+ assert "pero-ocr" in str(exc).lower()
111
+
112
+
113
+ class TestUnknownName:
114
+ def test_unknown_raises_with_supported_list(self) -> None:
115
+ with pytest.raises(ValueError) as ctx:
116
+ ocr_adapter_from_name("not_a_real_engine")
117
+ msg = str(ctx.value)
118
+ # Le message liste les moteurs supportés et les alias —
119
+ # utile pour le diagnostic.
120
+ assert "tesseract" in msg
121
+ assert "alias" in msg.lower()
122
+
123
+ def test_empty_name_raises(self) -> None:
124
+ with pytest.raises(ValueError):
125
+ ocr_adapter_from_name("")