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.
@@ -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))
@@ -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
+ [![PyPI version](https://img.shields.io/pypi/v/myocr-client.svg)](https://pypi.org/project/myocr-client/)
43
+ [![Python versions](https://img.shields.io/pypi/pyversions/myocr-client.svg)](https://pypi.org/project/myocr-client/)
44
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.