Claude commited on
Commit
b5c2eaf
·
unverified ·
1 Parent(s): cfac168

feat(adapters/ocr): Sprint A14-S33 — GoogleVisionAdapter natif (no shim)

Browse files

Migration native du legacy picarones.engines.google_vision vers
BaseOCRAdapter (S26). Pas un shim.

picarones/adapters/ocr/google_vision.py
---------------------------------------
- GoogleVisionAdapter(BaseOCRAdapter), execution_mode = "io".
- Constructeur kwargs-only : name, language_hints (défaut ["fr"]),
feature_type (DOCUMENT_TEXT_DETECTION ou TEXT_DETECTION),
api_key/credentials_path (overrides des env GOOGLE_API_KEY/
GOOGLE_APPLICATION_CREDENTIALS), timeout_seconds.
- Validation au constructeur : name alphanum + _-, feature_type dans
l'ensemble valide, timeout > 0.
- Routing : creds → SDK google-cloud-vision ; sinon api_key → REST
via urllib ; sinon OCRAdapterError.
- DOCUMENT_TEXT_DETECTION → fullTextAnnotation.text concaténé.
TEXT_DETECTION → textAnnotations[0].description.
- SDK ImportError → OCRAdapterError clair avec pip install.
- Erreurs HTTP (HTTPError + autres) wrappées dans OCRAdapterError.
- Erreur dans response.error → OCRAdapterError avec message Google.
- Écrit dans <stem>.<name>.txt à côté de l'image.
- Artifact id "<doc>:<name>:raw_text".

Tests S33 dédiés (29 nouveaux)
------------------------------
- Constructor : defaults, custom name/feature_type/language_hints,
rejet feature_type invalide, rejet name vide/invalide, rejet timeout
non-positif.
- Contract : isinstance BaseOCRAdapter, input/output_types,
execution_mode = "io".
- Auth : pas d'auth → OCRAdapterError, explicite credentials_path
prend priorité sur env, env fallback, explicit api_key prend
priorité.
- InputValidation : IMAGE absent, sans URI, image inexistante → tous
OCRAdapterError.
- REST : DOCUMENT_TEXT_DETECTION extrait fullTextAnnotation.text,
TEXT_DETECTION extrait textAnnotations[0].description, responses
vides → text vide, error dans response → OCRAdapterError, écriture
<stem>.<name>.txt.
- SDK : credentials_path route vers SDK, SDK manquant →
OCRAdapterError, SDK exception → OCRAdapterError wrappé.
- ArtifactID : utilise adapter name.

Pas de confidences pour S33
---------------------------
Confidences (legacy S50 Word.confidence dans pages.blocks.paragraphs)
reportées au sprint dédié ConfidenceArtifact.

Tests : 4700 passed, 11 skipped (vs 4671 avant : +29 S33).
Lint : ruff check picarones/ tests/ → All checks passed.

https://claude.ai/code/session_011XQZNitg1rCgia8ZD1a2hP

README.md CHANGED
@@ -396,7 +396,7 @@ ruff check picarones/ tests/
396
  python -m mypy picarones/core/
397
  ```
398
 
399
- **Test suite**: ~4690 tests, ~3 min on a modern laptop. Coverage
400
  floor at 85% (currently ~87%). The `network` marker excludes tests
401
  requiring live HTTP. A handful of tests depend on optional engines
402
  (`pero-ocr`, `pytesseract`) and are skipped/fail gracefully when
 
396
  python -m mypy picarones/core/
397
  ```
398
 
399
+ **Test suite**: ~4720 tests, ~3 min on a modern laptop. Coverage
400
  floor at 85% (currently ~87%). The `network` marker excludes tests
401
  requiring live HTTP. A handful of tests depend on optional engines
402
  (`pero-ocr`, `pytesseract`) and are skipped/fail gracefully when
