scanverion 0.1.0__tar.gz

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,70 @@
1
+ Metadata-Version: 2.4
2
+ Name: scanverion
3
+ Version: 0.1.0
4
+ Summary: Python client for the Scanverion OCR API.
5
+ Author: Scanverion
6
+ License: Apache-2.0
7
+ Classifier: License :: OSI Approved :: Apache Software License
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.10
10
+ Classifier: Programming Language :: Python :: 3.11
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Classifier: Programming Language :: Python :: 3.13
13
+ Classifier: Programming Language :: Python :: 3.14
14
+ Requires-Python: >=3.10
15
+ Description-Content-Type: text/markdown
16
+
17
+ # scanverion
18
+
19
+ Node and Python are the first supported Scanverion client languages. This package provides synchronous and asynchronous standard-library clients for the OCR API.
20
+
21
+ ## Download and install
22
+
23
+ Install the published package from PyPI:
24
+
25
+ ```powershell
26
+ python -m pip install scanverion==0.1.0
27
+ ```
28
+
29
+ It requires Python 3.10 or newer.
30
+
31
+ ## Quickstart
32
+
33
+ ```python
34
+ from pathlib import Path
35
+ from scanverion import Scanverion
36
+
37
+ client = Scanverion(
38
+ api_key="skv_...",
39
+ base_url="http://localhost:5009",
40
+ )
41
+ result = client.documents.process(
42
+ Path("invoice.pdf"),
43
+ file_name="invoice.pdf",
44
+ content_type="application/pdf",
45
+ operations=["Detection", "Scanner"],
46
+ scanner_fields=["invoiceNumber", "grossTotal"],
47
+ idempotency_key="invoice-2026-0007",
48
+ )
49
+ print(result["requestId"], result["objects"])
50
+ ```
51
+
52
+ The core methods accept `bytes`, `bytearray`, `memoryview`, `pathlib.Path`, or binary file objects. `AsyncScanverion` exposes the same resource groups and uses an injected async transport or `asyncio.to_thread` with the default transport.
53
+
54
+ ```python
55
+ from scanverion import AsyncScanverion
56
+
57
+ async with AsyncScanverion(api_key="skv_...") as client:
58
+ summary = await client.usage.summary(from_date="2026-07-01", to_date="2026-07-31")
59
+ ```
60
+
61
+ API keys are server-side credentials. Do not put them in browser code, mobile binaries, logs, or public fixtures. Processing POST retries happen only when an idempotency key is supplied; safe GET resources may retry transient failures.
62
+
63
+ ## Local validation
64
+
65
+ From the `scanverion` directory:
66
+
67
+ ```powershell
68
+ python -m unittest discover -s packages/sdk-python/tests -v
69
+ python -m compileall packages/sdk-python/src
70
+ ```
@@ -0,0 +1,54 @@
1
+ # scanverion
2
+
3
+ Node and Python are the first supported Scanverion client languages. This package provides synchronous and asynchronous standard-library clients for the OCR API.
4
+
5
+ ## Download and install
6
+
7
+ Install the published package from PyPI:
8
+
9
+ ```powershell
10
+ python -m pip install scanverion==0.1.0
11
+ ```
12
+
13
+ It requires Python 3.10 or newer.
14
+
15
+ ## Quickstart
16
+
17
+ ```python
18
+ from pathlib import Path
19
+ from scanverion import Scanverion
20
+
21
+ client = Scanverion(
22
+ api_key="skv_...",
23
+ base_url="http://localhost:5009",
24
+ )
25
+ result = client.documents.process(
26
+ Path("invoice.pdf"),
27
+ file_name="invoice.pdf",
28
+ content_type="application/pdf",
29
+ operations=["Detection", "Scanner"],
30
+ scanner_fields=["invoiceNumber", "grossTotal"],
31
+ idempotency_key="invoice-2026-0007",
32
+ )
33
+ print(result["requestId"], result["objects"])
34
+ ```
35
+
36
+ The core methods accept `bytes`, `bytearray`, `memoryview`, `pathlib.Path`, or binary file objects. `AsyncScanverion` exposes the same resource groups and uses an injected async transport or `asyncio.to_thread` with the default transport.
37
+
38
+ ```python
39
+ from scanverion import AsyncScanverion
40
+
41
+ async with AsyncScanverion(api_key="skv_...") as client:
42
+ summary = await client.usage.summary(from_date="2026-07-01", to_date="2026-07-31")
43
+ ```
44
+
45
+ API keys are server-side credentials. Do not put them in browser code, mobile binaries, logs, or public fixtures. Processing POST retries happen only when an idempotency key is supplied; safe GET resources may retry transient failures.
46
+
47
+ ## Local validation
48
+
49
+ From the `scanverion` directory:
50
+
51
+ ```powershell
52
+ python -m unittest discover -s packages/sdk-python/tests -v
53
+ python -m compileall packages/sdk-python/src
54
+ ```
@@ -0,0 +1,27 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "scanverion"
7
+ version = "0.1.0"
8
+ description = "Python client for the Scanverion OCR API."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "Apache-2.0" }
12
+ authors = [{ name = "Scanverion" }]
13
+ classifiers = [
14
+ "License :: OSI Approved :: Apache Software License",
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3.10",
17
+ "Programming Language :: Python :: 3.11",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Programming Language :: Python :: 3.13",
20
+ "Programming Language :: Python :: 3.14",
21
+ ]
22
+
23
+ [tool.setuptools.packages.find]
24
+ where = ["src"]
25
+
26
+ [tool.setuptools.package-data]
27
+ scanverion = ["py.typed"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,23 @@
1
+ """Small, dependency-free Scanverion API client."""
2
+
3
+ from .client import (
4
+ AnalysisClient,
5
+ AsyncScanverion,
6
+ Scanverion,
7
+ ScanverionApiError,
8
+ UsageClient,
9
+ )
10
+ from .models import (
11
+ Capability,
12
+ CapabilityCatalogResponse,
13
+ CapabilityUsageDetail,
14
+ CapabilityUsageResponse,
15
+ DailyUsageResponse,
16
+ RealtimeAnalysisBatchResponse,
17
+ RealtimeAnalysisResponse,
18
+ UsagePricePreview,
19
+ UsageSummary,
20
+ )
21
+
22
+ __all__ = ["AnalysisClient", "AsyncScanverion", "Capability", "CapabilityCatalogResponse", "CapabilityUsageDetail", "CapabilityUsageResponse", "DailyUsageResponse", "RealtimeAnalysisBatchResponse", "RealtimeAnalysisResponse", "Scanverion", "ScanverionApiError", "UsageClient", "UsagePricePreview", "UsageSummary"]
23
+ __version__ = "0.1.0"
@@ -0,0 +1,284 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import mimetypes
6
+ import secrets
7
+ import time
8
+ import urllib.error
9
+ import urllib.request
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+ from typing import Any, BinaryIO, Callable, Mapping, Protocol, Sequence
13
+ from urllib.parse import quote, urlencode
14
+ from .models import Capability, CapabilityCatalogResponse, CapabilityUsageDetail, CapabilityUsageResponse, DailyUsageResponse, RealtimeAnalysisBatchResponse, RealtimeAnalysisResponse, UsagePricePreview, UsageSummary
15
+
16
+ JsonObject = dict[str, Any]
17
+ FileInput = bytes | bytearray | memoryview | str | Path | BinaryIO
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class TransportResponse:
22
+ status: int
23
+ headers: Mapping[str, str]
24
+ body: bytes
25
+
26
+
27
+ class Transport(Protocol):
28
+ def __call__(self, method: str, url: str, headers: Mapping[str, str], body: bytes | None, timeout: float) -> TransportResponse: ...
29
+
30
+
31
+ class AsyncTransport(Protocol):
32
+ async def __call__(self, method: str, url: str, headers: Mapping[str, str], body: bytes | None, timeout: float) -> TransportResponse: ...
33
+
34
+
35
+ class ScanverionApiError(RuntimeError):
36
+ def __init__(self, message: str, status: int, problem: JsonObject | None = None, request_id: str | None = None):
37
+ super().__init__(message)
38
+ self.status = status
39
+ self.problem = problem
40
+ self.request_id = request_id
41
+ self.retryable = bool(problem.get("retryable")) if problem and "retryable" in problem else status in (408, 429) or status >= 500
42
+
43
+
44
+ class AnalysisClient:
45
+ def __init__(self, client: Scanverion, vehicle: bool = False):
46
+ self._client = client
47
+ self._vehicle = vehicle
48
+
49
+ def process(self, file: FileInput, *, file_name: str | None = None, content_type: str | None = None, operations: Sequence[str] | None = None, disable_object_segmentation: bool | None = None, scanner_fields: Sequence[str] | None = None, checkbox_definitions: str | None = None, idempotency_key: str | None = None) -> RealtimeAnalysisResponse:
50
+ route = "/api/v1/ai/vehicle-processing" if self._vehicle else "/api/v1/ai/document-processing"
51
+ fields = {"File": [(file, file_name, content_type)]}
52
+ return self._client._post_multipart(route, fields, operations=operations, disable_object_segmentation=disable_object_segmentation, scanner_fields=scanner_fields, checkbox_definitions=checkbox_definitions, idempotency_key=idempotency_key)
53
+
54
+ def process_batch(self, files: Sequence[FileInput], *, file_names: Sequence[str | None] | None = None, content_types: Sequence[str | None] | None = None, operations: Sequence[str] | None = None, disable_object_segmentation: bool | None = None, scanner_fields: Sequence[str] | None = None, checkbox_definitions: str | None = None, idempotency_key: str | None = None) -> RealtimeAnalysisBatchResponse:
55
+ if not files:
56
+ raise ValueError("Scanverion requires at least one file.")
57
+ route = "/api/v1/ai/vehicle-processing/batches" if self._vehicle else "/api/v1/ai/document-processing/batches"
58
+ entries = [(file, file_names[index] if file_names and index < len(file_names) else None, content_types[index] if content_types and index < len(content_types) else None) for index, file in enumerate(files)]
59
+ return self._client._post_multipart(route, {"Files": entries}, operations=operations, disable_object_segmentation=disable_object_segmentation, scanner_fields=scanner_fields, checkbox_definitions=checkbox_definitions, idempotency_key=idempotency_key)
60
+
61
+
62
+ class UsageClient:
63
+ def __init__(self, client: Scanverion):
64
+ self._client = client
65
+
66
+ def summary(self, *, from_date: str | None = None, to_date: str | None = None) -> UsageSummary:
67
+ return self._client._get("/api/v1/usage/summary", from_date, to_date)
68
+
69
+ def daily(self, *, from_date: str | None = None, to_date: str | None = None) -> DailyUsageResponse:
70
+ return self._client._get("/api/v1/usage/daily", from_date, to_date)
71
+
72
+ def by_capability(self, *, from_date: str | None = None, to_date: str | None = None) -> CapabilityUsageResponse:
73
+ return self._client._get("/api/v1/usage/by-capability", from_date, to_date)
74
+
75
+ def price_preview(self, *, from_date: str | None = None, to_date: str | None = None) -> UsagePricePreview:
76
+ return self._client._get("/api/v1/usage/price-preview", from_date, to_date)
77
+
78
+
79
+ class Scanverion:
80
+ def __init__(self, api_key: str, *, base_url: str = "http://localhost:5009", timeout: float = 60.0, max_retries: int = 2, transport: Transport | None = None, user_agent: str | None = None):
81
+ if not api_key.strip():
82
+ raise ValueError("Scanverion requires a non-empty api_key.")
83
+ if timeout <= 0 or max_retries < 0:
84
+ raise ValueError("timeout must be positive and max_retries must not be negative.")
85
+ self.api_key = api_key
86
+ self.base_url = base_url.rstrip("/")
87
+ self.timeout = timeout
88
+ self.max_retries = max_retries
89
+ self.user_agent = user_agent
90
+ self._transport = transport or default_transport
91
+ self.documents = AnalysisClient(self)
92
+ self.vehicles = AnalysisClient(self, vehicle=True)
93
+ self.usage = UsageClient(self)
94
+
95
+ def capabilities(self) -> list[Capability]:
96
+ return self._get("/api/v1/ai/capabilities")
97
+
98
+ def catalog_capabilities(self) -> CapabilityCatalogResponse:
99
+ return self._get("/api/v1/catalog/capabilities")
100
+
101
+ def capability_usage(self, capability_key: str, *, from_date: str | None = None, to_date: str | None = None) -> CapabilityUsageDetail:
102
+ return self._get(f"/api/v1/catalog/capabilities/{quote(capability_key, safe='')}/usage", from_date, to_date)
103
+
104
+ def _get(self, path: str, from_date: str | None = None, to_date: str | None = None) -> Any:
105
+ query = {key: value for key, value in (("from", from_date), ("to", to_date)) if value is not None}
106
+ return self._request("GET", f"{path}?{urlencode(query)}" if query else path, None, None, retryable=True)
107
+
108
+ def _post_multipart(self, path: str, fields: Mapping[str, Sequence[tuple[FileInput, str | None, str | None]]], *, operations: Sequence[str] | None, disable_object_segmentation: bool | None, scanner_fields: Sequence[str] | None, checkbox_definitions: str | None, idempotency_key: str | None) -> JsonObject:
109
+ parts: list[tuple[str, bytes, str | None, str | None]] = []
110
+ for field_name, files in fields.items():
111
+ for file, file_name, content_type in files:
112
+ payload, resolved_name, resolved_type = read_file_input(file, file_name, content_type)
113
+ parts.append((field_name, payload, resolved_name, resolved_type))
114
+ text_fields: list[tuple[str, str]] = [("Operations", operation) for operation in (operations or ["Detection"])]
115
+ if disable_object_segmentation is not None:
116
+ text_fields.append(("DisableObjectSegmentation", str(disable_object_segmentation).lower()))
117
+ text_fields.extend(("ScannerFields", field) for field in (scanner_fields or []))
118
+ if checkbox_definitions is not None:
119
+ text_fields.append(("CheckboxDefinitions", checkbox_definitions))
120
+ body, content_type = encode_multipart(parts, text_fields)
121
+ headers = {"Accept": "application/json", "Content-Type": content_type, "Authorization": f"Bearer {self.api_key}"}
122
+ if self.user_agent:
123
+ headers["User-Agent"] = self.user_agent
124
+ if idempotency_key:
125
+ headers["Idempotency-Key"] = idempotency_key
126
+ return self._request("POST", path, body, headers, retryable=bool(idempotency_key))
127
+
128
+ def _request(self, method: str, path: str, body: bytes | None, headers: Mapping[str, str] | None, *, retryable: bool) -> Any:
129
+ request_headers = dict(headers or {"Accept": "application/json", "Authorization": f"Bearer {self.api_key}"})
130
+ attempts = self.max_retries + 1 if retryable else 1
131
+ for attempt in range(attempts):
132
+ try:
133
+ response = self._transport(method, f"{self.base_url}{path}", request_headers, body, self.timeout)
134
+ except Exception:
135
+ if not retryable or attempt == attempts - 1:
136
+ raise
137
+ time.sleep(min(2**attempt, 10))
138
+ continue
139
+ if 200 <= response.status < 300:
140
+ return json.loads(response.body.decode("utf-8")) if response.body else None
141
+ problem = json.loads(response.body.decode("utf-8")) if response.body else None
142
+ request_id = response.headers.get("X-Request-Id") or (problem or {}).get("requestId")
143
+ error = ScanverionApiError((problem or {}).get("detail") or (problem or {}).get("title") or "Scanverion request failed.", response.status, problem, request_id)
144
+ if not error.retryable or not retryable or attempt == attempts - 1:
145
+ raise error
146
+ retry_after = response.headers.get("Retry-After")
147
+ time.sleep(min(float(retry_after) if retry_after else 2**attempt, 10))
148
+ raise RuntimeError("Scanverion request retry loop ended unexpectedly.")
149
+
150
+
151
+ class AsyncScanverion:
152
+ def __init__(self, api_key: str, *, base_url: str = "http://localhost:5009", timeout: float = 60.0, max_retries: int = 2, transport: AsyncTransport | None = None, user_agent: str | None = None):
153
+ self._client = Scanverion(api_key, base_url=base_url, timeout=timeout, max_retries=max_retries, user_agent=user_agent)
154
+ self._transport = transport
155
+ self.documents = AsyncAnalysisClient(self, vehicle=False)
156
+ self.vehicles = AsyncAnalysisClient(self, vehicle=True)
157
+ self.usage = AsyncUsageClient(self)
158
+
159
+ async def __aenter__(self) -> AsyncScanverion:
160
+ return self
161
+
162
+ async def __aexit__(self, *_: object) -> None:
163
+ return None
164
+
165
+ async def capabilities(self) -> list[Capability]:
166
+ return await self._get("/api/v1/ai/capabilities")
167
+
168
+ async def catalog_capabilities(self) -> CapabilityCatalogResponse:
169
+ return await self._get("/api/v1/catalog/capabilities")
170
+
171
+ async def _get(self, path: str, from_date: str | None = None, to_date: str | None = None) -> Any:
172
+ query = {key: value for key, value in (("from", from_date), ("to", to_date)) if value is not None}
173
+ return await self._request("GET", f"{path}?{urlencode(query)}" if query else path, None, retryable=True)
174
+
175
+ async def _post_multipart(self, path: str, fields: Mapping[str, Sequence[tuple[FileInput, str | None, str | None]]], **kwargs: Any) -> JsonObject:
176
+ body, content_type = build_multipart_request(fields, kwargs)
177
+ return await self._request("POST", path, body, retryable=bool(kwargs.get("idempotency_key")), content_type=content_type, idempotency_key=kwargs.get("idempotency_key"))
178
+
179
+ async def _request(self, method: str, path: str, body: bytes | None, *, retryable: bool, content_type: str | None = None, idempotency_key: str | None = None) -> Any:
180
+ headers = {"Accept": "application/json", "Authorization": f"Bearer {self._client.api_key}"}
181
+ if content_type:
182
+ headers["Content-Type"] = content_type
183
+ if self._client.user_agent:
184
+ headers["User-Agent"] = self._client.user_agent
185
+ if idempotency_key:
186
+ headers["Idempotency-Key"] = idempotency_key
187
+ if self._transport:
188
+ return await async_request(self._transport, self._client, method, path, body, headers, retryable)
189
+ return await asyncio.to_thread(self._client._request, method, path, body, headers, retryable=retryable)
190
+
191
+
192
+ class AsyncAnalysisClient:
193
+ def __init__(self, client: AsyncScanverion, vehicle: bool):
194
+ self._client = client
195
+ self._vehicle = vehicle
196
+
197
+ async def process(self, file: FileInput, **kwargs: Any) -> RealtimeAnalysisResponse:
198
+ route = "/api/v1/ai/vehicle-processing" if self._vehicle else "/api/v1/ai/document-processing"
199
+ return await self._client._post_multipart(route, {"File": [(file, kwargs.pop("file_name", None), kwargs.pop("content_type", None))]}, **kwargs)
200
+
201
+ async def process_batch(self, files: Sequence[FileInput], **kwargs: Any) -> RealtimeAnalysisBatchResponse:
202
+ if not files:
203
+ raise ValueError("Scanverion requires at least one file.")
204
+ names = kwargs.pop("file_names", None)
205
+ types = kwargs.pop("content_types", None)
206
+ entries = [(file, names[index] if names and index < len(names) else None, types[index] if types and index < len(types) else None) for index, file in enumerate(files)]
207
+ route = "/api/v1/ai/vehicle-processing/batches" if self._vehicle else "/api/v1/ai/document-processing/batches"
208
+ return await self._client._post_multipart(route, {"Files": entries}, **kwargs)
209
+
210
+
211
+ class AsyncUsageClient:
212
+ def __init__(self, client: AsyncScanverion):
213
+ self._client = client
214
+
215
+ async def summary(self, **kwargs: str | None) -> UsageSummary:
216
+ return await self._client._get("/api/v1/usage/summary", kwargs.get("from_date"), kwargs.get("to_date"))
217
+
218
+ async def daily(self, **kwargs: str | None) -> DailyUsageResponse:
219
+ return await self._client._get("/api/v1/usage/daily", kwargs.get("from_date"), kwargs.get("to_date"))
220
+
221
+ async def by_capability(self, **kwargs: str | None) -> CapabilityUsageResponse:
222
+ return await self._client._get("/api/v1/usage/by-capability", kwargs.get("from_date"), kwargs.get("to_date"))
223
+
224
+ async def price_preview(self, **kwargs: str | None) -> UsagePricePreview:
225
+ return await self._client._get("/api/v1/usage/price-preview", kwargs.get("from_date"), kwargs.get("to_date"))
226
+
227
+
228
+ def read_file_input(file: FileInput, file_name: str | None, content_type: str | None) -> tuple[bytes, str, str]:
229
+ if isinstance(file, (str, Path)):
230
+ path = Path(file)
231
+ return path.read_bytes(), file_name or path.name, content_type or mimetypes.guess_type(path.name)[0] or "application/octet-stream"
232
+ if isinstance(file, bytes):
233
+ return file, file_name or "file.bin", content_type or "application/octet-stream"
234
+ if isinstance(file, (bytearray, memoryview)):
235
+ return bytes(file), file_name or "file.bin", content_type or "application/octet-stream"
236
+ return file.read(), file_name or "file.bin", content_type or "application/octet-stream"
237
+
238
+
239
+ def encode_multipart(files: Sequence[tuple[str, bytes, str, str]], text_fields: Sequence[tuple[str, str]]) -> tuple[bytes, str]:
240
+ boundary = "scanverion-" + secrets.token_hex(12)
241
+ chunks: list[bytes] = []
242
+ for name, value in text_fields:
243
+ chunks.extend([f"--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\"\r\n\r\n{value}\r\n".encode(), b""])
244
+ for name, payload, file_name, content_type in files:
245
+ chunks.extend([f"--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\"; filename=\"{file_name}\"\r\nContent-Type: {content_type}\r\n\r\n".encode(), payload, b"\r\n"])
246
+ chunks.append(f"--{boundary}--\r\n".encode())
247
+ return b"".join(chunks), f"multipart/form-data; boundary={boundary}"
248
+
249
+
250
+ def build_multipart_request(fields: Mapping[str, Sequence[tuple[FileInput, str | None, str | None]]], options: Mapping[str, Any]) -> tuple[bytes, str]:
251
+ files: list[tuple[str, bytes, str, str]] = []
252
+ for name, entries in fields.items():
253
+ for file, file_name, content_type in entries:
254
+ files.append((name, *read_file_input(file, file_name, content_type)))
255
+ text_fields = [("Operations", operation) for operation in options.get("operations") or ["Detection"]]
256
+ if options.get("disable_object_segmentation") is not None:
257
+ text_fields.append(("DisableObjectSegmentation", str(options["disable_object_segmentation"]).lower()))
258
+ text_fields.extend(("ScannerFields", field) for field in options.get("scanner_fields") or [])
259
+ if options.get("checkbox_definitions") is not None:
260
+ text_fields.append(("CheckboxDefinitions", options["checkbox_definitions"]))
261
+ return encode_multipart(files, text_fields)
262
+
263
+
264
+ def default_transport(method: str, url: str, headers: Mapping[str, str], body: bytes | None, timeout: float) -> TransportResponse:
265
+ request = urllib.request.Request(url, data=body, headers=dict(headers), method=method)
266
+ try:
267
+ with urllib.request.urlopen(request, timeout=timeout) as response:
268
+ return TransportResponse(response.status, dict(response.headers.items()), response.read())
269
+ except urllib.error.HTTPError as error:
270
+ return TransportResponse(error.code, dict(error.headers.items()), error.read())
271
+
272
+
273
+ async def async_request(transport: AsyncTransport, client: Scanverion, method: str, path: str, body: bytes | None, headers: Mapping[str, str], retryable: bool) -> Any:
274
+ attempts = client.max_retries + 1 if retryable else 1
275
+ for attempt in range(attempts):
276
+ response = await transport(method, f"{client.base_url}{path}", headers, body, client.timeout)
277
+ if 200 <= response.status < 300:
278
+ return json.loads(response.body.decode("utf-8")) if response.body else None
279
+ problem = json.loads(response.body.decode("utf-8")) if response.body else None
280
+ error = ScanverionApiError((problem or {}).get("detail") or (problem or {}).get("title") or "Scanverion request failed.", response.status, problem, response.headers.get("X-Request-Id") or (problem or {}).get("requestId"))
281
+ if not error.retryable or not retryable or attempt == attempts - 1:
282
+ raise error
283
+ await asyncio.sleep(min(float(response.headers.get("Retry-After", 2**attempt)), 10))
284
+ raise RuntimeError("Scanverion request retry loop ended unexpectedly.")
@@ -0,0 +1,117 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, TypedDict
4
+
5
+
6
+ class BoundingBox(TypedDict):
7
+ left: float
8
+ top: float
9
+ right: float
10
+ bottom: float
11
+
12
+
13
+ class ScannerResult(TypedDict):
14
+ fields: dict[str, str | None]
15
+ issuingCountry: dict[str, Any] | None
16
+ detectedLanguages: list[dict[str, Any]] | None
17
+
18
+
19
+ class ObjectProcessingResults(TypedDict):
20
+ scanner: ScannerResult | None
21
+ rotation: dict[str, float] | None
22
+ blur: dict[str, bool] | None
23
+ faces: list[Any] | None
24
+ barcodes: list[Any] | None
25
+ checkboxes: list[Any] | None
26
+ signature: dict[str, Any] | None
27
+ lostOrStolenCard: dict[str, Any] | None
28
+
29
+
30
+ class DetectedObject(TypedDict):
31
+ objectId: str
32
+ category: str
33
+ confidence: float
34
+ bounds: BoundingBox
35
+ results: ObjectProcessingResults
36
+ pageNumber: int
37
+
38
+
39
+ class OperationSummary(TypedDict):
40
+ operation: str
41
+ status: str
42
+ message: str | None
43
+
44
+
45
+ class UsageCharge(TypedDict):
46
+ units: int
47
+ unitsByOperation: dict[str, int]
48
+
49
+
50
+ class RealtimeAnalysisResponse(TypedDict):
51
+ requestId: str
52
+ model: str
53
+ processingTimeMs: int
54
+ objects: list[DetectedObject]
55
+ operations: list[OperationSummary]
56
+ usage: UsageCharge
57
+
58
+
59
+ class RealtimeAnalysisBatchResponse(TypedDict):
60
+ results: list[RealtimeAnalysisResponse]
61
+ usage: UsageCharge
62
+
63
+
64
+ UsageSummary = TypedDict("UsageSummary", {"from": str, "to": str, "totalCalls": int, "estimatedCostEur": float, "includedCredits": int, "usedCredits": int})
65
+
66
+
67
+ class DailyUsagePoint(TypedDict):
68
+ date: str
69
+ calls: int
70
+
71
+
72
+ class DailyUsageResponse(TypedDict):
73
+ points: list[DailyUsagePoint]
74
+
75
+
76
+ class CapabilityUsageItem(TypedDict):
77
+ capabilityKey: str
78
+ label: str
79
+ calls: int
80
+ units: float
81
+ estimatedCostEur: float
82
+
83
+
84
+ class CapabilityUsageResponse(TypedDict):
85
+ items: list[CapabilityUsageItem]
86
+
87
+
88
+ class UsagePricePreview(TypedDict):
89
+ includedCostEur: float
90
+ overageCostEur: float
91
+ totalEstimatedCostEur: float
92
+ currency: str
93
+
94
+
95
+ class Capability(TypedDict):
96
+ key: str
97
+ name: str
98
+ model: str
99
+ supportedOperations: list[str]
100
+ supportedInputMimeTypes: list[str]
101
+ maximumFileSizeBytes: int
102
+
103
+
104
+ class CapabilityCatalogItem(TypedDict):
105
+ key: str
106
+ name: str
107
+ description: str
108
+ route: str
109
+ method: str
110
+ isEnabled: bool
111
+
112
+
113
+ class CapabilityCatalogResponse(TypedDict):
114
+ capabilities: list[CapabilityCatalogItem]
115
+
116
+
117
+ CapabilityUsageDetail = TypedDict("CapabilityUsageDetail", {"capabilityKey": str, "from": str, "to": str, "calls": int, "credits": float})
File without changes
@@ -0,0 +1,70 @@
1
+ Metadata-Version: 2.4
2
+ Name: scanverion
3
+ Version: 0.1.0
4
+ Summary: Python client for the Scanverion OCR API.
5
+ Author: Scanverion
6
+ License: Apache-2.0
7
+ Classifier: License :: OSI Approved :: Apache Software License
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.10
10
+ Classifier: Programming Language :: Python :: 3.11
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Classifier: Programming Language :: Python :: 3.13
13
+ Classifier: Programming Language :: Python :: 3.14
14
+ Requires-Python: >=3.10
15
+ Description-Content-Type: text/markdown
16
+
17
+ # scanverion
18
+
19
+ Node and Python are the first supported Scanverion client languages. This package provides synchronous and asynchronous standard-library clients for the OCR API.
20
+
21
+ ## Download and install
22
+
23
+ Install the published package from PyPI:
24
+
25
+ ```powershell
26
+ python -m pip install scanverion==0.1.0
27
+ ```
28
+
29
+ It requires Python 3.10 or newer.
30
+
31
+ ## Quickstart
32
+
33
+ ```python
34
+ from pathlib import Path
35
+ from scanverion import Scanverion
36
+
37
+ client = Scanverion(
38
+ api_key="skv_...",
39
+ base_url="http://localhost:5009",
40
+ )
41
+ result = client.documents.process(
42
+ Path("invoice.pdf"),
43
+ file_name="invoice.pdf",
44
+ content_type="application/pdf",
45
+ operations=["Detection", "Scanner"],
46
+ scanner_fields=["invoiceNumber", "grossTotal"],
47
+ idempotency_key="invoice-2026-0007",
48
+ )
49
+ print(result["requestId"], result["objects"])
50
+ ```
51
+
52
+ The core methods accept `bytes`, `bytearray`, `memoryview`, `pathlib.Path`, or binary file objects. `AsyncScanverion` exposes the same resource groups and uses an injected async transport or `asyncio.to_thread` with the default transport.
53
+
54
+ ```python
55
+ from scanverion import AsyncScanverion
56
+
57
+ async with AsyncScanverion(api_key="skv_...") as client:
58
+ summary = await client.usage.summary(from_date="2026-07-01", to_date="2026-07-31")
59
+ ```
60
+
61
+ API keys are server-side credentials. Do not put them in browser code, mobile binaries, logs, or public fixtures. Processing POST retries happen only when an idempotency key is supplied; safe GET resources may retry transient failures.
62
+
63
+ ## Local validation
64
+
65
+ From the `scanverion` directory:
66
+
67
+ ```powershell
68
+ python -m unittest discover -s packages/sdk-python/tests -v
69
+ python -m compileall packages/sdk-python/src
70
+ ```
@@ -0,0 +1,11 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/scanverion/__init__.py
4
+ src/scanverion/client.py
5
+ src/scanverion/models.py
6
+ src/scanverion/py.typed
7
+ src/scanverion.egg-info/PKG-INFO
8
+ src/scanverion.egg-info/SOURCES.txt
9
+ src/scanverion.egg-info/dependency_links.txt
10
+ src/scanverion.egg-info/top_level.txt
11
+ tests/test_client.py
@@ -0,0 +1 @@
1
+ scanverion
@@ -0,0 +1,104 @@
1
+ import asyncio
2
+ import json
3
+ import sys
4
+ import unittest
5
+ from pathlib import Path
6
+
7
+ sys.path.insert(0, str(Path(__file__).parents[1] / "src"))
8
+
9
+ from scanverion import AsyncScanverion, Scanverion, ScanverionApiError
10
+ from scanverion.client import TransportResponse
11
+
12
+ CONTRACT_FIXTURE = json.loads((Path(__file__).parents[3] / "fixtures" / "client-contract-cases.json").read_text(encoding="utf-8"))
13
+
14
+
15
+ class FakeTransport:
16
+ def __init__(self, responses):
17
+ self.responses = iter(responses)
18
+ self.calls = []
19
+
20
+ def __call__(self, method, url, headers, body, timeout):
21
+ self.calls.append((method, url, headers, body, timeout))
22
+ return next(self.responses)
23
+
24
+
25
+ def response(payload, status=200, headers=None):
26
+ return TransportResponse(status, headers or {}, json.dumps(payload).encode())
27
+
28
+
29
+ class ScanverionClientTests(unittest.TestCase):
30
+ def test_tracks_shared_v1_contract_cases(self):
31
+ self.assertEqual("v1", CONTRACT_FIXTURE["version"])
32
+ self.assertEqual(
33
+ {
34
+ "document-multipart",
35
+ "document-batch-multipart",
36
+ "vehicle-operations",
37
+ "usage-period",
38
+ "structured-problem",
39
+ },
40
+ {case["id"] for case in CONTRACT_FIXTURE["cases"]},
41
+ )
42
+
43
+ def test_processes_multipart_path_input_and_idempotency(self):
44
+ transport = FakeTransport([response({"requestId": "req_1", "model": "Document"})])
45
+ client = Scanverion("skv_test", transport=transport)
46
+
47
+ result = client.documents.process(b"image", file_name="sample.jpg", content_type="image/jpeg", operations=["Detection", "Scanner"], idempotency_key="sample-1")
48
+
49
+ self.assertEqual("req_1", result["requestId"])
50
+ method, url, headers, body, _ = transport.calls[0]
51
+ self.assertEqual("POST", method)
52
+ self.assertEqual("http://localhost:5009/api/v1/ai/document-processing", url)
53
+ self.assertEqual("Bearer skv_test", headers["Authorization"])
54
+ self.assertEqual("sample-1", headers["Idempotency-Key"])
55
+ self.assertIn(b'name="Operations"', body)
56
+ self.assertIn(b"Scanner", body)
57
+ self.assertIn(b'filename="sample.jpg"', body)
58
+
59
+ def test_usage_dates_are_encoded_and_transient_post_retries_only_with_key(self):
60
+ transport = FakeTransport([
61
+ response({"from": "2026-07-01", "to": "2026-07-31"}),
62
+ response({"title": "Busy", "retryable": True}, 503, {"Retry-After": "0"}),
63
+ response({"requestId": "req_2"}),
64
+ ])
65
+ client = Scanverion("skv_test", transport=transport, max_retries=1)
66
+
67
+ client.usage.summary(from_date="2026-07-01", to_date="2026-07-31")
68
+ self.assertEqual("http://localhost:5009/api/v1/usage/summary?from=2026-07-01&to=2026-07-31", transport.calls[0][1])
69
+ self.assertEqual("req_2", client.documents.process(b"x", idempotency_key="retry-1")["requestId"])
70
+ self.assertEqual(3, len(transport.calls))
71
+
72
+ def test_structured_error_preserves_request_id(self):
73
+ transport = FakeTransport([response({"title": "Invalid", "detail": "Fix input", "code": "invalid_request", "retryable": False, "requestId": "req_3"}, 422, {"X-Request-Id": "req_3"})])
74
+ with self.assertRaises(ScanverionApiError) as context:
75
+ Scanverion("skv_test", transport=transport).documents.process(b"x")
76
+ self.assertEqual(422, context.exception.status)
77
+ self.assertEqual("req_3", context.exception.request_id)
78
+ self.assertFalse(context.exception.retryable)
79
+
80
+ def test_transient_processing_error_is_not_retried_without_idempotency_key(self):
81
+ transport = FakeTransport([response({"title": "Busy", "retryable": True}, 503)])
82
+ with self.assertRaises(ScanverionApiError):
83
+ Scanverion("skv_test", transport=transport, max_retries=2).documents.process(b"x")
84
+ self.assertEqual(1, len(transport.calls))
85
+
86
+ def test_async_client_uses_injected_transport(self):
87
+ calls = []
88
+
89
+ async def transport(method, url, headers, body, timeout):
90
+ calls.append((method, url, body))
91
+ return response({"points": []})
92
+
93
+ async def run():
94
+ async with AsyncScanverion("skv_test", transport=transport) as client:
95
+ return await client.usage.daily(from_date="2026-07-01")
96
+
97
+ result = asyncio.run(run())
98
+ self.assertEqual([], result["points"])
99
+ self.assertEqual("GET", calls[0][0])
100
+ self.assertIn("from=2026-07-01", calls[0][1])
101
+
102
+
103
+ if __name__ == "__main__":
104
+ unittest.main()