doc-cheap 0.1.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.
- doc_cheap/__init__.py +99 -0
- doc_cheap/_client.py +284 -0
- doc_cheap/_errors.py +184 -0
- doc_cheap/_types.py +177 -0
- doc_cheap/_version.py +1 -0
- doc_cheap/py.typed +0 -0
- doc_cheap-0.1.0.dist-info/METADATA +169 -0
- doc_cheap-0.1.0.dist-info/RECORD +10 -0
- doc_cheap-0.1.0.dist-info/WHEEL +4 -0
- doc_cheap-0.1.0.dist-info/licenses/LICENSE +21 -0
doc_cheap/__init__.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""A typed Python client for the doc.cheap document-recognition API.
|
|
2
|
+
|
|
3
|
+
>>> from doc_cheap import Client, SANDBOX_KEY
|
|
4
|
+
>>> with Client(SANDBOX_KEY) as client: # doctest: +SKIP
|
|
5
|
+
... scan = client.recognize(path="passport.jpg")
|
|
6
|
+
... print(scan["meta"]["status"], scan["holder"])
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from ._client import (
|
|
10
|
+
API_KEY_ENV,
|
|
11
|
+
BASE_URL_ENV,
|
|
12
|
+
DEFAULT_BASE_URL,
|
|
13
|
+
MAX_IMAGE_BYTES,
|
|
14
|
+
SANDBOX_KEY,
|
|
15
|
+
Client,
|
|
16
|
+
)
|
|
17
|
+
from ._errors import (
|
|
18
|
+
APIConnectionError,
|
|
19
|
+
APIError,
|
|
20
|
+
AuthenticationError,
|
|
21
|
+
ConflictError,
|
|
22
|
+
DocCheapError,
|
|
23
|
+
InsufficientCreditsError,
|
|
24
|
+
InternalServerError,
|
|
25
|
+
InvalidArgumentError,
|
|
26
|
+
InvalidRequestError,
|
|
27
|
+
NotFoundError,
|
|
28
|
+
PermissionDeniedError,
|
|
29
|
+
RateLimitError,
|
|
30
|
+
ServiceUnavailableError,
|
|
31
|
+
)
|
|
32
|
+
from ._types import (
|
|
33
|
+
Confidence,
|
|
34
|
+
CreditsByKind,
|
|
35
|
+
FreeAllowance,
|
|
36
|
+
Scan,
|
|
37
|
+
ScanAuthenticity,
|
|
38
|
+
ScanCheck,
|
|
39
|
+
ScanDeletion,
|
|
40
|
+
ScanDocument,
|
|
41
|
+
ScanField,
|
|
42
|
+
ScanHolder,
|
|
43
|
+
ScanImages,
|
|
44
|
+
ScanList,
|
|
45
|
+
ScanMeta,
|
|
46
|
+
ScanMRZ,
|
|
47
|
+
ScanQuality,
|
|
48
|
+
ScanSummary,
|
|
49
|
+
ScanTiming,
|
|
50
|
+
Status,
|
|
51
|
+
Usage,
|
|
52
|
+
UsagePeriod,
|
|
53
|
+
UsageScans,
|
|
54
|
+
)
|
|
55
|
+
from ._version import __version__
|
|
56
|
+
|
|
57
|
+
__all__ = [
|
|
58
|
+
"API_KEY_ENV",
|
|
59
|
+
"BASE_URL_ENV",
|
|
60
|
+
"DEFAULT_BASE_URL",
|
|
61
|
+
"MAX_IMAGE_BYTES",
|
|
62
|
+
"SANDBOX_KEY",
|
|
63
|
+
"APIConnectionError",
|
|
64
|
+
"APIError",
|
|
65
|
+
"AuthenticationError",
|
|
66
|
+
"Client",
|
|
67
|
+
"Confidence",
|
|
68
|
+
"ConflictError",
|
|
69
|
+
"CreditsByKind",
|
|
70
|
+
"DocCheapError",
|
|
71
|
+
"FreeAllowance",
|
|
72
|
+
"InsufficientCreditsError",
|
|
73
|
+
"InternalServerError",
|
|
74
|
+
"InvalidArgumentError",
|
|
75
|
+
"InvalidRequestError",
|
|
76
|
+
"NotFoundError",
|
|
77
|
+
"PermissionDeniedError",
|
|
78
|
+
"RateLimitError",
|
|
79
|
+
"Scan",
|
|
80
|
+
"ScanAuthenticity",
|
|
81
|
+
"ScanCheck",
|
|
82
|
+
"ScanDeletion",
|
|
83
|
+
"ScanDocument",
|
|
84
|
+
"ScanField",
|
|
85
|
+
"ScanHolder",
|
|
86
|
+
"ScanImages",
|
|
87
|
+
"ScanList",
|
|
88
|
+
"ScanMRZ",
|
|
89
|
+
"ScanMeta",
|
|
90
|
+
"ScanQuality",
|
|
91
|
+
"ScanSummary",
|
|
92
|
+
"ScanTiming",
|
|
93
|
+
"ServiceUnavailableError",
|
|
94
|
+
"Status",
|
|
95
|
+
"Usage",
|
|
96
|
+
"UsagePeriod",
|
|
97
|
+
"UsageScans",
|
|
98
|
+
"__version__",
|
|
99
|
+
]
|
doc_cheap/_client.py
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
"""The synchronous client for the doc.cheap API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
from collections.abc import Mapping
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from types import TracebackType
|
|
11
|
+
from typing import Any, Union, cast
|
|
12
|
+
from urllib.parse import quote
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from ._errors import APIConnectionError, DocCheapError, InvalidArgumentError, error_from_response
|
|
17
|
+
from ._types import Scan, ScanDeletion, ScanList, Usage
|
|
18
|
+
from ._version import __version__
|
|
19
|
+
|
|
20
|
+
DEFAULT_BASE_URL = "https://api.doc.cheap"
|
|
21
|
+
"""The API the client talks to unless told otherwise."""
|
|
22
|
+
|
|
23
|
+
SANDBOX_KEY = "sk_sandbox_public"
|
|
24
|
+
"""A public key that works without an account and never draws credits."""
|
|
25
|
+
|
|
26
|
+
MAX_IMAGE_BYTES = 25 * 1024 * 1024
|
|
27
|
+
"""The largest image the API accepts, measured before base64 encoding."""
|
|
28
|
+
|
|
29
|
+
API_KEY_ENV = "DOC_CHEAP_API_KEY"
|
|
30
|
+
BASE_URL_ENV = "DOC_CHEAP_API_BASE"
|
|
31
|
+
|
|
32
|
+
_MAX_REFERENCE_LENGTH = 128
|
|
33
|
+
_MAX_IDEMPOTENCY_KEY_LENGTH = 255
|
|
34
|
+
_MAX_CURSOR_LENGTH = 256
|
|
35
|
+
_MAX_RETAIN_HOURS = 8760
|
|
36
|
+
_MAX_LIST_LIMIT = 100
|
|
37
|
+
_COUNTRY_CODE = re.compile(r"[A-Z]{3}")
|
|
38
|
+
_DATA_URL_PREFIX = re.compile(r"^data:[^,]*;base64,", re.IGNORECASE)
|
|
39
|
+
|
|
40
|
+
StrPath = Union[str, "os.PathLike[str]"]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _default_user_agent() -> str:
|
|
44
|
+
return f"doc-cheap-python/{__version__} (+https://gitlab.com/doccheap/doc-cheap-python)"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _decoded_length(encoded: str) -> int:
|
|
48
|
+
# The size the API will see after decoding, without decoding a string
|
|
49
|
+
# that may be tens of megabytes only to measure it.
|
|
50
|
+
return len(encoded) * 3 // 4
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Client:
|
|
54
|
+
"""Calls the doc.cheap API.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
api_key: The API key. When omitted it is read from the
|
|
58
|
+
``DOC_CHEAP_API_KEY`` environment variable. A live key starts with
|
|
59
|
+
``sk_live_``; :data:`SANDBOX_KEY` works without an account.
|
|
60
|
+
base_url: The API address. When omitted it is read from
|
|
61
|
+
``DOC_CHEAP_API_BASE``, else :data:`DEFAULT_BASE_URL`.
|
|
62
|
+
timeout: Seconds to wait for an answer. Ignored when ``http_client``
|
|
63
|
+
is given.
|
|
64
|
+
http_client: An ``httpx.Client`` to send the requests through, for a
|
|
65
|
+
proxy, custom transport or shared connection pool. The caller
|
|
66
|
+
keeps ownership of it: :meth:`close` leaves it open.
|
|
67
|
+
user_agent: A product token put in front of the client's own
|
|
68
|
+
``User-Agent``, so an application's requests can be told apart.
|
|
69
|
+
|
|
70
|
+
The client is safe to share between threads, like the ``httpx.Client``
|
|
71
|
+
under it. Use it as a context manager, or call :meth:`close`, to release
|
|
72
|
+
its connections.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def __init__(
|
|
76
|
+
self,
|
|
77
|
+
api_key: str | None = None,
|
|
78
|
+
*,
|
|
79
|
+
base_url: str | None = None,
|
|
80
|
+
timeout: float = 60.0,
|
|
81
|
+
http_client: httpx.Client | None = None,
|
|
82
|
+
user_agent: str | None = None,
|
|
83
|
+
) -> None:
|
|
84
|
+
key = api_key if api_key is not None else os.environ.get(API_KEY_ENV)
|
|
85
|
+
if not key:
|
|
86
|
+
raise DocCheapError(
|
|
87
|
+
f"no API key: pass api_key or set {API_KEY_ENV} "
|
|
88
|
+
f"(the public sandbox key is {SANDBOX_KEY!r})"
|
|
89
|
+
)
|
|
90
|
+
self._api_key = key
|
|
91
|
+
base = base_url or os.environ.get(BASE_URL_ENV) or DEFAULT_BASE_URL
|
|
92
|
+
self._base_url = base.rstrip("/")
|
|
93
|
+
agent = _default_user_agent()
|
|
94
|
+
if user_agent and user_agent.strip():
|
|
95
|
+
agent = f"{user_agent.strip()} {agent}"
|
|
96
|
+
self._user_agent = agent
|
|
97
|
+
self._owns_http_client = http_client is None
|
|
98
|
+
self._http = http_client if http_client is not None else httpx.Client(timeout=timeout)
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def base_url(self) -> str:
|
|
102
|
+
"""The API address requests are sent to."""
|
|
103
|
+
return self._base_url
|
|
104
|
+
|
|
105
|
+
def close(self) -> None:
|
|
106
|
+
"""Releases the connections of the HTTP client this client created."""
|
|
107
|
+
if self._owns_http_client:
|
|
108
|
+
self._http.close()
|
|
109
|
+
|
|
110
|
+
def __enter__(self) -> Client:
|
|
111
|
+
return self
|
|
112
|
+
|
|
113
|
+
def __exit__(
|
|
114
|
+
self,
|
|
115
|
+
exc_type: type[BaseException] | None,
|
|
116
|
+
exc: BaseException | None,
|
|
117
|
+
tb: TracebackType | None,
|
|
118
|
+
) -> None:
|
|
119
|
+
self.close()
|
|
120
|
+
|
|
121
|
+
def recognize(
|
|
122
|
+
self,
|
|
123
|
+
*,
|
|
124
|
+
image: bytes | None = None,
|
|
125
|
+
image_base64: str | None = None,
|
|
126
|
+
path: StrPath | None = None,
|
|
127
|
+
reference: str | None = None,
|
|
128
|
+
idempotency_key: str | None = None,
|
|
129
|
+
expect_country: str | None = None,
|
|
130
|
+
return_portrait: bool | None = None,
|
|
131
|
+
retain_hours: int | None = None,
|
|
132
|
+
) -> Scan:
|
|
133
|
+
"""Sends one image and returns what was read from it.
|
|
134
|
+
|
|
135
|
+
Give exactly one of ``image`` (raw JPEG or PNG bytes), ``image_base64``
|
|
136
|
+
(the same bytes as standard base64, a ``data:`` URL prefix allowed) or
|
|
137
|
+
``path`` (a file to read).
|
|
138
|
+
|
|
139
|
+
An image that holds no readable document is not an error: the scan
|
|
140
|
+
comes back with a status other than ``"recognized"`` and
|
|
141
|
+
``scan["meta"]["billed"]`` set to ``False``.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
reference: A label of up to 128 characters, stored with the scan
|
|
145
|
+
and returned with it.
|
|
146
|
+
idempotency_key: When set, a retried request returns the first
|
|
147
|
+
answer instead of reading the document twice. Up to 255
|
|
148
|
+
characters.
|
|
149
|
+
expect_country: The issuing country you expect, as three
|
|
150
|
+
upper-case letters (ISO 3166-1 alpha-3).
|
|
151
|
+
return_portrait: Whether to return the holder's portrait image.
|
|
152
|
+
retain_hours: How long the API keeps the scan, from 0 to 8760.
|
|
153
|
+
"""
|
|
154
|
+
body: dict[str, Any] = {"image": self._image(image, image_base64, path)}
|
|
155
|
+
if reference:
|
|
156
|
+
if len(reference) > _MAX_REFERENCE_LENGTH:
|
|
157
|
+
raise InvalidArgumentError(
|
|
158
|
+
f"reference is {len(reference)} characters, "
|
|
159
|
+
f"the limit is {_MAX_REFERENCE_LENGTH}"
|
|
160
|
+
)
|
|
161
|
+
body["reference"] = reference
|
|
162
|
+
options: dict[str, Any] = {}
|
|
163
|
+
if expect_country:
|
|
164
|
+
if not _COUNTRY_CODE.fullmatch(expect_country):
|
|
165
|
+
raise InvalidArgumentError(
|
|
166
|
+
f"expect_country {expect_country!r} is not three upper-case letters"
|
|
167
|
+
)
|
|
168
|
+
options["expect_country"] = expect_country
|
|
169
|
+
if return_portrait is not None:
|
|
170
|
+
options["return_portrait"] = bool(return_portrait)
|
|
171
|
+
if retain_hours is not None:
|
|
172
|
+
if isinstance(retain_hours, bool) or not 0 <= retain_hours <= _MAX_RETAIN_HOURS:
|
|
173
|
+
raise InvalidArgumentError(
|
|
174
|
+
f"retain_hours {retain_hours!r} is outside 0..{_MAX_RETAIN_HOURS}"
|
|
175
|
+
)
|
|
176
|
+
options["retain_hours"] = retain_hours
|
|
177
|
+
if options:
|
|
178
|
+
body["options"] = options
|
|
179
|
+
headers: dict[str, str] = {}
|
|
180
|
+
if idempotency_key:
|
|
181
|
+
if len(idempotency_key) > _MAX_IDEMPOTENCY_KEY_LENGTH:
|
|
182
|
+
raise InvalidArgumentError(
|
|
183
|
+
f"idempotency_key is {len(idempotency_key)} characters, "
|
|
184
|
+
f"the limit is {_MAX_IDEMPOTENCY_KEY_LENGTH}"
|
|
185
|
+
)
|
|
186
|
+
headers["Idempotency-Key"] = idempotency_key
|
|
187
|
+
return cast(Scan, self._request("POST", "/v1/scans", json=body, headers=headers))
|
|
188
|
+
|
|
189
|
+
def get_scan(self, scan_id: str) -> Scan:
|
|
190
|
+
"""Returns a stored scan by its id."""
|
|
191
|
+
return cast(Scan, self._request("GET", self._scan_path(scan_id)))
|
|
192
|
+
|
|
193
|
+
def list_scans(self, *, limit: int | None = None, cursor: str | None = None) -> ScanList:
|
|
194
|
+
"""Returns one page of stored scans, newest first.
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
limit: The page size, from 1 to 100. The API's default when omitted.
|
|
198
|
+
cursor: The ``next_cursor`` of the previous page. It is ``None`` on
|
|
199
|
+
the last page.
|
|
200
|
+
"""
|
|
201
|
+
params: dict[str, str] = {}
|
|
202
|
+
if limit is not None:
|
|
203
|
+
if isinstance(limit, bool) or not 1 <= limit <= _MAX_LIST_LIMIT:
|
|
204
|
+
raise InvalidArgumentError(f"limit {limit!r} is outside 1..{_MAX_LIST_LIMIT}")
|
|
205
|
+
params["limit"] = str(limit)
|
|
206
|
+
if cursor:
|
|
207
|
+
if len(cursor) > _MAX_CURSOR_LENGTH:
|
|
208
|
+
raise InvalidArgumentError(
|
|
209
|
+
f"cursor is {len(cursor)} characters, the limit is {_MAX_CURSOR_LENGTH}"
|
|
210
|
+
)
|
|
211
|
+
params["cursor"] = cursor
|
|
212
|
+
return cast(ScanList, self._request("GET", "/v1/scans", params=params))
|
|
213
|
+
|
|
214
|
+
def delete_scan(self, scan_id: str) -> ScanDeletion:
|
|
215
|
+
"""Deletes a stored scan by its id."""
|
|
216
|
+
return cast(ScanDeletion, self._request("DELETE", self._scan_path(scan_id)))
|
|
217
|
+
|
|
218
|
+
def usage(self) -> Usage:
|
|
219
|
+
"""Returns the account's balance and what was spent in the current period."""
|
|
220
|
+
return cast(Usage, self._request("GET", "/v1/usage"))
|
|
221
|
+
|
|
222
|
+
@staticmethod
|
|
223
|
+
def _scan_path(scan_id: str) -> str:
|
|
224
|
+
if not scan_id:
|
|
225
|
+
raise InvalidArgumentError("a scan id is required")
|
|
226
|
+
return "/v1/scans/" + quote(scan_id, safe="")
|
|
227
|
+
|
|
228
|
+
@staticmethod
|
|
229
|
+
def _image(image: bytes | None, image_base64: str | None, path: StrPath | None) -> str:
|
|
230
|
+
given = sum(source is not None for source in (image, image_base64, path))
|
|
231
|
+
if given != 1:
|
|
232
|
+
raise InvalidArgumentError("give exactly one of image, image_base64 or path")
|
|
233
|
+
if path is not None:
|
|
234
|
+
try:
|
|
235
|
+
image = Path(path).read_bytes()
|
|
236
|
+
except OSError as exc:
|
|
237
|
+
raise InvalidArgumentError(f"cannot read the image file: {exc}") from exc
|
|
238
|
+
if image is not None:
|
|
239
|
+
if not image:
|
|
240
|
+
raise InvalidArgumentError("the image is empty")
|
|
241
|
+
if len(image) > MAX_IMAGE_BYTES:
|
|
242
|
+
raise InvalidArgumentError(
|
|
243
|
+
f"image is {len(image)} bytes, the limit is {MAX_IMAGE_BYTES}"
|
|
244
|
+
)
|
|
245
|
+
return base64.b64encode(image).decode("ascii")
|
|
246
|
+
encoded = _DATA_URL_PREFIX.sub("", cast(str, image_base64).strip(), count=1)
|
|
247
|
+
if not encoded:
|
|
248
|
+
raise InvalidArgumentError("the image is empty")
|
|
249
|
+
if _decoded_length(encoded) > MAX_IMAGE_BYTES + 2:
|
|
250
|
+
raise InvalidArgumentError(f"image is larger than {MAX_IMAGE_BYTES} bytes")
|
|
251
|
+
return encoded
|
|
252
|
+
|
|
253
|
+
def _request(
|
|
254
|
+
self,
|
|
255
|
+
method: str,
|
|
256
|
+
path: str,
|
|
257
|
+
*,
|
|
258
|
+
json: Mapping[str, Any] | None = None,
|
|
259
|
+
params: Mapping[str, str] | None = None,
|
|
260
|
+
headers: Mapping[str, str] | None = None,
|
|
261
|
+
) -> Any:
|
|
262
|
+
request_headers = {
|
|
263
|
+
"Accept": "application/json",
|
|
264
|
+
"Authorization": f"Bearer {self._api_key}",
|
|
265
|
+
"User-Agent": self._user_agent,
|
|
266
|
+
}
|
|
267
|
+
if headers:
|
|
268
|
+
request_headers.update(headers)
|
|
269
|
+
try:
|
|
270
|
+
response = self._http.request(
|
|
271
|
+
method,
|
|
272
|
+
self._base_url + path,
|
|
273
|
+
json=json,
|
|
274
|
+
params=params or None,
|
|
275
|
+
headers=request_headers,
|
|
276
|
+
)
|
|
277
|
+
except httpx.HTTPError as exc:
|
|
278
|
+
raise APIConnectionError(f"{method} {path}: {exc}") from exc
|
|
279
|
+
if not response.is_success:
|
|
280
|
+
raise error_from_response(response)
|
|
281
|
+
try:
|
|
282
|
+
return response.json()
|
|
283
|
+
except ValueError as exc:
|
|
284
|
+
raise DocCheapError(f"{method} {path}: the answer is not JSON") from exc
|
doc_cheap/_errors.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""The exceptions the client raises.
|
|
2
|
+
|
|
3
|
+
Every failure is a :class:`DocCheapError`. An answer from the API with an
|
|
4
|
+
error status is an :class:`APIError`, narrowed to a subclass by the API's own
|
|
5
|
+
error code, so a caller can catch the case it handles (``NotFoundError``,
|
|
6
|
+
``RateLimitError``) and let the rest propagate.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from collections.abc import Mapping
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
# The error body is small JSON; anything larger is not one, and parsing it
|
|
17
|
+
# whole would only hold memory for a message nobody can read.
|
|
18
|
+
_MAX_ERROR_BODY_BYTES = 64 * 1024
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class DocCheapError(Exception):
|
|
22
|
+
"""Base class of every exception this package raises."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class InvalidArgumentError(DocCheapError, ValueError):
|
|
26
|
+
"""An argument the API would refuse, caught before any request is sent."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class APIConnectionError(DocCheapError):
|
|
30
|
+
"""The request did not get an answer: a network failure or a timeout."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class APIError(DocCheapError):
|
|
34
|
+
"""The API answered with a status outside 2xx.
|
|
35
|
+
|
|
36
|
+
Attributes:
|
|
37
|
+
status_code: The HTTP status, for example 401 or 404.
|
|
38
|
+
code: The API's machine-readable error code, for example
|
|
39
|
+
``"unauthorized"``. When the body carries none it is ``"http_"``
|
|
40
|
+
followed by the status.
|
|
41
|
+
message: The API's human-readable explanation.
|
|
42
|
+
docs_url: The documentation page for ``code``, when the API gave one.
|
|
43
|
+
request_id: Identifies the request in the API's logs; quote it when
|
|
44
|
+
asking for help.
|
|
45
|
+
event_id: Identifies a recorded unexpected failure, or ``None``.
|
|
46
|
+
retry_after: Seconds to wait before retrying, from the ``Retry-After``
|
|
47
|
+
header of a 429 or 503 answer, or ``None`` when it is absent.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
*,
|
|
53
|
+
status_code: int,
|
|
54
|
+
code: str,
|
|
55
|
+
message: str,
|
|
56
|
+
docs_url: str | None = None,
|
|
57
|
+
request_id: str | None = None,
|
|
58
|
+
event_id: str | None = None,
|
|
59
|
+
retry_after: int | None = None,
|
|
60
|
+
) -> None:
|
|
61
|
+
super().__init__(f"{status_code} {code}: {message}")
|
|
62
|
+
self.status_code = status_code
|
|
63
|
+
self.code = code
|
|
64
|
+
self.message = message
|
|
65
|
+
self.docs_url = docs_url
|
|
66
|
+
self.request_id = request_id
|
|
67
|
+
self.event_id = event_id
|
|
68
|
+
self.retry_after = retry_after
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class InvalidRequestError(APIError):
|
|
72
|
+
"""The request was malformed, failed validation, or carried a bad image."""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class AuthenticationError(APIError):
|
|
76
|
+
"""The API key is missing, unknown or revoked."""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class PermissionDeniedError(APIError):
|
|
80
|
+
"""The key is valid but may not do this, for example before registration."""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class InsufficientCreditsError(APIError):
|
|
84
|
+
"""The account has no credits left for a billable scan."""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class NotFoundError(APIError):
|
|
88
|
+
"""The scan does not exist, was deleted, or was never stored."""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class ConflictError(APIError):
|
|
92
|
+
"""The request clashes with an earlier one, such as a reused idempotency key."""
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class RateLimitError(APIError):
|
|
96
|
+
"""Too many requests; wait ``retry_after`` seconds before the next one."""
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class ServiceUnavailableError(APIError):
|
|
100
|
+
"""The API cannot serve the request right now; retrying later may succeed."""
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class InternalServerError(APIError):
|
|
104
|
+
"""The API failed unexpectedly; quote ``request_id`` and ``event_id``."""
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
_BY_CODE: Mapping[str, type[APIError]] = {
|
|
108
|
+
"invalid_request": InvalidRequestError,
|
|
109
|
+
"validation_failed": InvalidRequestError,
|
|
110
|
+
"payload_too_large": InvalidRequestError,
|
|
111
|
+
"unsupported_media_type": InvalidRequestError,
|
|
112
|
+
"unauthorized": AuthenticationError,
|
|
113
|
+
"registration_required": PermissionDeniedError,
|
|
114
|
+
"impersonation_read_only": PermissionDeniedError,
|
|
115
|
+
"insufficient_credits": InsufficientCreditsError,
|
|
116
|
+
"not_found": NotFoundError,
|
|
117
|
+
"idempotency_conflict": ConflictError,
|
|
118
|
+
"idempotency_in_progress": ConflictError,
|
|
119
|
+
"idempotency_replay_unavailable": ConflictError,
|
|
120
|
+
"document_repeated": RateLimitError,
|
|
121
|
+
"topup_in_progress": ConflictError,
|
|
122
|
+
"rate_limited": RateLimitError,
|
|
123
|
+
"engine_unavailable": ServiceUnavailableError,
|
|
124
|
+
"service_unavailable": ServiceUnavailableError,
|
|
125
|
+
"maintenance": ServiceUnavailableError,
|
|
126
|
+
"payment_driver_unavailable": ServiceUnavailableError,
|
|
127
|
+
"rate_unavailable": ServiceUnavailableError,
|
|
128
|
+
"internal_error": InternalServerError,
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
# Used only when the body names no code this package knows, so an error from a
|
|
132
|
+
# proxy in front of the API still lands in the class its status implies.
|
|
133
|
+
_BY_STATUS: Mapping[int, type[APIError]] = {
|
|
134
|
+
400: InvalidRequestError,
|
|
135
|
+
401: AuthenticationError,
|
|
136
|
+
402: InsufficientCreditsError,
|
|
137
|
+
403: PermissionDeniedError,
|
|
138
|
+
404: NotFoundError,
|
|
139
|
+
409: ConflictError,
|
|
140
|
+
413: InvalidRequestError,
|
|
141
|
+
415: InvalidRequestError,
|
|
142
|
+
422: InvalidRequestError,
|
|
143
|
+
429: RateLimitError,
|
|
144
|
+
503: ServiceUnavailableError,
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _text(value: object) -> str | None:
|
|
149
|
+
return value if isinstance(value, str) and value else None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _retry_after(response: httpx.Response) -> int | None:
|
|
153
|
+
raw = response.headers.get("retry-after", "").strip()
|
|
154
|
+
if raw.isdigit() and int(raw) > 0:
|
|
155
|
+
return int(raw)
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def error_from_response(response: httpx.Response) -> APIError:
|
|
160
|
+
"""Builds the exception for an answer with an error status."""
|
|
161
|
+
body: object = None
|
|
162
|
+
content = response.content[:_MAX_ERROR_BODY_BYTES]
|
|
163
|
+
try:
|
|
164
|
+
body = json.loads(content)
|
|
165
|
+
except ValueError:
|
|
166
|
+
body = None
|
|
167
|
+
error = body.get("error") if isinstance(body, dict) else None
|
|
168
|
+
fields: dict[str, object] = error if isinstance(error, dict) else {}
|
|
169
|
+
|
|
170
|
+
status = response.status_code
|
|
171
|
+
code = _text(fields.get("code")) or f"http_{status}"
|
|
172
|
+
message = _text(fields.get("message")) or response.reason_phrase or f"HTTP {status}"
|
|
173
|
+
cls = _BY_CODE.get(code) or _BY_STATUS.get(status)
|
|
174
|
+
if cls is None:
|
|
175
|
+
cls = InternalServerError if status >= 500 else APIError
|
|
176
|
+
return cls(
|
|
177
|
+
status_code=status,
|
|
178
|
+
code=code,
|
|
179
|
+
message=message,
|
|
180
|
+
docs_url=_text(fields.get("docs_url")),
|
|
181
|
+
request_id=_text(fields.get("request_id")),
|
|
182
|
+
event_id=_text(fields.get("event_id")),
|
|
183
|
+
retry_after=_retry_after(response),
|
|
184
|
+
)
|
doc_cheap/_types.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""The shapes of the API's answers, as typed dictionaries.
|
|
2
|
+
|
|
3
|
+
The client returns the decoded JSON as it came, so every field the API adds
|
|
4
|
+
later is still there; these types describe the fields known today, for editors
|
|
5
|
+
and type checkers. Dates are ISO-8601 strings and country codes are ISO 3166-1
|
|
6
|
+
alpha-3.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Literal, TypedDict
|
|
12
|
+
|
|
13
|
+
Status = Literal[
|
|
14
|
+
"recognized",
|
|
15
|
+
"no_document_found",
|
|
16
|
+
"unreadable",
|
|
17
|
+
"unsupported_document",
|
|
18
|
+
"rejected",
|
|
19
|
+
]
|
|
20
|
+
"""The outcome of one scan. Whether it was charged is ``meta["billed"]``."""
|
|
21
|
+
|
|
22
|
+
Confidence = Literal["low", "medium", "high"]
|
|
23
|
+
"""How strongly the recognition backs a value: a band rather than a number."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ScanTiming(TypedDict):
|
|
27
|
+
upload_ms: int
|
|
28
|
+
processing_ms: int
|
|
29
|
+
total_ms: int
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ScanMeta(TypedDict):
|
|
33
|
+
schema_version: str
|
|
34
|
+
id: str
|
|
35
|
+
status: Status
|
|
36
|
+
billed: bool
|
|
37
|
+
confidence: Confidence
|
|
38
|
+
timing: ScanTiming | None
|
|
39
|
+
created_at: str
|
|
40
|
+
reference: str | None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ScanDocument(TypedDict):
|
|
44
|
+
kind: str
|
|
45
|
+
country: str | None
|
|
46
|
+
country_name: str | None
|
|
47
|
+
issuing_state: str | None
|
|
48
|
+
type_name: str | None
|
|
49
|
+
type_confidence: Confidence
|
|
50
|
+
number: str | None
|
|
51
|
+
series: str | None
|
|
52
|
+
issue_date: str | None
|
|
53
|
+
expiry_date: str | None
|
|
54
|
+
is_expired: bool | None
|
|
55
|
+
days_remaining: int | None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class ScanHolder(TypedDict):
|
|
59
|
+
given_names: str | None
|
|
60
|
+
surname: str | None
|
|
61
|
+
full_name: str | None
|
|
62
|
+
birth_date: str | None
|
|
63
|
+
sex: str | None
|
|
64
|
+
nationality: str | None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class ScanField(TypedDict):
|
|
68
|
+
id: str
|
|
69
|
+
name: str
|
|
70
|
+
label: str
|
|
71
|
+
category: str
|
|
72
|
+
value: str | None
|
|
73
|
+
language: str | None
|
|
74
|
+
confidence: Confidence
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class ScanMRZ(TypedDict):
|
|
78
|
+
status: Literal["passed", "failed", "absent"]
|
|
79
|
+
reason: str | None
|
|
80
|
+
lines: list[str]
|
|
81
|
+
text: str | None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class ScanImages(TypedDict):
|
|
85
|
+
"""Image areas cut from the document, each a ``data:`` URL or ``None``."""
|
|
86
|
+
|
|
87
|
+
document_crop: str | None
|
|
88
|
+
rear: str | None
|
|
89
|
+
main_photo: str | None
|
|
90
|
+
signature: str | None
|
|
91
|
+
watermark_face: str | None
|
|
92
|
+
barcode: str | None
|
|
93
|
+
chip: str | None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class ScanQuality(TypedDict):
|
|
97
|
+
overall: str
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class ScanCheck(TypedDict):
|
|
101
|
+
name: str
|
|
102
|
+
label: str
|
|
103
|
+
result: str
|
|
104
|
+
detail: str | None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class ScanAuthenticity(TypedDict):
|
|
108
|
+
overall: str
|
|
109
|
+
checks: list[ScanCheck]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class Scan(TypedDict):
|
|
113
|
+
"""The full result of one recognition."""
|
|
114
|
+
|
|
115
|
+
meta: ScanMeta
|
|
116
|
+
document: ScanDocument | None
|
|
117
|
+
holder: ScanHolder | None
|
|
118
|
+
fields: list[ScanField]
|
|
119
|
+
mrz: ScanMRZ
|
|
120
|
+
images: ScanImages
|
|
121
|
+
quality: ScanQuality
|
|
122
|
+
authenticity: ScanAuthenticity
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class ScanSummary(TypedDict):
|
|
126
|
+
id: str
|
|
127
|
+
status: Status
|
|
128
|
+
billed: bool
|
|
129
|
+
duration_ms: int
|
|
130
|
+
reference: str | None
|
|
131
|
+
created_at: str
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class ScanList(TypedDict):
|
|
135
|
+
"""One page of stored scans, newest first."""
|
|
136
|
+
|
|
137
|
+
scans: list[ScanSummary]
|
|
138
|
+
next_cursor: str | None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class ScanDeletion(TypedDict):
|
|
142
|
+
id: str
|
|
143
|
+
deleted: bool
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class UsagePeriod(TypedDict):
|
|
147
|
+
start: str
|
|
148
|
+
end: str
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class UsageScans(TypedDict):
|
|
152
|
+
total: int
|
|
153
|
+
billed: int
|
|
154
|
+
by_status: dict[str, int]
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class FreeAllowance(TypedDict):
|
|
158
|
+
monthly_credits: int
|
|
159
|
+
remaining_credits: int
|
|
160
|
+
resets_at: str | None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class CreditsByKind(TypedDict):
|
|
164
|
+
free: int
|
|
165
|
+
paid: int
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class Usage(TypedDict):
|
|
169
|
+
"""The account's balance and what was spent in the current period."""
|
|
170
|
+
|
|
171
|
+
balance_credits: int | None
|
|
172
|
+
period: UsagePeriod
|
|
173
|
+
scans: UsageScans
|
|
174
|
+
credits_spent: int
|
|
175
|
+
free_allowance: FreeAllowance | None
|
|
176
|
+
paid_balance_credits: int | None
|
|
177
|
+
credits_spent_by_kind: CreditsByKind
|
doc_cheap/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
doc_cheap/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: doc-cheap
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Typed Python client for the doc.cheap document-recognition API: read passports, ID cards and driver's licences into JSON.
|
|
5
|
+
Project-URL: Homepage, https://doc.cheap
|
|
6
|
+
Project-URL: Documentation, https://doc.cheap/docs
|
|
7
|
+
Project-URL: Source, https://gitlab.com/doccheap/doc-cheap-python
|
|
8
|
+
Project-URL: Changelog, https://gitlab.com/doccheap/doc-cheap-python/-/blob/main/CHANGELOG.md
|
|
9
|
+
Author-email: Teoh Chin Heng <admin@doc.cheap>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: doc.cheap,document-recognition,id-card,kyc,mrz,ocr,passport
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Requires-Dist: httpx<1,>=0.27
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: mypy>=1.11; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest-cov>=5; extra == 'dev'
|
|
29
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
30
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
31
|
+
Provides-Extra: test
|
|
32
|
+
Requires-Dist: pytest-cov>=5; extra == 'test'
|
|
33
|
+
Requires-Dist: pytest>=8; extra == 'test'
|
|
34
|
+
Description-Content-Type: text/markdown
|
|
35
|
+
|
|
36
|
+
# doc-cheap
|
|
37
|
+
|
|
38
|
+
[](https://pypi.org/project/doc-cheap/)
|
|
39
|
+
|
|
40
|
+
The Python client for [doc.cheap](https://doc.cheap): it sends a photo or scan
|
|
41
|
+
of a passport, national ID card or driving licence to the doc.cheap API and
|
|
42
|
+
returns the printed fields – name, date of birth, document number, expiry, the
|
|
43
|
+
machine-readable zone – as typed dictionaries.
|
|
44
|
+
|
|
45
|
+
One recognised document costs one credit, $0.01. Registering gives 100 free
|
|
46
|
+
documents every month. An unreadable image, an empty frame or an unsupported
|
|
47
|
+
document type costs nothing, and every answer says whether it was billed.
|
|
48
|
+
|
|
49
|
+
The package needs Python 3.10 or later and depends only on
|
|
50
|
+
[httpx](https://www.python-httpx.org/).
|
|
51
|
+
|
|
52
|
+
## Installation
|
|
53
|
+
|
|
54
|
+
```sh
|
|
55
|
+
pip install doc-cheap
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Quick start
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from doc_cheap import SANDBOX_KEY, Client
|
|
62
|
+
|
|
63
|
+
with Client(SANDBOX_KEY) as client:
|
|
64
|
+
scan = client.recognize(path="passport.jpg")
|
|
65
|
+
|
|
66
|
+
print("status:", scan["meta"]["status"], "billed:", scan["meta"]["billed"])
|
|
67
|
+
if scan["holder"] and scan["holder"]["full_name"]:
|
|
68
|
+
print("holder:", scan["holder"]["full_name"])
|
|
69
|
+
if scan["document"] and scan["document"]["expiry_date"]:
|
|
70
|
+
print("expires:", scan["document"]["expiry_date"])
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## API keys
|
|
74
|
+
|
|
75
|
+
- **Live key** – `sk_live_…`, created in the
|
|
76
|
+
[doc.cheap cabinet](https://doc.cheap/docs/concepts/api-keys-and-sessions).
|
|
77
|
+
- **Sandbox key** – `sk_sandbox_public` (the `doc_cheap.SANDBOX_KEY` constant)
|
|
78
|
+
works without an account. It gives 10 free recognised documents per address
|
|
79
|
+
in all, and at most 10 requests per address an hour, whatever their answer.
|
|
80
|
+
|
|
81
|
+
`Client()` without a key reads it from the `DOC_CHEAP_API_KEY` environment
|
|
82
|
+
variable, which keeps a live key out of source code. The key is sent as a
|
|
83
|
+
Bearer token.
|
|
84
|
+
|
|
85
|
+
## Methods
|
|
86
|
+
|
|
87
|
+
| Method | Request | What it does |
|
|
88
|
+
| --- | --- | --- |
|
|
89
|
+
| `recognize` | `POST /v1/scans` | Reads one JPEG or PNG image, from a file (`path`), raw bytes (`image`) or base64 (`image_base64`), with an optional `reference` label, `idempotency_key`, `expect_country`, `return_portrait` and `retain_hours`. |
|
|
90
|
+
| `get_scan` | `GET /v1/scans/{id}` | Returns a stored result. |
|
|
91
|
+
| `list_scans` | `GET /v1/scans` | Returns one page of stored results; pass `next_cursor` back as `cursor` for the next page. |
|
|
92
|
+
| `delete_scan` | `DELETE /v1/scans/{id}` | Deletes a stored result. |
|
|
93
|
+
| `usage` | `GET /v1/usage` | Returns this month's free credits, the paid credits, the credits spent and the scan counters. |
|
|
94
|
+
|
|
95
|
+
An image that holds no readable document is not an error: the scan comes back
|
|
96
|
+
with a status such as `unreadable` or `no_document_found` and `billed` set to
|
|
97
|
+
`False`.
|
|
98
|
+
|
|
99
|
+
**Stored results.** A result is stored only when the scan was made with a live
|
|
100
|
+
key under a non-zero retention window, and only until that window ends. Under
|
|
101
|
+
the sandbox key nothing is stored, so `get_scan` and `delete_scan` raise
|
|
102
|
+
`NotFoundError`. None of `get_scan`, `delete_scan` or `usage` charges a credit.
|
|
103
|
+
|
|
104
|
+
## Errors
|
|
105
|
+
|
|
106
|
+
Every exception the package raises is a `doc_cheap.DocCheapError`. When the API
|
|
107
|
+
answers with an error status, it is an `APIError` carrying the HTTP status, the
|
|
108
|
+
API's error code and its message, narrowed to a subclass you can catch:
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
from doc_cheap import APIError, NotFoundError, RateLimitError
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
scan = client.get_scan(scan_id)
|
|
115
|
+
except NotFoundError:
|
|
116
|
+
scan = None
|
|
117
|
+
except RateLimitError as err:
|
|
118
|
+
print("try again in", err.retry_after, "seconds")
|
|
119
|
+
except APIError as err:
|
|
120
|
+
print(err.status_code, err.code, err.message, err.request_id)
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
| Exception | When |
|
|
124
|
+
| --- | --- |
|
|
125
|
+
| `InvalidRequestError` | The request was malformed or failed validation (400, 413, 415, 422). |
|
|
126
|
+
| `AuthenticationError` | The key is missing, unknown or revoked (401). |
|
|
127
|
+
| `PermissionDeniedError` | The key may not do this (403). |
|
|
128
|
+
| `InsufficientCreditsError` | The account has no credits left (402). |
|
|
129
|
+
| `NotFoundError` | No such scan (404). |
|
|
130
|
+
| `ConflictError` | An idempotency key is reused or still in progress (409). |
|
|
131
|
+
| `RateLimitError` | Too many requests; `retry_after` says how long to wait (429). |
|
|
132
|
+
| `ServiceUnavailableError` | The service is briefly unavailable (503). |
|
|
133
|
+
| `InternalServerError` | Any other server error (5xx). |
|
|
134
|
+
| `APIConnectionError` | No answer at all: a network failure or a timeout. |
|
|
135
|
+
|
|
136
|
+
An argument the API would refuse anyway – no image, an image over 25 MiB, a
|
|
137
|
+
reference over 128 characters – raises `InvalidArgumentError` (also a
|
|
138
|
+
`ValueError`) before any request is sent.
|
|
139
|
+
|
|
140
|
+
## Options
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
import httpx
|
|
144
|
+
from doc_cheap import Client
|
|
145
|
+
|
|
146
|
+
client = Client(
|
|
147
|
+
timeout=30.0,
|
|
148
|
+
user_agent="my-app/1.0",
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
# Or bring your own httpx.Client for a proxy or a shared connection pool;
|
|
152
|
+
# the doc.cheap client leaves it open when it closes.
|
|
153
|
+
client = Client(http_client=httpx.Client(proxy="http://proxy.internal:3128"))
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
`base_url` (or the `DOC_CHEAP_API_BASE` environment variable) points the client
|
|
157
|
+
at another address, for example a test server.
|
|
158
|
+
|
|
159
|
+
## Resources
|
|
160
|
+
|
|
161
|
+
- [doc.cheap documentation](https://doc.cheap/docs)
|
|
162
|
+
- [Your first recognition](https://doc.cheap/docs/start/first-recognition)
|
|
163
|
+
- [From the sandbox to a live key](https://doc.cheap/docs/start/from-sandbox-to-live)
|
|
164
|
+
- [API reference](https://doc.cheap/docs/reference)
|
|
165
|
+
- [Handling errors](https://doc.cheap/docs/guides/handle-errors)
|
|
166
|
+
|
|
167
|
+
## License
|
|
168
|
+
|
|
169
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
doc_cheap/__init__.py,sha256=bj9ZfRD3KAuIXtM08qMppoYgHrDilGmUuKZuHGK18hU,1988
|
|
2
|
+
doc_cheap/_client.py,sha256=qrfGAfLBHV9On2ucv-dbo5poDeIFAovAKeFFmqc7abs,11258
|
|
3
|
+
doc_cheap/_errors.py,sha256=82McwhzjolDjdirA-a858MbjHQLBXvlCBE81he7CIUk,6238
|
|
4
|
+
doc_cheap/_types.py,sha256=U_pBofvyE7SxG8X0rZVwtTB9KKMjLlPkC5MSUX4ZC8M,3641
|
|
5
|
+
doc_cheap/_version.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
6
|
+
doc_cheap/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
doc_cheap-0.1.0.dist-info/METADATA,sha256=isOj_a_KenK8e0TV5dBm2qRJhat6jQtZrJ3V8clHOT4,6845
|
|
8
|
+
doc_cheap-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
9
|
+
doc_cheap-0.1.0.dist-info/licenses/LICENSE,sha256=6cIUipszzlbgE6uIjGi_fU3qNMHyGdK6PSQ8NGw1Tno,1076
|
|
10
|
+
doc_cheap-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Document Cheap Inc.
|
|
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.
|