picarones/adapters/ocr/__init__.py CHANGED
@@ -20,6 +20,7 @@ dédiés, **natifs** au nouveau contrat (pas de shim sur le legacy
20
  from __future__ import annotations
21
 
22
  from picarones.adapters.ocr.base import BaseOCRAdapter, OCRAdapterError
 
23
  from picarones.adapters.ocr.mistral_ocr import MistralOCRAdapter
24
  from picarones.adapters.ocr.pero_ocr import PeroOCRAdapter
25
  from picarones.adapters.ocr.precomputed import PrecomputedTextAdapter
@@ -28,6 +29,7 @@ from picarones.adapters.ocr.tesseract import TesseractAdapter
28
  __all__ = [
29
  "BaseOCRAdapter",
30
  "OCRAdapterError",
 
31
  "MistralOCRAdapter",
32
  "PeroOCRAdapter",
33
  "PrecomputedTextAdapter",
 
20
  from __future__ import annotations
21
 
22
  from picarones.adapters.ocr.base import BaseOCRAdapter, OCRAdapterError
23
+ from picarones.adapters.ocr.google_vision import GoogleVisionAdapter
24
  from picarones.adapters.ocr.mistral_ocr import MistralOCRAdapter
25
  from picarones.adapters.ocr.pero_ocr import PeroOCRAdapter
26
  from picarones.adapters.ocr.precomputed import PrecomputedTextAdapter
 
29
  __all__ = [
30
  "BaseOCRAdapter",
31
  "OCRAdapterError",
32
+ "GoogleVisionAdapter",
33
  "MistralOCRAdapter",
34
  "PeroOCRAdapter",
35
  "PrecomputedTextAdapter",
picarones/adapters/ocr/google_vision.py ADDED
@@ -0,0 +1,298 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """``GoogleVisionAdapter`` natif — Sprint A14-S33.
2
+
3
+ Migration native du legacy ``picarones.engines.google_vision.GoogleVisionEngine``
4
+ vers le contrat ``BaseOCRAdapter`` (S26). **Pas un shim**.
5
+
6
+ Le legacy reste en place jusqu'au S46.
7
+
8
+ Cas d'usage BnF
9
+ ---------------
10
+ Google Cloud Vision propose deux modes d'OCR :
11
+
12
+ - ``DOCUMENT_TEXT_DETECTION`` (défaut) : optimisé pour les textes
13
+ denses et multilinguistiques — retourne une ``fullTextAnnotation``
14
+ hiérarchique (pages → blocks → paragraphs → words → symbols) avec
15
+ un texte plat ``text``.
16
+ - ``TEXT_DETECTION`` : mode court, retourne uniquement les
17
+ ``textAnnotations[0].description``.
18
+
19
+ L'adapter route automatiquement vers SDK (auth service account) ou
20
+ REST direct (auth clé API) selon la configuration disponible.
21
+
22
+ Configuration
23
+ -------------
24
+ Constructeur :
25
+
26
+ - ``name`` (défaut ``"google_vision"``).
27
+ - ``language_hints`` (défaut ``["fr"]``) : suggestions Vision API.
28
+ - ``feature_type`` (défaut ``"DOCUMENT_TEXT_DETECTION"``).
29
+ - ``api_key`` : clé API Google. Si ``None``, lit ``GOOGLE_API_KEY``.
30
+ - ``credentials_path`` : chemin vers un service account JSON. Si
31
+ ``None``, lit ``GOOGLE_APPLICATION_CREDENTIALS``.
32
+ - ``timeout_seconds`` (défaut 60).
33
+
34
+ Au moins une des deux authentifications (SDK ou REST) doit être
35
+ disponible.
36
+
37
+ Anti-sur-ingénierie
38
+ -------------------
39
+ - Pas d'extraction de confidences (legacy S50 — reportée).
40
+ - Pas de pré-validation du JSON service account — le SDK le fait.
41
+ - Pas de support batch — un appel par image.
42
+ """
43
+
44
+ from __future__ import annotations
45
+
46
+ import base64
47
+ import json
48
+ import os
49
+ import urllib.error
50
+ import urllib.request
51
+ from pathlib import Path
52
+ from typing import Any
53
+
54
+ from picarones.adapters.ocr.base import BaseOCRAdapter, OCRAdapterError
55
+ from picarones.domain.artifacts import Artifact, ArtifactType
56
+
57
+
58
+ _VALID_FEATURE_TYPES = frozenset({"DOCUMENT_TEXT_DETECTION", "TEXT_DETECTION"})
59
+
60
+
61
+ class GoogleVisionAdapter(BaseOCRAdapter):
62
+ """Adapter Google Cloud Vision natif au contrat S26.
63
+
64
+ Parameters
65
+ ----------
66
+ name:
67
+ Identifiant lisible. Défaut ``"google_vision"``.
68
+ language_hints:
69
+ Suggestions Vision API. Défaut ``["fr"]``.
70
+ feature_type:
71
+ ``"DOCUMENT_TEXT_DETECTION"`` (défaut) ou ``"TEXT_DETECTION"``.
72
+ api_key:
73
+ Clé API explicite. Si ``None``, lit ``GOOGLE_API_KEY``.
74
+ credentials_path:
75
+ Chemin service account JSON explicite. Si ``None``, lit
76
+ ``GOOGLE_APPLICATION_CREDENTIALS``.
77
+ timeout_seconds:
78
+ Timeout HTTP (REST). Défaut 60.
79
+
80
+ Raises
81
+ ------
82
+ OCRAdapterError
83
+ Au constructeur si name ou feature_type invalides.
84
+ """
85
+
86
+ input_types = frozenset({ArtifactType.IMAGE})
87
+ output_types = frozenset({ArtifactType.RAW_TEXT})
88
+ execution_mode = "io"
89
+
90
+ def __init__(
91
+ self,
92
+ *,
93
+ name: str = "google_vision",
94
+ language_hints: list[str] | None = None,
95
+ feature_type: str = "DOCUMENT_TEXT_DETECTION",
96
+ api_key: str | None = None,
97
+ credentials_path: str | None = None,
98
+ timeout_seconds: float = 60.0,
99
+ ) -> None:
100
+ if not name or not name.strip():
101
+ raise OCRAdapterError(
102
+ "GoogleVisionAdapter : name vide non autorisé.",
103
+ )
104
+ if not all(c.isalnum() or c in "_-" for c in name):
105
+ raise OCRAdapterError(
106
+ f"GoogleVisionAdapter : name invalide {name!r} — "
107
+ "alphanumérique + _ - uniquement.",
108
+ )
109
+ if feature_type not in _VALID_FEATURE_TYPES:
110
+ raise OCRAdapterError(
111
+ f"GoogleVisionAdapter : feature_type invalide "
112
+ f"{feature_type!r}. Valeurs valides : "
113
+ f"{sorted(_VALID_FEATURE_TYPES)}.",
114
+ )
115
+ if timeout_seconds <= 0:
116
+ raise OCRAdapterError(
117
+ f"GoogleVisionAdapter : timeout_seconds doit être > 0, "
118
+ f"reçu {timeout_seconds}.",
119
+ )
120
+ self._name = name
121
+ self._language_hints = list(language_hints or ["fr"])
122
+ self._feature_type = feature_type
123
+ self._explicit_api_key = api_key
124
+ self._explicit_credentials = credentials_path
125
+ self._timeout = timeout_seconds
126
+
127
+ @property
128
+ def name(self) -> str:
129
+ return self._name
130
+
131
+ @property
132
+ def feature_type(self) -> str:
133
+ return self._feature_type
134
+
135
+ def _resolve_credentials_path(self) -> str | None:
136
+ return self._explicit_credentials or os.environ.get(
137
+ "GOOGLE_APPLICATION_CREDENTIALS",
138
+ )
139
+
140
+ def _resolve_api_key(self) -> str | None:
141
+ return self._explicit_api_key or os.environ.get("GOOGLE_API_KEY")
142
+
143
+ def execute(
144
+ self,
145
+ inputs: dict[ArtifactType, Artifact],
146
+ params: dict[str, Any],
147
+ context: Any,
148
+ ) -> dict[ArtifactType, Artifact]:
149
+ """Exécute Google Vision OCR sur l'image fournie.
150
+
151
+ Routing :
152
+
153
+ - Si un service account JSON est disponible
154
+ (``credentials_path`` ou ``GOOGLE_APPLICATION_CREDENTIALS``)
155
+ → passe par le SDK ``google-cloud-vision``.
156
+ - Sinon, si une clé API simple est disponible
157
+ (``api_key`` ou ``GOOGLE_API_KEY``) → passe par REST direct
158
+ via ``urllib``.
159
+ - Sinon → ``OCRAdapterError``.
160
+ """
161
+ if ArtifactType.IMAGE not in inputs:
162
+ raise OCRAdapterError(
163
+ f"{self.name} : input IMAGE manquant.",
164
+ )
165
+ image_artifact = inputs[ArtifactType.IMAGE]
166
+ if image_artifact.uri is None:
167
+ raise OCRAdapterError(
168
+ f"{self.name} : artefact image "
169
+ f"{image_artifact.id!r} sans URI.",
170
+ )
171
+ image_path = Path(image_artifact.uri)
172
+ if not image_path.exists():
173
+ raise OCRAdapterError(
174
+ f"{self.name} : image introuvable {image_path!r}.",
175
+ )
176
+
177
+ creds = self._resolve_credentials_path()
178
+ api_key = self._resolve_api_key()
179
+
180
+ if creds:
181
+ text = self._call_via_sdk(image_path)
182
+ elif api_key:
183
+ text = self._call_via_rest(image_path, api_key)
184
+ else:
185
+ raise OCRAdapterError(
186
+ f"{self.name} : authentification manquante. Définir "
187
+ "GOOGLE_APPLICATION_CREDENTIALS (service account JSON) "
188
+ "ou GOOGLE_API_KEY.",
189
+ )
190
+
191
+ text_path = (
192
+ image_path.parent / f"{image_path.stem}.{self.name}.txt"
193
+ )
194
+ text_path.write_text(text, encoding="utf-8")
195
+
196
+ return {
197
+ ArtifactType.RAW_TEXT: Artifact(
198
+ id=f"{context.document_id}:{self.name}:raw_text",
199
+ document_id=context.document_id,
200
+ type=ArtifactType.RAW_TEXT,
201
+ produced_by_step="ocr",
202
+ uri=str(text_path),
203
+ ),
204
+ }
205
+
206
+ # ──────────────────────────────────────────────────────────────
207
+ # SDK / REST
208
+ # ──────────────────────────────────────────────────────────────
209
+
210
+ def _call_via_sdk(self, image_path: Path) -> str:
211
+ try:
212
+ from google.cloud import vision
213
+ except ImportError as exc:
214
+ raise OCRAdapterError(
215
+ f"{self.name} : SDK google-cloud-vision non installé. "
216
+ "Installer avec : pip install google-cloud-vision",
217
+ ) from exc
218
+
219
+ try:
220
+ client = vision.ImageAnnotatorClient()
221
+ image = vision.Image(content=image_path.read_bytes())
222
+ ctx = vision.ImageContext(language_hints=self._language_hints)
223
+
224
+ if self._feature_type == "DOCUMENT_TEXT_DETECTION":
225
+ response = client.document_text_detection(
226
+ image=image, image_context=ctx,
227
+ )
228
+ text = response.full_text_annotation.text
229
+ else:
230
+ response = client.text_detection(
231
+ image=image, image_context=ctx,
232
+ )
233
+ texts = response.text_annotations
234
+ text = texts[0].description if texts else ""
235
+ except Exception as exc:
236
+ raise OCRAdapterError(
237
+ f"{self.name} : SDK Google Vision a levé : "
238
+ f"{type(exc).__name__}: {exc}",
239
+ ) from exc
240
+ return text
241
+
242
+ def _call_via_rest(self, image_path: Path, api_key: str) -> str:
243
+ image_b64 = base64.b64encode(
244
+ image_path.read_bytes(),
245
+ ).decode("ascii")
246
+ payload = json.dumps({
247
+ "requests": [{
248
+ "image": {"content": image_b64},
249
+ "features": [
250
+ {"type": self._feature_type, "maxResults": 1},
251
+ ],
252
+ "imageContext": {"languageHints": self._language_hints},
253
+ }],
254
+ }).encode("utf-8")
255
+ req = urllib.request.Request(
256
+ "https://vision.googleapis.com/v1/images:annotate",
257
+ data=payload,
258
+ headers={
259
+ "Content-Type": "application/json",
260
+ "X-Goog-Api-Key": api_key,
261
+ },
262
+ )
263
+ try:
264
+ with urllib.request.urlopen(req, timeout=self._timeout) as resp:
265
+ result = json.loads(resp.read().decode("utf-8"))
266
+ except urllib.error.HTTPError as exc:
267
+ body = ""
268
+ try:
269
+ body = exc.read().decode("utf-8")
270
+ except Exception: # noqa: BLE001
271
+ pass
272
+ raise OCRAdapterError(
273
+ f"{self.name} : Google Vision API erreur {exc.code} : {body}",
274
+ ) from exc
275
+ except Exception as exc:
276
+ raise OCRAdapterError(
277
+ f"{self.name} : erreur API Google Vision : "
278
+ f"{type(exc).__name__}: {exc}",
279
+ ) from exc
280
+
281
+ responses = result.get("responses", [{}])
282
+ if not responses:
283
+ return ""
284
+ r = responses[0]
285
+ if "error" in r:
286
+ raise OCRAdapterError(
287
+ f"{self.name} : Google Vision API erreur : {r['error']}",
288
+ )
289
+
290
+ if self._feature_type == "DOCUMENT_TEXT_DETECTION":
291
+ full = r.get("fullTextAnnotation") or {}
292
+ return full.get("text", "") if isinstance(full, dict) else ""
293
+ # TEXT_DETECTION
294
+ texts = r.get("textAnnotations", [])
295
+ return texts[0]["description"] if texts else ""
296
+
297
+
298
+ __all__ = ["GoogleVisionAdapter"]
tests/adapters/ocr/test_sprint_a14_s33_google_vision_adapter.py ADDED
@@ -0,0 +1,418 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Sprint A14-S33 — ``GoogleVisionAdapter`` natif au contrat S26."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from pathlib import Path
8
+ from unittest.mock import MagicMock, patch
9
+
10
+ import pytest
11
+
12
+ from picarones.adapters.ocr import (
13
+ BaseOCRAdapter,
14
+ GoogleVisionAdapter,
15
+ OCRAdapterError,
16
+ )
17
+ from picarones.domain.artifacts import Artifact, ArtifactType
18
+ from picarones.pipeline.types import RunContext
19
+
20
+
21
+ def _make_image_artifact(uri: str) -> Artifact:
22
+ return Artifact(
23
+ id="d1:img",
24
+ document_id="d1",
25
+ type=ArtifactType.IMAGE,
26
+ uri=uri,
27
+ )
28
+
29
+
30
+ def _make_context() -> RunContext:
31
+ return RunContext(
32
+ document_id="d1",
33
+ code_version="1.0.0",
34
+ pipeline_name="test",
35
+ )
36
+
37
+
38
+ def _make_dummy_image(tmp_path: Path) -> Path:
39
+ path = tmp_path / "page.png"
40
+ path.write_bytes(b"PNG_FAKE_BYTES")
41
+ return path
42
+
43
+
44
+ # ──────────────────────────────────────────────────────────────────────
45
+ # Constructeur
46
+ # ──────────────────────────────────────────────────────────────────────
47
+
48
+
49
+ class TestGoogleVisionConstructor:
50
+ def test_defaults(self) -> None:
51
+ adapter = GoogleVisionAdapter()
52
+ assert adapter.name == "google_vision"
53
+ assert adapter.feature_type == "DOCUMENT_TEXT_DETECTION"
54
+
55
+ def test_custom_name(self) -> None:
56
+ adapter = GoogleVisionAdapter(name="my_gv")
57
+ assert adapter.name == "my_gv"
58
+
59
+ def test_text_detection_feature(self) -> None:
60
+ adapter = GoogleVisionAdapter(feature_type="TEXT_DETECTION")
61
+ assert adapter.feature_type == "TEXT_DETECTION"
62
+
63
+ def test_rejects_invalid_feature_type(self) -> None:
64
+ with pytest.raises(OCRAdapterError, match="feature_type"):
65
+ GoogleVisionAdapter(feature_type="UNKNOWN_FEATURE")
66
+
67
+ def test_rejects_empty_name(self) -> None:
68
+ with pytest.raises(OCRAdapterError, match="vide"):
69
+ GoogleVisionAdapter(name="")
70
+
71
+ def test_rejects_invalid_chars_in_name(self) -> None:
72
+ with pytest.raises(OCRAdapterError, match="invalide"):
73
+ GoogleVisionAdapter(name="bad name")
74
+
75
+ def test_rejects_non_positive_timeout(self) -> None:
76
+ with pytest.raises(OCRAdapterError, match="timeout"):
77
+ GoogleVisionAdapter(timeout_seconds=0)
78
+
79
+ def test_default_language_hints(self) -> None:
80
+ adapter = GoogleVisionAdapter()
81
+ # Vérifier que les hints sont stockés (privé mais accessible).
82
+ assert adapter._language_hints == ["fr"]
83
+
84
+ def test_custom_language_hints(self) -> None:
85
+ adapter = GoogleVisionAdapter(language_hints=["en", "lat"])
86
+ assert adapter._language_hints == ["en", "lat"]
87
+
88
+
89
+ # ──────────────────────────────────────────────────────────────────────
90
+ # Contrat BaseOCRAdapter
91
+ # ──────────────────────────────────────────────────────────────────────
92
+
93
+
94
+ class TestGoogleVisionContract:
95
+ def test_inherits_base_adapter(self) -> None:
96
+ adapter = GoogleVisionAdapter()
97
+ assert isinstance(adapter, BaseOCRAdapter)
98
+
99
+ def test_input_types(self) -> None:
100
+ assert GoogleVisionAdapter.input_types == frozenset({ArtifactType.IMAGE})
101
+
102
+ def test_output_types(self) -> None:
103
+ assert GoogleVisionAdapter.output_types == frozenset({ArtifactType.RAW_TEXT})
104
+
105
+ def test_execution_mode_is_io(self) -> None:
106
+ assert GoogleVisionAdapter.execution_mode == "io"
107
+
108
+
109
+ # ──────────────────────────────────────────────────────────────────────
110
+ # Auth resolution
111
+ # ──────────────────────────────────────────────────────────────────────
112
+
113
+
114
+ class TestGoogleVisionAuth:
115
+ def test_no_auth_raises(self, tmp_path: Path) -> None:
116
+ adapter = GoogleVisionAdapter()
117
+ image_path = _make_dummy_image(tmp_path)
118
+ artifact = _make_image_artifact(str(image_path))
119
+ with patch.dict("os.environ", {}, clear=True):
120
+ with pytest.raises(OCRAdapterError, match="authentification manquante"):
121
+ adapter.execute(
122
+ inputs={ArtifactType.IMAGE: artifact},
123
+ params={},
124
+ context=_make_context(),
125
+ )
126
+
127
+ def test_explicit_credentials_path_takes_priority(self) -> None:
128
+ adapter = GoogleVisionAdapter(credentials_path="/explicit/creds.json")
129
+ with patch.dict(
130
+ "os.environ",
131
+ {"GOOGLE_APPLICATION_CREDENTIALS": "/env/creds.json"},
132
+ ):
133
+ assert adapter._resolve_credentials_path() == "/explicit/creds.json"
134
+
135
+ def test_env_credentials_fallback(self) -> None:
136
+ adapter = GoogleVisionAdapter()
137
+ with patch.dict(
138
+ "os.environ",
139
+ {"GOOGLE_APPLICATION_CREDENTIALS": "/env/creds.json"},
140
+ ):
141
+ assert adapter._resolve_credentials_path() == "/env/creds.json"
142
+
143
+ def test_explicit_api_key_takes_priority(self) -> None:
144
+ adapter = GoogleVisionAdapter(api_key="explicit_key")
145
+ with patch.dict("os.environ", {"GOOGLE_API_KEY": "env_key"}):
146
+ assert adapter._resolve_api_key() == "explicit_key"
147
+
148
+
149
+ # ──────────────────────────────────────────────────────────────────────
150
+ # Input validation
151
+ # ──────────────────────────────────────────────────────────────────────
152
+
153
+
154
+ class TestGoogleVisionInputValidation:
155
+ def test_missing_image_input_raises(self) -> None:
156
+ adapter = GoogleVisionAdapter(api_key="x")
157
+ with pytest.raises(OCRAdapterError, match="IMAGE manquant"):
158
+ adapter.execute(inputs={}, params={}, context=_make_context())
159
+
160
+ def test_image_artifact_without_uri_raises(self) -> None:
161
+ adapter = GoogleVisionAdapter(api_key="x")
162
+ artifact = Artifact(
163
+ id="d1:img",
164
+ document_id="d1",
165
+ type=ArtifactType.IMAGE,
166
+ uri=None,
167
+ )
168
+ with pytest.raises(OCRAdapterError, match="sans URI"):
169
+ adapter.execute(
170
+ inputs={ArtifactType.IMAGE: artifact},
171
+ params={},
172
+ context=_make_context(),
173
+ )
174
+
175
+ def test_image_path_does_not_exist_raises(self) -> None:
176
+ adapter = GoogleVisionAdapter(api_key="x")
177
+ artifact = _make_image_artifact("/nonexistent/img.png")
178
+ with pytest.raises(OCRAdapterError, match="introuvable"):
179
+ adapter.execute(
180
+ inputs={ArtifactType.IMAGE: artifact},
181
+ params={},
182
+ context=_make_context(),
183
+ )
184
+
185
+
186
+ # ──────────────────────────────────────────────────────────────────────
187
+ # REST API path (api_key)
188
+ # ──────────────────────────────────────────────────────────────────────
189
+
190
+
191
+ class TestGoogleVisionREST:
192
+ def _mock_urlopen(self, response_dict: dict):
193
+ mock_resp = MagicMock()
194
+ mock_resp.read.return_value = json.dumps(response_dict).encode("utf-8")
195
+ mock_resp.__enter__.return_value = mock_resp
196
+ return patch("urllib.request.urlopen", return_value=mock_resp)
197
+
198
+ def test_document_text_detection_returns_full_text(
199
+ self, tmp_path: Path,
200
+ ) -> None:
201
+ adapter = GoogleVisionAdapter(api_key="x")
202
+ image_path = _make_dummy_image(tmp_path)
203
+ artifact = _make_image_artifact(str(image_path))
204
+
205
+ response = {
206
+ "responses": [{
207
+ "fullTextAnnotation": {"text": "Bonjour\nle monde"},
208
+ }],
209
+ }
210
+
211
+ with self._mock_urlopen(response):
212
+ result = adapter.execute(
213
+ inputs={ArtifactType.IMAGE: artifact},
214
+ params={},
215
+ context=_make_context(),
216
+ )
217
+ out_text = Path(result[ArtifactType.RAW_TEXT].uri).read_text(
218
+ encoding="utf-8",
219
+ )
220
+ assert out_text == "Bonjour\nle monde"
221
+
222
+ def test_text_detection_returns_first_annotation(
223
+ self, tmp_path: Path,
224
+ ) -> None:
225
+ adapter = GoogleVisionAdapter(
226
+ api_key="x", feature_type="TEXT_DETECTION",
227
+ )
228
+ image_path = _make_dummy_image(tmp_path)
229
+ artifact = _make_image_artifact(str(image_path))
230
+
231
+ response = {
232
+ "responses": [{
233
+ "textAnnotations": [
234
+ {"description": "Texte court"},
235
+ ],
236
+ }],
237
+ }
238
+
239
+ with self._mock_urlopen(response):
240
+ result = adapter.execute(
241
+ inputs={ArtifactType.IMAGE: artifact},
242
+ params={},
243
+ context=_make_context(),
244
+ )
245
+ out_text = Path(result[ArtifactType.RAW_TEXT].uri).read_text(
246
+ encoding="utf-8",
247
+ )
248
+ assert out_text == "Texte court"
249
+
250
+ def test_empty_responses_returns_empty_text(self, tmp_path: Path) -> None:
251
+ adapter = GoogleVisionAdapter(api_key="x")
252
+ image_path = _make_dummy_image(tmp_path)
253
+ artifact = _make_image_artifact(str(image_path))
254
+
255
+ with self._mock_urlopen({"responses": [{}]}):
256
+ result = adapter.execute(
257
+ inputs={ArtifactType.IMAGE: artifact},
258
+ params={},
259
+ context=_make_context(),
260
+ )
261
+ out_text = Path(result[ArtifactType.RAW_TEXT].uri).read_text(
262
+ encoding="utf-8",
263
+ )
264
+ assert out_text == ""
265
+
266
+ def test_api_error_in_response_raises(self, tmp_path: Path) -> None:
267
+ adapter = GoogleVisionAdapter(api_key="x")
268
+ image_path = _make_dummy_image(tmp_path)
269
+ artifact = _make_image_artifact(str(image_path))
270
+
271
+ response = {
272
+ "responses": [{
273
+ "error": {"code": 7, "message": "Permission denied"},
274
+ }],
275
+ }
276
+
277
+ with self._mock_urlopen(response):
278
+ with pytest.raises(OCRAdapterError, match="Permission denied"):
279
+ adapter.execute(
280
+ inputs={ArtifactType.IMAGE: artifact},
281
+ params={},
282
+ context=_make_context(),
283
+ )
284
+
285
+ def test_writes_to_stem_name_pattern(self, tmp_path: Path) -> None:
286
+ adapter = GoogleVisionAdapter(api_key="x", name="my_gv")
287
+ image_path = _make_dummy_image(tmp_path)
288
+ artifact = _make_image_artifact(str(image_path))
289
+
290
+ response = {"responses": [{"fullTextAnnotation": {"text": "x"}}]}
291
+
292
+ with self._mock_urlopen(response):
293
+ result = adapter.execute(
294
+ inputs={ArtifactType.IMAGE: artifact},
295
+ params={},
296
+ context=_make_context(),
297
+ )
298
+ out_path = Path(result[ArtifactType.RAW_TEXT].uri)
299
+ assert out_path.name == "page.my_gv.txt"
300
+
301
+
302
+ # ──────────────────────────────────────────────────────────────────────
303
+ # SDK path (credentials_path)
304
+ # ──────────────────────────────────────────────────────────────────────
305
+
306
+
307
+ class TestGoogleVisionSDK:
308
+ def test_credentials_path_routes_to_sdk(self, tmp_path: Path) -> None:
309
+ creds_path = tmp_path / "creds.json"
310
+ creds_path.write_text("{}")
311
+ adapter = GoogleVisionAdapter(credentials_path=str(creds_path))
312
+ image_path = _make_dummy_image(tmp_path)
313
+ artifact = _make_image_artifact(str(image_path))
314
+
315
+ # Mock du SDK google.cloud.vision
316
+ mock_response = MagicMock()
317
+ mock_response.full_text_annotation.text = "SDK output text"
318
+ mock_client = MagicMock()
319
+ mock_client.document_text_detection.return_value = mock_response
320
+
321
+ fake_vision = MagicMock()
322
+ fake_vision.ImageAnnotatorClient = MagicMock(return_value=mock_client)
323
+ fake_vision.Image = MagicMock(return_value="image_obj")
324
+ fake_vision.ImageContext = MagicMock(return_value="ctx_obj")
325
+ fake_module = MagicMock()
326
+ fake_module.vision = fake_vision
327
+
328
+ with patch.dict(sys.modules, {
329
+ "google": fake_module,
330
+ "google.cloud": fake_module,
331
+ "google.cloud.vision": fake_vision,
332
+ }):
333
+ result = adapter.execute(
334
+ inputs={ArtifactType.IMAGE: artifact},
335
+ params={},
336
+ context=_make_context(),
337
+ )
338
+ out_text = Path(result[ArtifactType.RAW_TEXT].uri).read_text(
339
+ encoding="utf-8",
340
+ )
341
+ assert out_text == "SDK output text"
342
+
343
+ def test_sdk_missing_raises_clean_error(self, tmp_path: Path) -> None:
344
+ creds_path = tmp_path / "creds.json"
345
+ creds_path.write_text("{}")
346
+ adapter = GoogleVisionAdapter(credentials_path=str(creds_path))
347
+ image_path = _make_dummy_image(tmp_path)
348
+ artifact = _make_image_artifact(str(image_path))
349
+
350
+ with patch.dict(sys.modules, {
351
+ "google.cloud.vision": None,
352
+ "google.cloud": None,
353
+ }):
354
+ with pytest.raises(OCRAdapterError, match="google-cloud-vision"):
355
+ adapter.execute(
356
+ inputs={ArtifactType.IMAGE: artifact},
357
+ params={},
358
+ context=_make_context(),
359
+ )
360
+
361
+ def test_sdk_internal_error_wrapped(self, tmp_path: Path) -> None:
362
+ creds_path = tmp_path / "creds.json"
363
+ creds_path.write_text("{}")
364
+ adapter = GoogleVisionAdapter(credentials_path=str(creds_path))
365
+ image_path = _make_dummy_image(tmp_path)
366
+ artifact = _make_image_artifact(str(image_path))
367
+
368
+ mock_client = MagicMock()
369
+ mock_client.document_text_detection.side_effect = RuntimeError(
370
+ "SDK boom",
371
+ )
372
+
373
+ fake_vision = MagicMock()
374
+ fake_vision.ImageAnnotatorClient = MagicMock(return_value=mock_client)
375
+ fake_vision.Image = MagicMock(return_value="image_obj")
376
+ fake_vision.ImageContext = MagicMock(return_value="ctx_obj")
377
+ fake_module = MagicMock()
378
+ fake_module.vision = fake_vision
379
+
380
+ with patch.dict(sys.modules, {
381
+ "google": fake_module,
382
+ "google.cloud": fake_module,
383
+ "google.cloud.vision": fake_vision,
384
+ }):
385
+ with pytest.raises(OCRAdapterError, match="RuntimeError.*SDK boom"):
386
+ adapter.execute(
387
+ inputs={ArtifactType.IMAGE: artifact},
388
+ params={},
389
+ context=_make_context(),
390
+ )
391
+
392
+
393
+ # ──────────────────────────────────────────────────────────────────────
394
+ # Artifact ID
395
+ # ──────────────────────────────────────────────────────────────────────
396
+
397
+
398
+ class TestGoogleVisionArtifactID:
399
+ def test_artifact_id_uses_adapter_name(self, tmp_path: Path) -> None:
400
+ adapter = GoogleVisionAdapter(api_key="x", name="custom_gv")
401
+ image_path = _make_dummy_image(tmp_path)
402
+ artifact = _make_image_artifact(str(image_path))
403
+
404
+ response = {"responses": [{"fullTextAnnotation": {"text": "x"}}]}
405
+ mock_resp = MagicMock()
406
+ mock_resp.read.return_value = json.dumps(response).encode("utf-8")
407
+ mock_resp.__enter__.return_value = mock_resp
408
+
409
+ with patch("urllib.request.urlopen", return_value=mock_resp):
410
+ result = adapter.execute(
411
+ inputs={ArtifactType.IMAGE: artifact},
412
+ params={},
413
+ context=_make_context(),
414
+ )
415
+ produced = result[ArtifactType.RAW_TEXT]
416
+ assert produced.id == "d1:custom_gv:raw_text"
417
+ assert produced.document_id == "d1"
418
+ assert produced.produced_by_step == "ocr"