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.
- scanverion-0.1.0/PKG-INFO +70 -0
- scanverion-0.1.0/README.md +54 -0
- scanverion-0.1.0/pyproject.toml +27 -0
- scanverion-0.1.0/setup.cfg +4 -0
- scanverion-0.1.0/src/scanverion/__init__.py +23 -0
- scanverion-0.1.0/src/scanverion/client.py +284 -0
- scanverion-0.1.0/src/scanverion/models.py +117 -0
- scanverion-0.1.0/src/scanverion/py.typed +0 -0
- scanverion-0.1.0/src/scanverion.egg-info/PKG-INFO +70 -0
- scanverion-0.1.0/src/scanverion.egg-info/SOURCES.txt +11 -0
- scanverion-0.1.0/src/scanverion.egg-info/dependency_links.txt +1 -0
- scanverion-0.1.0/src/scanverion.egg-info/top_level.txt +1 -0
- scanverion-0.1.0/tests/test_client.py +104 -0
|
@@ -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,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
|
+
|
|
@@ -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()
|