myocr-client 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- myocr_client/__init__.py +63 -0
- myocr_client/client.py +374 -0
- myocr_client/exceptions.py +195 -0
- myocr_client/models.py +201 -0
- myocr_client/webhook.py +68 -0
- myocr_client-0.2.0.dist-info/METADATA +274 -0
- myocr_client-0.2.0.dist-info/RECORD +9 -0
- myocr_client-0.2.0.dist-info/WHEEL +4 -0
- myocr_client-0.2.0.dist-info/licenses/LICENSE +21 -0
myocr_client/__init__.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""myocr.app — Python SDK for the v1 API.
|
|
2
|
+
|
|
3
|
+
from myocr_client import MyOCRClient
|
|
4
|
+
client = MyOCRClient(api_key="sk_live_...")
|
|
5
|
+
result = client.convert("fattura.pdf", model="invoice")
|
|
6
|
+
result.save("out.xlsx")
|
|
7
|
+
|
|
8
|
+
Async jobs (file grandi):
|
|
9
|
+
job = client.create_job("big.pdf", model="bank_statement",
|
|
10
|
+
webhook_url="https://my.app/webhook")
|
|
11
|
+
job.wait() # polling con backoff esponenziale
|
|
12
|
+
job.download("out.xlsx")
|
|
13
|
+
"""
|
|
14
|
+
from .client import MyOCRClient
|
|
15
|
+
from .models import ConversionResult, Job, JobStatus, JobResult, BatchResult
|
|
16
|
+
from .exceptions import (
|
|
17
|
+
MyOCRError,
|
|
18
|
+
MissingApiKey,
|
|
19
|
+
InvalidApiKey,
|
|
20
|
+
UnsupportedModel,
|
|
21
|
+
UnsupportedFileType,
|
|
22
|
+
MissingFile,
|
|
23
|
+
FileTooLarge,
|
|
24
|
+
TooManyPages,
|
|
25
|
+
InvalidWebhookUrl,
|
|
26
|
+
QuotaExceeded,
|
|
27
|
+
NotReady,
|
|
28
|
+
NotFound,
|
|
29
|
+
OcrEngineError,
|
|
30
|
+
StorageError,
|
|
31
|
+
ServiceNotReady,
|
|
32
|
+
RateLimited,
|
|
33
|
+
InternalError,
|
|
34
|
+
)
|
|
35
|
+
from .webhook import verify_webhook_signature
|
|
36
|
+
|
|
37
|
+
__version__ = "0.2.0"
|
|
38
|
+
__all__ = [
|
|
39
|
+
"MyOCRClient",
|
|
40
|
+
"ConversionResult",
|
|
41
|
+
"Job",
|
|
42
|
+
"JobStatus",
|
|
43
|
+
"JobResult",
|
|
44
|
+
"BatchResult",
|
|
45
|
+
"MyOCRError",
|
|
46
|
+
"MissingApiKey",
|
|
47
|
+
"InvalidApiKey",
|
|
48
|
+
"UnsupportedModel",
|
|
49
|
+
"UnsupportedFileType",
|
|
50
|
+
"MissingFile",
|
|
51
|
+
"FileTooLarge",
|
|
52
|
+
"TooManyPages",
|
|
53
|
+
"InvalidWebhookUrl",
|
|
54
|
+
"QuotaExceeded",
|
|
55
|
+
"NotReady",
|
|
56
|
+
"NotFound",
|
|
57
|
+
"OcrEngineError",
|
|
58
|
+
"StorageError",
|
|
59
|
+
"ServiceNotReady",
|
|
60
|
+
"RateLimited",
|
|
61
|
+
"InternalError",
|
|
62
|
+
"verify_webhook_signature",
|
|
63
|
+
]
|
myocr_client/client.py
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
"""Client principale per l'API myocr.app v1.
|
|
2
|
+
|
|
3
|
+
Tutti i metodi sollevano sottoclassi di MyOCRError sui codici errore.
|
|
4
|
+
Retry automatico su 429 e 5xx (3 tentativi, backoff esponenziale 1s/2s/4s).
|
|
5
|
+
"""
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import time
|
|
9
|
+
from typing import Optional, BinaryIO, Union, List, IO
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
import requests
|
|
13
|
+
except ImportError:
|
|
14
|
+
raise ImportError(
|
|
15
|
+
"myocr-client requires `requests`. Install with: pip install requests"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
from .exceptions import (
|
|
19
|
+
MyOCRError,
|
|
20
|
+
MissingApiKey,
|
|
21
|
+
RateLimited,
|
|
22
|
+
InternalError,
|
|
23
|
+
from_response,
|
|
24
|
+
)
|
|
25
|
+
from .models import ConversionResult, Job, JobResult, BatchResult, JobStatus
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
DEFAULT_BASE_URL = "https://api.myocr.app"
|
|
29
|
+
DEFAULT_TIMEOUT = 60 # seconds
|
|
30
|
+
DEFAULT_RETRY_ATTEMPTS = 3
|
|
31
|
+
RETRYABLE_STATUS = {429, 500, 502, 503, 504}
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _looks_like_path(file: Union[str, BinaryIO, bytes]) -> bool:
|
|
37
|
+
return isinstance(file, str) and os.path.exists(file)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _prepare_file_tuple(file, filename: Optional[str] = None):
|
|
41
|
+
"""Normalizza l'input file in tupla (filename, fileobj-or-bytes) per requests."""
|
|
42
|
+
if isinstance(file, (bytes, bytearray)):
|
|
43
|
+
return (filename or "upload.pdf", bytes(file))
|
|
44
|
+
if hasattr(file, "read"):
|
|
45
|
+
name = filename or getattr(file, "name", None) or "upload.pdf"
|
|
46
|
+
if isinstance(name, str) and os.sep in name:
|
|
47
|
+
name = os.path.basename(name)
|
|
48
|
+
return (name, file)
|
|
49
|
+
if isinstance(file, str):
|
|
50
|
+
return (filename or os.path.basename(file), open(file, "rb"))
|
|
51
|
+
raise TypeError(f"file must be a path, file-like, or bytes; got {type(file)!r}")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class MyOCRClient:
|
|
55
|
+
"""Client HTTP per l'API myocr.app.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
api_key: chiave 'sk_live_...' o 'sk_test_...' (o letta da env MYOCR_API_KEY)
|
|
59
|
+
base_url: default https://api.myocr.app (override per staging beta.myocr.app)
|
|
60
|
+
timeout: secondi per ogni HTTP request
|
|
61
|
+
retry_attempts: tentativi totali su 429 e 5xx (default 3)
|
|
62
|
+
session: opzionale requests.Session pre-configurato (proxy, certificati)
|
|
63
|
+
|
|
64
|
+
Esempio:
|
|
65
|
+
from myocr_client import MyOCRClient
|
|
66
|
+
client = MyOCRClient(api_key="sk_live_...")
|
|
67
|
+
result = client.convert("invoice.pdf", model="invoice")
|
|
68
|
+
result.save("out.xlsx")
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
def __init__(
|
|
72
|
+
self,
|
|
73
|
+
api_key: Optional[str] = None,
|
|
74
|
+
base_url: Optional[str] = None,
|
|
75
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
76
|
+
retry_attempts: int = DEFAULT_RETRY_ATTEMPTS,
|
|
77
|
+
session: Optional["requests.Session"] = None,
|
|
78
|
+
):
|
|
79
|
+
self.api_key = api_key or os.environ.get("MYOCR_API_KEY", "").strip()
|
|
80
|
+
if not self.api_key:
|
|
81
|
+
raise MissingApiKey(
|
|
82
|
+
"api_key required (pass as argument or set MYOCR_API_KEY env var)"
|
|
83
|
+
)
|
|
84
|
+
self.base_url = (base_url or os.environ.get("MYOCR_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
|
|
85
|
+
self.timeout = timeout
|
|
86
|
+
self.retry_attempts = max(1, retry_attempts)
|
|
87
|
+
self._session = session or requests.Session()
|
|
88
|
+
|
|
89
|
+
def _headers(self) -> dict:
|
|
90
|
+
return {
|
|
91
|
+
"X-API-Key": self.api_key,
|
|
92
|
+
"User-Agent": "myocr-client-python/0.1.0",
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
def _request(self, method: str, path: str, **kwargs) -> "requests.Response":
|
|
96
|
+
"""HTTP request con retry su 429/5xx."""
|
|
97
|
+
url = f"{self.base_url}{path}"
|
|
98
|
+
headers = kwargs.pop("headers", {})
|
|
99
|
+
headers.update(self._headers())
|
|
100
|
+
kwargs.setdefault("timeout", self.timeout)
|
|
101
|
+
|
|
102
|
+
last_exc = None
|
|
103
|
+
for attempt in range(self.retry_attempts):
|
|
104
|
+
try:
|
|
105
|
+
resp = self._session.request(method, url, headers=headers, **kwargs)
|
|
106
|
+
except requests.RequestException as e:
|
|
107
|
+
last_exc = e
|
|
108
|
+
if attempt < self.retry_attempts - 1:
|
|
109
|
+
delay = 2 ** attempt
|
|
110
|
+
logger.warning(f"[myocr] {method} {path} network error attempt {attempt + 1}: {e}; retry in {delay}s")
|
|
111
|
+
time.sleep(delay)
|
|
112
|
+
continue
|
|
113
|
+
raise InternalError(f"network error: {e}", status_code=0) from e
|
|
114
|
+
|
|
115
|
+
if resp.status_code in RETRYABLE_STATUS and attempt < self.retry_attempts - 1:
|
|
116
|
+
# Honor Retry-After se presente
|
|
117
|
+
retry_after = resp.headers.get("Retry-After")
|
|
118
|
+
try:
|
|
119
|
+
delay = float(retry_after) if retry_after else 2 ** attempt
|
|
120
|
+
except (TypeError, ValueError):
|
|
121
|
+
delay = 2 ** attempt
|
|
122
|
+
logger.warning(f"[myocr] {method} {path} → {resp.status_code} attempt {attempt + 1}; retry in {delay}s")
|
|
123
|
+
time.sleep(delay)
|
|
124
|
+
continue
|
|
125
|
+
return resp
|
|
126
|
+
if last_exc:
|
|
127
|
+
raise InternalError(str(last_exc), status_code=0) from last_exc
|
|
128
|
+
return resp # type: ignore
|
|
129
|
+
|
|
130
|
+
def _raise_if_error(self, resp: "requests.Response") -> None:
|
|
131
|
+
"""Se response non 2xx, solleva l'eccezione appropriata."""
|
|
132
|
+
if 200 <= resp.status_code < 300:
|
|
133
|
+
return
|
|
134
|
+
request_id = resp.headers.get("X-MyOCR-Request-Id")
|
|
135
|
+
try:
|
|
136
|
+
body = resp.json()
|
|
137
|
+
except ValueError:
|
|
138
|
+
body = resp.text
|
|
139
|
+
raise from_response(resp.status_code, body, request_id=request_id)
|
|
140
|
+
|
|
141
|
+
# ---------- Status ----------
|
|
142
|
+
|
|
143
|
+
def status(self) -> dict:
|
|
144
|
+
"""GET /v1/status — health check pubblico (no auth richiesta ma la mandiamo comunque)."""
|
|
145
|
+
resp = self._request("GET", "/v1/status")
|
|
146
|
+
self._raise_if_error(resp)
|
|
147
|
+
return resp.json().get("data", {})
|
|
148
|
+
|
|
149
|
+
def usage(self) -> dict:
|
|
150
|
+
"""GET /v1/usage — quota corrente per la propria API key.
|
|
151
|
+
Ritorna dict con plan/calls_used/calls_limit/percentage/reset_date/year_month/is_test_key.
|
|
152
|
+
Utile per controllare programmaticamente quando avvicinarsi al limite."""
|
|
153
|
+
resp = self._request("GET", "/v1/usage")
|
|
154
|
+
self._raise_if_error(resp)
|
|
155
|
+
return resp.json().get("data", {})
|
|
156
|
+
|
|
157
|
+
# ---------- Sync conversion ----------
|
|
158
|
+
|
|
159
|
+
def convert(
|
|
160
|
+
self,
|
|
161
|
+
file: Union[str, bytes, IO[bytes]],
|
|
162
|
+
model: str = "tables",
|
|
163
|
+
output: str = "xlsx",
|
|
164
|
+
filename: Optional[str] = None,
|
|
165
|
+
) -> ConversionResult:
|
|
166
|
+
"""POST /v1/convert — sync, max 5MB / 10 pagine.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
file: path stringa, bytes, o file-like object aperto in 'rb'
|
|
170
|
+
model: tables / text / invoice / receipt / bank_statement / business_card
|
|
171
|
+
output: xlsx / txt / json
|
|
172
|
+
filename: nome originale (utile se file è bytes o file-like senza .name)
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
ConversionResult con .content (bytes) o .data (dict per output=json).
|
|
176
|
+
|
|
177
|
+
Raises:
|
|
178
|
+
QuotaExceeded, FileTooLarge, TooManyPages, OcrEngineError, RateLimited, ...
|
|
179
|
+
"""
|
|
180
|
+
file_tuple = _prepare_file_tuple(file, filename=filename)
|
|
181
|
+
files = {"file": file_tuple}
|
|
182
|
+
data = {"model": model, "output": output}
|
|
183
|
+
try:
|
|
184
|
+
resp = self._request("POST", "/v1/convert", files=files, data=data)
|
|
185
|
+
finally:
|
|
186
|
+
# se abbiamo aperto noi il file, chiudilo
|
|
187
|
+
if isinstance(file, str) and hasattr(file_tuple[1], "close"):
|
|
188
|
+
try:
|
|
189
|
+
file_tuple[1].close()
|
|
190
|
+
except Exception:
|
|
191
|
+
pass
|
|
192
|
+
|
|
193
|
+
self._raise_if_error(resp)
|
|
194
|
+
|
|
195
|
+
request_id = resp.headers.get("X-MyOCR-Request-Id")
|
|
196
|
+
pages_used_h = resp.headers.get("X-MyOCR-Pages-Used")
|
|
197
|
+
pages_used = int(pages_used_h) if pages_used_h and pages_used_h.isdigit() else None
|
|
198
|
+
model_used = resp.headers.get("X-MyOCR-Model") or model
|
|
199
|
+
|
|
200
|
+
ctype = resp.headers.get("Content-Type", "").lower()
|
|
201
|
+
if "application/json" in ctype:
|
|
202
|
+
body = resp.json()
|
|
203
|
+
return ConversionResult(
|
|
204
|
+
data=body.get("data") if isinstance(body, dict) else body,
|
|
205
|
+
format="json",
|
|
206
|
+
request_id=request_id,
|
|
207
|
+
pages_used=pages_used,
|
|
208
|
+
model=model_used,
|
|
209
|
+
)
|
|
210
|
+
if "text/plain" in ctype:
|
|
211
|
+
return ConversionResult(
|
|
212
|
+
content=resp.content,
|
|
213
|
+
format="txt",
|
|
214
|
+
request_id=request_id,
|
|
215
|
+
pages_used=pages_used,
|
|
216
|
+
model=model_used,
|
|
217
|
+
)
|
|
218
|
+
return ConversionResult(
|
|
219
|
+
content=resp.content,
|
|
220
|
+
format="xlsx",
|
|
221
|
+
request_id=request_id,
|
|
222
|
+
pages_used=pages_used,
|
|
223
|
+
model=model_used,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
# ---------- Async jobs ----------
|
|
227
|
+
|
|
228
|
+
def create_job(
|
|
229
|
+
self,
|
|
230
|
+
file: Union[str, bytes, IO[bytes]],
|
|
231
|
+
model: str = "tables",
|
|
232
|
+
webhook_url: Optional[str] = None,
|
|
233
|
+
filename: Optional[str] = None,
|
|
234
|
+
) -> Job:
|
|
235
|
+
"""POST /v1/jobs — async, max 50MB."""
|
|
236
|
+
file_tuple = _prepare_file_tuple(file, filename=filename)
|
|
237
|
+
files = {"file": file_tuple}
|
|
238
|
+
data = {"model": model}
|
|
239
|
+
if webhook_url:
|
|
240
|
+
data["webhook_url"] = webhook_url
|
|
241
|
+
try:
|
|
242
|
+
resp = self._request("POST", "/v1/jobs", files=files, data=data)
|
|
243
|
+
finally:
|
|
244
|
+
if isinstance(file, str) and hasattr(file_tuple[1], "close"):
|
|
245
|
+
try:
|
|
246
|
+
file_tuple[1].close()
|
|
247
|
+
except Exception:
|
|
248
|
+
pass
|
|
249
|
+
|
|
250
|
+
self._raise_if_error(resp)
|
|
251
|
+
body = resp.json().get("data", {})
|
|
252
|
+
return Job.from_dict(body, client=self)
|
|
253
|
+
|
|
254
|
+
def get_job(self, request_id: str) -> Job:
|
|
255
|
+
"""GET /v1/jobs/{id}."""
|
|
256
|
+
resp = self._request("GET", f"/v1/jobs/{request_id}")
|
|
257
|
+
self._raise_if_error(resp)
|
|
258
|
+
return Job.from_dict(resp.json().get("data", {}), client=self)
|
|
259
|
+
|
|
260
|
+
def get_job_result(self, request_id: str) -> JobResult:
|
|
261
|
+
"""GET /v1/jobs/{id}/result — signed URL R2 o file binario."""
|
|
262
|
+
resp = self._request("GET", f"/v1/jobs/{request_id}/result")
|
|
263
|
+
self._raise_if_error(resp)
|
|
264
|
+
ctype = resp.headers.get("Content-Type", "").lower()
|
|
265
|
+
if "application/json" in ctype:
|
|
266
|
+
body = resp.json().get("data", {})
|
|
267
|
+
return JobResult(
|
|
268
|
+
result_url=body.get("result_url"),
|
|
269
|
+
expires_in=body.get("expires_in"),
|
|
270
|
+
)
|
|
271
|
+
return JobResult(
|
|
272
|
+
content=resp.content,
|
|
273
|
+
content_type=ctype or None,
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
def delete_job(self, request_id: str) -> dict:
|
|
277
|
+
"""DELETE /v1/jobs/{id}."""
|
|
278
|
+
resp = self._request("DELETE", f"/v1/jobs/{request_id}")
|
|
279
|
+
self._raise_if_error(resp)
|
|
280
|
+
return resp.json().get("data", {})
|
|
281
|
+
|
|
282
|
+
# ---------- Batch ----------
|
|
283
|
+
|
|
284
|
+
def batch(
|
|
285
|
+
self,
|
|
286
|
+
files: List[Union[str, bytes, IO[bytes]]],
|
|
287
|
+
model: str = "tables",
|
|
288
|
+
webhook_url: Optional[str] = None,
|
|
289
|
+
filenames: Optional[List[str]] = None,
|
|
290
|
+
) -> BatchResult:
|
|
291
|
+
"""POST /v1/batch — 1-20 file in una chiamata."""
|
|
292
|
+
if not files:
|
|
293
|
+
raise ValueError("files list cannot be empty")
|
|
294
|
+
if len(files) > 20:
|
|
295
|
+
raise ValueError("max 20 files per batch")
|
|
296
|
+
|
|
297
|
+
names = filenames or [None] * len(files)
|
|
298
|
+
if len(names) != len(files):
|
|
299
|
+
raise ValueError("filenames length must match files length")
|
|
300
|
+
|
|
301
|
+
multi_files = []
|
|
302
|
+
opened = []
|
|
303
|
+
for f, name in zip(files, names):
|
|
304
|
+
tup = _prepare_file_tuple(f, filename=name)
|
|
305
|
+
multi_files.append(("files", tup))
|
|
306
|
+
if isinstance(f, str):
|
|
307
|
+
opened.append(tup[1])
|
|
308
|
+
|
|
309
|
+
data = {"model": model}
|
|
310
|
+
if webhook_url:
|
|
311
|
+
data["webhook_url"] = webhook_url
|
|
312
|
+
|
|
313
|
+
try:
|
|
314
|
+
resp = self._request("POST", "/v1/batch", files=multi_files, data=data)
|
|
315
|
+
finally:
|
|
316
|
+
for fh in opened:
|
|
317
|
+
try:
|
|
318
|
+
fh.close()
|
|
319
|
+
except Exception:
|
|
320
|
+
pass
|
|
321
|
+
|
|
322
|
+
self._raise_if_error(resp)
|
|
323
|
+
body = resp.json().get("data", {})
|
|
324
|
+
return BatchResult(
|
|
325
|
+
batch_id=body.get("batch_id", ""),
|
|
326
|
+
model=body.get("model", model),
|
|
327
|
+
jobs=[
|
|
328
|
+
Job(
|
|
329
|
+
request_id=j.get("request_id", ""),
|
|
330
|
+
status=JobStatus(j.get("status", "pending")),
|
|
331
|
+
model=body.get("model", model),
|
|
332
|
+
_client=self,
|
|
333
|
+
)
|
|
334
|
+
for j in body.get("jobs", [])
|
|
335
|
+
],
|
|
336
|
+
errors=body.get("errors", []),
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
# ---------- API keys (richiede session login utente, non X-API-Key) ----------
|
|
340
|
+
# Esposte per completeness; uso reale dal dashboard /account/api.
|
|
341
|
+
|
|
342
|
+
def list_keys(self) -> List[dict]:
|
|
343
|
+
"""GET /v1/keys — lista keys utente (richiede session cookie, non X-API-Key)."""
|
|
344
|
+
resp = self._request("GET", "/v1/keys")
|
|
345
|
+
self._raise_if_error(resp)
|
|
346
|
+
return resp.json().get("data", [])
|
|
347
|
+
|
|
348
|
+
def rotate_key(self, key_id: int) -> dict:
|
|
349
|
+
"""POST /v1/keys/{id}/rotate — revoca + nuova in atomica.
|
|
350
|
+
Ritorna dict con 'key' (raw) mostrato una sola volta. Salvalo subito.
|
|
351
|
+
Richiede session login (non X-API-Key)."""
|
|
352
|
+
resp = self._request("POST", f"/v1/keys/{key_id}/rotate")
|
|
353
|
+
self._raise_if_error(resp)
|
|
354
|
+
return resp.json().get("data", {})
|
|
355
|
+
|
|
356
|
+
def list_jobs(
|
|
357
|
+
self,
|
|
358
|
+
status: Optional[str] = None,
|
|
359
|
+
model: Optional[str] = None,
|
|
360
|
+
limit: int = 20,
|
|
361
|
+
offset: int = 0,
|
|
362
|
+
) -> dict:
|
|
363
|
+
"""GET /v1/jobs — lista paginata job per la propria api_key.
|
|
364
|
+
Filtri: status (pending/processing/done/failed), model. limit max 100.
|
|
365
|
+
Ritorna {total, limit, offset, jobs: [...]}."""
|
|
366
|
+
params = {"limit": str(min(limit, 100)), "offset": str(max(offset, 0))}
|
|
367
|
+
if status:
|
|
368
|
+
params["status"] = status
|
|
369
|
+
if model:
|
|
370
|
+
params["model"] = model
|
|
371
|
+
from urllib.parse import urlencode
|
|
372
|
+
resp = self._request("GET", f"/v1/jobs?{urlencode(params)}")
|
|
373
|
+
self._raise_if_error(resp)
|
|
374
|
+
return resp.json().get("data", {})
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Mappa 1:1 sui codici errore dell'API v1 (vedi static/openapi.yaml).
|
|
2
|
+
|
|
3
|
+
Tutte le eccezioni custom ereditano da MyOCRError. Il client le solleva
|
|
4
|
+
sulla base del campo `error.code` dell'envelope JSON, oppure dello status HTTP
|
|
5
|
+
quando il body non è JSON.
|
|
6
|
+
|
|
7
|
+
Esempio:
|
|
8
|
+
try:
|
|
9
|
+
client.convert("doc.pdf", model="invoice")
|
|
10
|
+
except QuotaExceeded as e:
|
|
11
|
+
print(e.upgrade_url)
|
|
12
|
+
except InvalidApiKey:
|
|
13
|
+
print("revoke or rotate key")
|
|
14
|
+
"""
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class MyOCRError(Exception):
|
|
19
|
+
"""Base per tutte le eccezioni dell'SDK."""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
message: str = "",
|
|
24
|
+
*,
|
|
25
|
+
code: Optional[str] = None,
|
|
26
|
+
request_id: Optional[str] = None,
|
|
27
|
+
status_code: Optional[int] = None,
|
|
28
|
+
payload: Optional[dict] = None,
|
|
29
|
+
):
|
|
30
|
+
super().__init__(message or code or "myocr error")
|
|
31
|
+
self.code = code
|
|
32
|
+
self.message = message
|
|
33
|
+
self.request_id = request_id
|
|
34
|
+
self.status_code = status_code
|
|
35
|
+
self.payload = payload or {}
|
|
36
|
+
|
|
37
|
+
def __repr__(self) -> str:
|
|
38
|
+
return (
|
|
39
|
+
f"{self.__class__.__name__}(code={self.code!r}, "
|
|
40
|
+
f"status={self.status_code}, request_id={self.request_id!r}, "
|
|
41
|
+
f"message={self.message!r})"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class MissingApiKey(MyOCRError):
|
|
46
|
+
"""X-API-Key header assente."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class InvalidApiKey(MyOCRError):
|
|
50
|
+
"""Key non trovata o revocata."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class UnsupportedModel(MyOCRError):
|
|
54
|
+
"""Parametro model non in tables/text/invoice/receipt/bank_statement/business_card."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class UnsupportedFileType(MyOCRError):
|
|
58
|
+
"""Estensione file non supportata."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class MissingFile(MyOCRError):
|
|
62
|
+
"""Multipart file mancante o vuoto."""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class FileTooLarge(MyOCRError):
|
|
66
|
+
"""File supera 5MB (sync) o 50MB (async)."""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class TooManyPages(MyOCRError):
|
|
70
|
+
"""Documento supera 10 pagine sul flow sync."""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class InvalidWebhookUrl(MyOCRError):
|
|
74
|
+
"""webhook_url non http(s)://."""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class QuotaExceeded(MyOCRError):
|
|
78
|
+
"""Quota mensile esaurita (status 402).
|
|
79
|
+
|
|
80
|
+
Attributi extra letti dal payload error:
|
|
81
|
+
upgrade_url — URL dashboard per upgrade
|
|
82
|
+
calls_used — chiamate consumate questo mese
|
|
83
|
+
calls_limit — limite piano corrente
|
|
84
|
+
reset_date — ISO date del reset
|
|
85
|
+
current_plan — nome piano attuale
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
def __init__(self, *args, **kwargs):
|
|
89
|
+
super().__init__(*args, **kwargs)
|
|
90
|
+
err = self.payload.get("error") if isinstance(self.payload, dict) else None
|
|
91
|
+
if isinstance(err, dict):
|
|
92
|
+
self.upgrade_url = err.get("upgrade_url")
|
|
93
|
+
self.calls_used = err.get("calls_used")
|
|
94
|
+
self.calls_limit = err.get("calls_limit")
|
|
95
|
+
self.reset_date = err.get("reset_date")
|
|
96
|
+
self.current_plan = err.get("current_plan")
|
|
97
|
+
else:
|
|
98
|
+
self.upgrade_url = None
|
|
99
|
+
self.calls_used = None
|
|
100
|
+
self.calls_limit = None
|
|
101
|
+
self.reset_date = None
|
|
102
|
+
self.current_plan = None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class NotReady(MyOCRError):
|
|
106
|
+
"""Risultato job richiesto prima che status=done."""
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class NotFound(MyOCRError):
|
|
110
|
+
"""Job/key non trovato."""
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class OcrEngineError(MyOCRError):
|
|
114
|
+
"""Errore upstream del motore OCR (502)."""
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class StorageError(MyOCRError):
|
|
118
|
+
"""Errore object storage (R2)."""
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class ServiceNotReady(MyOCRError):
|
|
122
|
+
"""Endpoint non ancora attivo (503)."""
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class RateLimited(MyOCRError):
|
|
126
|
+
"""Rate limit superato (429)."""
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class InternalError(MyOCRError):
|
|
130
|
+
"""Errore server generico (500)."""
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# Mapping codice errore API → eccezione SDK
|
|
134
|
+
CODE_TO_EXCEPTION = {
|
|
135
|
+
"MISSING_API_KEY": MissingApiKey,
|
|
136
|
+
"INVALID_API_KEY": InvalidApiKey,
|
|
137
|
+
"UNSUPPORTED_MODEL": UnsupportedModel,
|
|
138
|
+
"UNSUPPORTED_FILE_TYPE": UnsupportedFileType,
|
|
139
|
+
"MISSING_FILE": MissingFile,
|
|
140
|
+
"FILE_TOO_LARGE": FileTooLarge,
|
|
141
|
+
"TOO_MANY_PAGES": TooManyPages,
|
|
142
|
+
"INVALID_WEBHOOK_URL": InvalidWebhookUrl,
|
|
143
|
+
"QUOTA_EXCEEDED": QuotaExceeded,
|
|
144
|
+
"INSUFFICIENT_CREDITS": QuotaExceeded,
|
|
145
|
+
"quota_exceeded": QuotaExceeded,
|
|
146
|
+
"NOT_READY": NotReady,
|
|
147
|
+
"NOT_FOUND": NotFound,
|
|
148
|
+
"OCR_ERROR": OcrEngineError,
|
|
149
|
+
"STORAGE_ERROR": StorageError,
|
|
150
|
+
"SERVICE_NOT_READY": ServiceNotReady,
|
|
151
|
+
"INTERNAL_ERROR": InternalError,
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def from_response(status_code: int, body, request_id: Optional[str] = None) -> MyOCRError:
|
|
156
|
+
"""Costruisce l'eccezione appropriata da una response HTTP non 2xx."""
|
|
157
|
+
code = None
|
|
158
|
+
message = ""
|
|
159
|
+
payload = None
|
|
160
|
+
if isinstance(body, dict):
|
|
161
|
+
payload = body
|
|
162
|
+
err = body.get("error")
|
|
163
|
+
if isinstance(err, dict):
|
|
164
|
+
code = err.get("code")
|
|
165
|
+
message = err.get("message", "")
|
|
166
|
+
elif isinstance(err, str):
|
|
167
|
+
code = err
|
|
168
|
+
message = body.get("message", "")
|
|
169
|
+
elif isinstance(body, (bytes, str)):
|
|
170
|
+
message = body.decode("utf-8", errors="replace") if isinstance(body, bytes) else body
|
|
171
|
+
|
|
172
|
+
if status_code == 429:
|
|
173
|
+
return RateLimited(message or "Rate limit exceeded", code=code or "RATE_LIMITED",
|
|
174
|
+
status_code=status_code, request_id=request_id, payload=payload)
|
|
175
|
+
|
|
176
|
+
if code and code in CODE_TO_EXCEPTION:
|
|
177
|
+
return CODE_TO_EXCEPTION[code](message or code, code=code,
|
|
178
|
+
status_code=status_code, request_id=request_id, payload=payload)
|
|
179
|
+
|
|
180
|
+
# Fallback per status code senza body strutturato
|
|
181
|
+
if status_code == 401:
|
|
182
|
+
return InvalidApiKey(message or "Unauthorized", code=code, status_code=status_code,
|
|
183
|
+
request_id=request_id, payload=payload)
|
|
184
|
+
if status_code == 404:
|
|
185
|
+
return NotFound(message or "Not found", code=code, status_code=status_code,
|
|
186
|
+
request_id=request_id, payload=payload)
|
|
187
|
+
if status_code == 402:
|
|
188
|
+
return QuotaExceeded(message or "Quota exceeded", code=code, status_code=status_code,
|
|
189
|
+
request_id=request_id, payload=payload)
|
|
190
|
+
if status_code >= 500:
|
|
191
|
+
return InternalError(message or f"Server error ({status_code})", code=code,
|
|
192
|
+
status_code=status_code, request_id=request_id, payload=payload)
|
|
193
|
+
|
|
194
|
+
return MyOCRError(message or f"HTTP {status_code}", code=code, status_code=status_code,
|
|
195
|
+
request_id=request_id, payload=payload)
|
myocr_client/models.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Modelli dati restituiti dall'SDK."""
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from enum import Enum
|
|
4
|
+
from typing import Optional, List, Any, Dict
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class JobStatus(str, Enum):
|
|
8
|
+
PENDING = "pending"
|
|
9
|
+
PROCESSING = "processing"
|
|
10
|
+
DONE = "done"
|
|
11
|
+
FAILED = "failed"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class ConversionResult:
|
|
16
|
+
"""Risultato di `client.convert(...)`.
|
|
17
|
+
|
|
18
|
+
Tre forme possibili in base al model/output:
|
|
19
|
+
- xlsx binario: `content` è bytes, `format='xlsx'`
|
|
20
|
+
- txt: `content` è bytes/str, `format='txt'`
|
|
21
|
+
- json metadata: `data` è dict, `format='json'`
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
content: Optional[bytes] = None
|
|
25
|
+
data: Optional[Dict[str, Any]] = None
|
|
26
|
+
format: str = "xlsx"
|
|
27
|
+
request_id: Optional[str] = None
|
|
28
|
+
pages_used: Optional[int] = None
|
|
29
|
+
model: Optional[str] = None
|
|
30
|
+
|
|
31
|
+
def save(self, path: str) -> None:
|
|
32
|
+
"""Scrive il contenuto su disk. Per format='json' usa data via .json invece."""
|
|
33
|
+
if self.content is None:
|
|
34
|
+
raise ValueError(
|
|
35
|
+
"ConversionResult has no binary content (format=json). "
|
|
36
|
+
"Use `result.data` or `result.json()` instead."
|
|
37
|
+
)
|
|
38
|
+
with open(path, "wb") as f:
|
|
39
|
+
f.write(self.content)
|
|
40
|
+
|
|
41
|
+
def text(self) -> str:
|
|
42
|
+
"""Decodifica come UTF-8 — utile per format='txt'."""
|
|
43
|
+
if self.content is None:
|
|
44
|
+
return ""
|
|
45
|
+
return self.content.decode("utf-8", errors="replace")
|
|
46
|
+
|
|
47
|
+
def json(self) -> Dict[str, Any]:
|
|
48
|
+
"""Ritorna il dict data (format='json') o solleva."""
|
|
49
|
+
if self.data is None:
|
|
50
|
+
raise ValueError("ConversionResult has no JSON data (format != 'json')")
|
|
51
|
+
return self.data
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class JobResult:
|
|
56
|
+
"""Wrapper risultato per /v1/jobs/{id}/result.
|
|
57
|
+
|
|
58
|
+
Due forme:
|
|
59
|
+
- signed URL R2: `result_url` settato, `expires_in` in secondi
|
|
60
|
+
- file binario diretto: `content` bytes
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
result_url: Optional[str] = None
|
|
64
|
+
expires_in: Optional[int] = None
|
|
65
|
+
content: Optional[bytes] = None
|
|
66
|
+
content_type: Optional[str] = None
|
|
67
|
+
|
|
68
|
+
def save(self, path: str) -> None:
|
|
69
|
+
"""Salva il file. Se result_url è settato, scarica dal signed URL."""
|
|
70
|
+
if self.content is not None:
|
|
71
|
+
with open(path, "wb") as f:
|
|
72
|
+
f.write(self.content)
|
|
73
|
+
return
|
|
74
|
+
if self.result_url:
|
|
75
|
+
import urllib.request
|
|
76
|
+
with urllib.request.urlopen(self.result_url, timeout=60) as resp:
|
|
77
|
+
data = resp.read()
|
|
78
|
+
with open(path, "wb") as f:
|
|
79
|
+
f.write(data)
|
|
80
|
+
return
|
|
81
|
+
raise ValueError("JobResult has neither content nor result_url")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass
|
|
85
|
+
class Job:
|
|
86
|
+
"""Job async. Restituito da create_job() e get_job().
|
|
87
|
+
|
|
88
|
+
Usare `job.wait()` per polling fino a `done`/`failed`.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
request_id: str
|
|
92
|
+
status: JobStatus
|
|
93
|
+
model: Optional[str] = None
|
|
94
|
+
pages_used: Optional[int] = None
|
|
95
|
+
created_at: Optional[str] = None
|
|
96
|
+
completed_at: Optional[str] = None
|
|
97
|
+
error_detail: Optional[str] = None
|
|
98
|
+
_client: Any = field(default=None, repr=False)
|
|
99
|
+
|
|
100
|
+
@classmethod
|
|
101
|
+
def from_dict(cls, d: dict, client=None) -> "Job":
|
|
102
|
+
return cls(
|
|
103
|
+
request_id=d.get("request_id", ""),
|
|
104
|
+
status=JobStatus(d.get("status", "pending")),
|
|
105
|
+
model=d.get("model"),
|
|
106
|
+
pages_used=d.get("pages_used"),
|
|
107
|
+
created_at=d.get("created_at"),
|
|
108
|
+
completed_at=d.get("completed_at"),
|
|
109
|
+
error_detail=d.get("error_detail"),
|
|
110
|
+
_client=client,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def is_done(self) -> bool:
|
|
115
|
+
return self.status == JobStatus.DONE
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def is_failed(self) -> bool:
|
|
119
|
+
return self.status == JobStatus.FAILED
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def is_terminal(self) -> bool:
|
|
123
|
+
return self.status in (JobStatus.DONE, JobStatus.FAILED)
|
|
124
|
+
|
|
125
|
+
def refresh(self) -> "Job":
|
|
126
|
+
"""Ri-leggi lo stato dal server. Richiede il client originario."""
|
|
127
|
+
if self._client is None:
|
|
128
|
+
raise RuntimeError("Job not bound to a client (use client.get_job(request_id))")
|
|
129
|
+
updated = self._client.get_job(self.request_id)
|
|
130
|
+
# in-place update dei campi
|
|
131
|
+
self.status = updated.status
|
|
132
|
+
self.pages_used = updated.pages_used
|
|
133
|
+
self.completed_at = updated.completed_at
|
|
134
|
+
self.error_detail = updated.error_detail
|
|
135
|
+
return self
|
|
136
|
+
|
|
137
|
+
def wait(self, timeout: float = 600.0, initial_delay: float = 1.0,
|
|
138
|
+
max_delay: float = 15.0) -> "Job":
|
|
139
|
+
"""Polling con backoff esponenziale finché lo stato è terminale.
|
|
140
|
+
|
|
141
|
+
timeout: secondi totali prima di sollevare TimeoutError.
|
|
142
|
+
Default: 10 min, retry a 1s,2s,4s,8s,15s,15s,...
|
|
143
|
+
"""
|
|
144
|
+
import time
|
|
145
|
+
deadline = time.monotonic() + timeout
|
|
146
|
+
delay = initial_delay
|
|
147
|
+
while True:
|
|
148
|
+
self.refresh()
|
|
149
|
+
if self.is_terminal:
|
|
150
|
+
return self
|
|
151
|
+
if time.monotonic() >= deadline:
|
|
152
|
+
raise TimeoutError(
|
|
153
|
+
f"Job {self.request_id} still {self.status.value} after {timeout}s"
|
|
154
|
+
)
|
|
155
|
+
time.sleep(min(delay, max(0.1, deadline - time.monotonic())))
|
|
156
|
+
delay = min(delay * 2, max_delay)
|
|
157
|
+
|
|
158
|
+
def get_result(self) -> JobResult:
|
|
159
|
+
"""Richiede /v1/jobs/{id}/result. Solleva NotReady se status != done."""
|
|
160
|
+
if self._client is None:
|
|
161
|
+
raise RuntimeError("Job not bound to a client")
|
|
162
|
+
return self._client.get_job_result(self.request_id)
|
|
163
|
+
|
|
164
|
+
def download(self, path: str) -> None:
|
|
165
|
+
"""Shortcut: get_result().save(path)."""
|
|
166
|
+
self.get_result().save(path)
|
|
167
|
+
|
|
168
|
+
def delete(self) -> None:
|
|
169
|
+
"""Cancella job + cleanup file."""
|
|
170
|
+
if self._client is None:
|
|
171
|
+
raise RuntimeError("Job not bound to a client")
|
|
172
|
+
self._client.delete_job(self.request_id)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@dataclass
|
|
176
|
+
class BatchResult:
|
|
177
|
+
"""Risultato di `client.batch(...)`. Misto: jobs creati + errori per-file."""
|
|
178
|
+
|
|
179
|
+
batch_id: str
|
|
180
|
+
model: str
|
|
181
|
+
jobs: List[Job] = field(default_factory=list)
|
|
182
|
+
errors: List[Dict[str, Any]] = field(default_factory=list)
|
|
183
|
+
|
|
184
|
+
@property
|
|
185
|
+
def jobs_created(self) -> int:
|
|
186
|
+
return len(self.jobs)
|
|
187
|
+
|
|
188
|
+
def wait_all(self, timeout: float = 1200.0, parallel: bool = True, max_workers: int = 10) -> List[Job]:
|
|
189
|
+
"""Polling fino a stato terminale per tutti i job.
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
timeout: secondi per ogni singolo job
|
|
193
|
+
parallel: se True (default), usa ThreadPoolExecutor per pollare in parallelo.
|
|
194
|
+
Per 20 job, ~20× più veloce di sequenziale quando dominato da network.
|
|
195
|
+
max_workers: thread max (default 10). I/O bound quindi sicuro alzare.
|
|
196
|
+
"""
|
|
197
|
+
if not parallel or len(self.jobs) <= 1:
|
|
198
|
+
return [j.wait(timeout=timeout) for j in self.jobs]
|
|
199
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
200
|
+
with ThreadPoolExecutor(max_workers=min(max_workers, len(self.jobs))) as ex:
|
|
201
|
+
return list(ex.map(lambda j: j.wait(timeout=timeout), self.jobs))
|
myocr_client/webhook.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Helper per verificare la firma HMAC dei webhook in entrata.
|
|
2
|
+
|
|
3
|
+
myocr firma ogni webhook con header `X-MyOCR-Signature: sha256=<hex>` calcolato
|
|
4
|
+
come HMAC-SHA256 del body grezzo, usando come chiave il `WEBHOOK_SIGNING_SECRET`
|
|
5
|
+
che hai configurato lato server.
|
|
6
|
+
|
|
7
|
+
Esempio (Flask):
|
|
8
|
+
|
|
9
|
+
from flask import request
|
|
10
|
+
from myocr_client import verify_webhook_signature, MyOCRError
|
|
11
|
+
|
|
12
|
+
@app.route("/webhooks/myocr", methods=["POST"])
|
|
13
|
+
def myocr_webhook():
|
|
14
|
+
body = request.get_data() # raw bytes, non json parsed!
|
|
15
|
+
sig = request.headers.get("X-MyOCR-Signature", "")
|
|
16
|
+
if not verify_webhook_signature(body, sig, secret="my-shared-secret"):
|
|
17
|
+
return "invalid signature", 401
|
|
18
|
+
event = request.get_json()
|
|
19
|
+
# event = {"event": "job.completed", "data": {"request_id": "...", ...}}
|
|
20
|
+
return "", 200
|
|
21
|
+
|
|
22
|
+
Importante: usa `request.get_data()` (raw), NON `request.get_json()` — la firma
|
|
23
|
+
è calcolata sul body byte-per-byte come ricevuto, non sul JSON ri-serializzato.
|
|
24
|
+
"""
|
|
25
|
+
import hashlib
|
|
26
|
+
import hmac
|
|
27
|
+
from typing import Union
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def verify_webhook_signature(
|
|
31
|
+
body: Union[bytes, str],
|
|
32
|
+
signature: str,
|
|
33
|
+
secret: str,
|
|
34
|
+
) -> bool:
|
|
35
|
+
"""Verifica HMAC-SHA256 del body con segreto condiviso.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
body: raw bytes ricevuti (NON il JSON parsato).
|
|
39
|
+
signature: valore header 'X-MyOCR-Signature' (formato 'sha256=<hex>').
|
|
40
|
+
secret: WEBHOOK_SIGNING_SECRET condiviso col server myocr.
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
True se la firma è valida, False altrimenti.
|
|
44
|
+
|
|
45
|
+
Usa hmac.compare_digest per evitare timing attacks.
|
|
46
|
+
"""
|
|
47
|
+
if not signature or not secret:
|
|
48
|
+
return False
|
|
49
|
+
if isinstance(body, str):
|
|
50
|
+
body = body.encode("utf-8")
|
|
51
|
+
if not isinstance(secret, str):
|
|
52
|
+
secret = str(secret)
|
|
53
|
+
|
|
54
|
+
# Formato atteso: "sha256=<hex>"
|
|
55
|
+
expected_prefix = "sha256="
|
|
56
|
+
if signature.startswith(expected_prefix):
|
|
57
|
+
received = signature[len(expected_prefix):].strip()
|
|
58
|
+
else:
|
|
59
|
+
# Permettiamo anche solo l'hex per tolleranza
|
|
60
|
+
received = signature.strip()
|
|
61
|
+
|
|
62
|
+
computed = hmac.new(
|
|
63
|
+
secret.encode("utf-8"),
|
|
64
|
+
body,
|
|
65
|
+
hashlib.sha256,
|
|
66
|
+
).hexdigest()
|
|
67
|
+
|
|
68
|
+
return hmac.compare_digest(received, computed)
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: myocr-client
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Official Python SDK for myocr.app — convert PDFs and images to structured Excel using myocr's OCR engine.
|
|
5
|
+
Project-URL: Homepage, https://www.myocr.app
|
|
6
|
+
Project-URL: Documentation, https://www.myocr.app/docs/api
|
|
7
|
+
Project-URL: Repository, https://github.com/Selaf688/myocr-3.5
|
|
8
|
+
Project-URL: Bug Tracker, https://github.com/Selaf688/myocr-3.5/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/Selaf688/myocr-3.5/blob/main/sdk/python/CHANGELOG.md
|
|
10
|
+
Author-email: "MAD.AI SRL" <info@myocr.app>
|
|
11
|
+
License: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: api-client,document-extraction,excel,myocr,ocr,pdf
|
|
14
|
+
Classifier: Development Status :: 4 - Beta
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python
|
|
19
|
+
Classifier: Programming Language :: Python :: 3
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
25
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
26
|
+
Classifier: Topic :: Office/Business :: Office Suites
|
|
27
|
+
Classifier: Topic :: Scientific/Engineering :: Image Recognition
|
|
28
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
29
|
+
Requires-Python: >=3.8
|
|
30
|
+
Requires-Dist: requests>=2.28
|
|
31
|
+
Provides-Extra: dev
|
|
32
|
+
Requires-Dist: build>=1.0; extra == 'dev'
|
|
33
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
34
|
+
Requires-Dist: responses>=0.23; extra == 'dev'
|
|
35
|
+
Requires-Dist: twine>=4.0; extra == 'dev'
|
|
36
|
+
Description-Content-Type: text/markdown
|
|
37
|
+
|
|
38
|
+
# myocr-client — Python SDK for myocr.app
|
|
39
|
+
|
|
40
|
+
Official Python client for the **[myocr.app](https://www.myocr.app)** API. Convert PDFs and images to structured Excel using myocr's OCR engine (invoice, receipt, bank statement, business card, generic tables, plain text).
|
|
41
|
+
|
|
42
|
+
[](https://pypi.org/project/myocr-client/)
|
|
43
|
+
[](https://pypi.org/project/myocr-client/)
|
|
44
|
+
[](https://opensource.org/licenses/MIT)
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## Install
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pip install myocr-client
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Quick start
|
|
55
|
+
|
|
56
|
+
Get an API key at [/account/api](https://www.myocr.app/account/api) (signup required), then:
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from myocr_client import MyOCRClient
|
|
60
|
+
|
|
61
|
+
client = MyOCRClient(api_key="sk_live_...")
|
|
62
|
+
# or set MYOCR_API_KEY in env
|
|
63
|
+
|
|
64
|
+
# Synchronous conversion (≤5MB, ≤10 pages, returns immediately)
|
|
65
|
+
result = client.convert("invoice.pdf", model="invoice")
|
|
66
|
+
result.save("invoice.xlsx")
|
|
67
|
+
|
|
68
|
+
print(result.pages_used, result.model, result.request_id)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Models
|
|
72
|
+
|
|
73
|
+
| Model | Output | Best for |
|
|
74
|
+
|---|---|---|
|
|
75
|
+
| `tables` | xlsx with generic tables | Any structured table |
|
|
76
|
+
| `text` | plain txt | OCR text extraction |
|
|
77
|
+
| `invoice` | xlsx with Vendor / Customer / Total / Line items | Invoices, bills |
|
|
78
|
+
| `receipt` | xlsx with Merchant / Date / Items / Total | Receipts |
|
|
79
|
+
| `bank_statement` | xlsx with Account / Transactions sheet | Bank statements |
|
|
80
|
+
| `business_card` | xlsx with Contact / Company / Phones / Emails | Business cards |
|
|
81
|
+
|
|
82
|
+
## Async jobs (files > 5MB or > 10 pages)
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
job = client.create_job(
|
|
86
|
+
"annual_report.pdf",
|
|
87
|
+
model="bank_statement",
|
|
88
|
+
webhook_url="https://your.app/webhooks/myocr", # optional
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
# Option 1: polling with exponential backoff
|
|
92
|
+
job.wait(timeout=600)
|
|
93
|
+
job.download("report.xlsx")
|
|
94
|
+
|
|
95
|
+
# Option 2: notified via webhook (preferred for prod) — see "Webhook verification" below
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Batch (1–20 files in one call)
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
result = client.batch(
|
|
102
|
+
["a.pdf", "b.pdf", "c.pdf"],
|
|
103
|
+
model="invoice",
|
|
104
|
+
webhook_url="https://your.app/webhooks/myocr",
|
|
105
|
+
)
|
|
106
|
+
print(result.jobs_created, "jobs queued;", len(result.errors), "errors")
|
|
107
|
+
|
|
108
|
+
# Wait for all and download
|
|
109
|
+
for job in result.wait_all(timeout=1200):
|
|
110
|
+
if job.is_done:
|
|
111
|
+
job.download(f"{job.request_id}.xlsx")
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Webhook verification
|
|
115
|
+
|
|
116
|
+
myocr signs every webhook with HMAC-SHA256 (header `X-MyOCR-Signature: sha256=<hex>`). Always verify before trusting the payload — and **use raw bytes**, not the parsed JSON:
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
from flask import Flask, request
|
|
120
|
+
from myocr_client import verify_webhook_signature
|
|
121
|
+
|
|
122
|
+
app = Flask(__name__)
|
|
123
|
+
SECRET = "your-shared-secret" # same as server WEBHOOK_SIGNING_SECRET
|
|
124
|
+
|
|
125
|
+
@app.route("/webhooks/myocr", methods=["POST"])
|
|
126
|
+
def myocr_webhook():
|
|
127
|
+
body = request.get_data() # raw bytes, NOT request.get_json()
|
|
128
|
+
sig = request.headers.get("X-MyOCR-Signature", "")
|
|
129
|
+
if not verify_webhook_signature(body, sig, SECRET):
|
|
130
|
+
return "invalid signature", 401
|
|
131
|
+
|
|
132
|
+
event = request.get_json() # safe now
|
|
133
|
+
# {"event": "job.completed", "data": {"request_id": "...", "status": "done", ...}}
|
|
134
|
+
return "", 200
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Events: `job.completed`, `job.failed`.
|
|
138
|
+
Retry policy: 1m → 5m → 30m → 2h (4 retries beyond the first attempt).
|
|
139
|
+
|
|
140
|
+
## Error handling
|
|
141
|
+
|
|
142
|
+
Every error code maps to a typed exception:
|
|
143
|
+
|
|
144
|
+
```python
|
|
145
|
+
from myocr_client import MyOCRClient, QuotaExceeded, InvalidApiKey, OcrEngineError
|
|
146
|
+
|
|
147
|
+
client = MyOCRClient(api_key="sk_live_...")
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
result = client.convert("doc.pdf", model="invoice")
|
|
151
|
+
except QuotaExceeded as e:
|
|
152
|
+
print(f"Plan {e.current_plan}, used {e.calls_used}/{e.calls_limit}")
|
|
153
|
+
print(f"Upgrade: {e.upgrade_url}")
|
|
154
|
+
print(f"Resets: {e.reset_date}")
|
|
155
|
+
except InvalidApiKey:
|
|
156
|
+
print("Rotate your key from /account/api")
|
|
157
|
+
except OcrEngineError:
|
|
158
|
+
print("OCR engine upstream failure; safe to retry")
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
| Exception | HTTP | Code |
|
|
162
|
+
|---|---|---|
|
|
163
|
+
| `MissingApiKey` | 401 | `MISSING_API_KEY` |
|
|
164
|
+
| `InvalidApiKey` | 401 | `INVALID_API_KEY` |
|
|
165
|
+
| `UnsupportedModel` | 400 | `UNSUPPORTED_MODEL` |
|
|
166
|
+
| `UnsupportedFileType` | 400 | `UNSUPPORTED_FILE_TYPE` |
|
|
167
|
+
| `MissingFile` | 400 | `MISSING_FILE` |
|
|
168
|
+
| `FileTooLarge` | 413 | `FILE_TOO_LARGE` |
|
|
169
|
+
| `TooManyPages` | 413 | `TOO_MANY_PAGES` |
|
|
170
|
+
| `InvalidWebhookUrl` | 400 | `INVALID_WEBHOOK_URL` |
|
|
171
|
+
| `QuotaExceeded` | 402 | `QUOTA_EXCEEDED` |
|
|
172
|
+
| `NotReady` | 409 | `NOT_READY` |
|
|
173
|
+
| `NotFound` | 404 | `NOT_FOUND` |
|
|
174
|
+
| `OcrEngineError` | 502 | `OCR_ERROR` |
|
|
175
|
+
| `StorageError` | 503 | `STORAGE_ERROR` |
|
|
176
|
+
| `RateLimited` | 429 | — |
|
|
177
|
+
| `ServiceNotReady` | 503 | `SERVICE_NOT_READY` |
|
|
178
|
+
| `InternalError` | 500 | `INTERNAL_ERROR` |
|
|
179
|
+
|
|
180
|
+
The SDK automatically retries `429` and `5xx` responses up to 3 times with exponential backoff (honoring `Retry-After` when present). After retries exhausted the exception is raised.
|
|
181
|
+
|
|
182
|
+
## Input flexibility
|
|
183
|
+
|
|
184
|
+
`client.convert()` and `client.create_job()` accept:
|
|
185
|
+
|
|
186
|
+
- A file path: `client.convert("/path/to/doc.pdf", ...)`
|
|
187
|
+
- Raw bytes: `client.convert(pdf_bytes, filename="doc.pdf", ...)`
|
|
188
|
+
- A file-like object: `with open("doc.pdf", "rb") as f: client.convert(f, ...)`
|
|
189
|
+
|
|
190
|
+
## Configuration
|
|
191
|
+
|
|
192
|
+
| Argument | Env var | Default |
|
|
193
|
+
|---|---|---|
|
|
194
|
+
| `api_key` | `MYOCR_API_KEY` | — (required) |
|
|
195
|
+
| `base_url` | `MYOCR_BASE_URL` | `https://api.myocr.app` |
|
|
196
|
+
| `timeout` | — | 60s |
|
|
197
|
+
| `retry_attempts` | — | 3 |
|
|
198
|
+
| `session` | — | new `requests.Session()` |
|
|
199
|
+
|
|
200
|
+
For staging:
|
|
201
|
+
|
|
202
|
+
```python
|
|
203
|
+
client = MyOCRClient(api_key="sk_test_...", base_url="https://beta.myocr.app")
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
## Monitor your quota
|
|
207
|
+
|
|
208
|
+
Check current month usage programmatically (e.g. to upgrade before exhaustion):
|
|
209
|
+
|
|
210
|
+
```python
|
|
211
|
+
usage = client.usage()
|
|
212
|
+
# {
|
|
213
|
+
# "plan": "free", "calls_used": 42, "calls_limit": 100,
|
|
214
|
+
# "percentage": 42.0, "reset_date": "2026-06-01T00:00:00",
|
|
215
|
+
# "year_month": "2026-05", "is_test_key": False
|
|
216
|
+
# }
|
|
217
|
+
if usage["percentage"] and usage["percentage"] > 80:
|
|
218
|
+
# alert ops, upgrade plan, or stop background workers
|
|
219
|
+
...
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
## Status & limits
|
|
223
|
+
|
|
224
|
+
```python
|
|
225
|
+
status = client.status()
|
|
226
|
+
# {
|
|
227
|
+
# "service": "myocr.app API", "version": "v1",
|
|
228
|
+
# "models_supported": ["bank_statement", "business_card", ...],
|
|
229
|
+
# "features": {"sync_convert": True, "async_jobs": True, "webhook": True, ...},
|
|
230
|
+
# "limits": {"sync_max_bytes": 5242880, "sync_max_pages": 10,
|
|
231
|
+
# "jobs_max_bytes": 52428800, "sync_rate_per_minute": 60,
|
|
232
|
+
# "jobs_rate_per_minute": 120}
|
|
233
|
+
# }
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
## Rate limits (server-side)
|
|
237
|
+
|
|
238
|
+
| Endpoint | Limit |
|
|
239
|
+
|---|---|
|
|
240
|
+
| `POST /v1/convert` | 60 / min |
|
|
241
|
+
| `POST /v1/jobs` | 120 / min |
|
|
242
|
+
| `POST /v1/batch` | 30 / min |
|
|
243
|
+
|
|
244
|
+
The SDK handles `429` with automatic retry. If you saturate the quota, upgrade your plan from the dashboard.
|
|
245
|
+
|
|
246
|
+
## Reference
|
|
247
|
+
|
|
248
|
+
- **Full OpenAPI spec:** [openapi.yaml](https://www.myocr.app/static/openapi.yaml)
|
|
249
|
+
- **Interactive docs:** [/docs/api](https://www.myocr.app/docs/api) (Scalar UI)
|
|
250
|
+
- **Dashboard:** [/account/api](https://www.myocr.app/account/api) — manage keys, view usage, upgrade
|
|
251
|
+
- **Webhook signing secret:** generated when you create a webhook integration; shared via dashboard.
|
|
252
|
+
|
|
253
|
+
## Development
|
|
254
|
+
|
|
255
|
+
```bash
|
|
256
|
+
git clone https://github.com/Selaf688/myocr-3.5
|
|
257
|
+
cd myocr-3.5/sdk/python
|
|
258
|
+
pip install -e ".[dev]"
|
|
259
|
+
pytest -v
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
## Versioning
|
|
263
|
+
|
|
264
|
+
Semantic versioning. The API itself is `v1` and stable; the SDK can release patch/minor independently.
|
|
265
|
+
|
|
266
|
+
## License
|
|
267
|
+
|
|
268
|
+
MIT. See [LICENSE](./LICENSE).
|
|
269
|
+
|
|
270
|
+
## Support
|
|
271
|
+
|
|
272
|
+
- Documentation: <https://www.myocr.app/docs/api>
|
|
273
|
+
- Email: info@myocr.app
|
|
274
|
+
- Issues: <https://github.com/Selaf688/myocr-3.5/issues>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
myocr_client/__init__.py,sha256=98unZ8qK-HnFMZRS_3zbw8LWTi1ixmCVnuvvabSWQAc,1493
|
|
2
|
+
myocr_client/client.py,sha256=HO5FSSggcKpzWFCqX7jSm571aL3uYw3qF3iyQKfgXck,13936
|
|
3
|
+
myocr_client/exceptions.py,sha256=MMmjzztMSPa6uXGZ6TWxW3VBAVxuHH228Y-9aT4rDo4,6273
|
|
4
|
+
myocr_client/models.py,sha256=BtcYEb8N_N9N6w494mtoNFrYZsz6Dk0VeSOKY3oqLMU,6948
|
|
5
|
+
myocr_client/webhook.py,sha256=aYeUINjAHCzgeGpaQSnuB9auX91Q7kkb2t4NaBmVNXE,2203
|
|
6
|
+
myocr_client-0.2.0.dist-info/METADATA,sha256=0t6cXbu53sWi7lSf5leO2RMGLTBTdBwcpK-fwDOIUnw,9198
|
|
7
|
+
myocr_client-0.2.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
8
|
+
myocr_client-0.2.0.dist-info/licenses/LICENSE,sha256=XOo4nn9CoGzqCDnfV2Mk2Nt_-g0RJ1xM_775tuWQ9Bo,1067
|
|
9
|
+
myocr_client-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 MAD.AI SRL
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|