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 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
+ [![PyPI](https://img.shields.io/pypi/v/doc-cheap.svg)](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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.