docxtract-sdk 1.0.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.
- docxtract/__init__.py +45 -0
- docxtract/_http.py +141 -0
- docxtract/client.py +242 -0
- docxtract/errors.py +140 -0
- docxtract/models.py +188 -0
- docxtract/py.typed +0 -0
- docxtract_sdk-1.0.0.dist-info/METADATA +232 -0
- docxtract_sdk-1.0.0.dist-info/RECORD +10 -0
- docxtract_sdk-1.0.0.dist-info/WHEEL +4 -0
- docxtract_sdk-1.0.0.dist-info/licenses/LICENSE +21 -0
docxtract/__init__.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Official Python SDK for the DocXtract document extraction API.
|
|
2
|
+
|
|
3
|
+
Date: 2026-08-23 | Author: Alok | File: __init__.py
|
|
4
|
+
|
|
5
|
+
from docxtract import DocXtract
|
|
6
|
+
|
|
7
|
+
dx = DocXtract(os.environ["DOCXTRACT_API_KEY"])
|
|
8
|
+
result = dx.extract("invoice.pdf", model="invoice")
|
|
9
|
+
print(result["vendor"])
|
|
10
|
+
|
|
11
|
+
Standard library only — no runtime dependencies.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from .client import DocXtract, __version__
|
|
15
|
+
from .errors import (
|
|
16
|
+
AuthenticationError,
|
|
17
|
+
DocXtractError,
|
|
18
|
+
ExtractionFailedError,
|
|
19
|
+
JobError,
|
|
20
|
+
QuotaError,
|
|
21
|
+
RateLimitError,
|
|
22
|
+
RequestError,
|
|
23
|
+
ServerError,
|
|
24
|
+
TransportError,
|
|
25
|
+
to_error,
|
|
26
|
+
)
|
|
27
|
+
from .models import Chunk, ExtractionResult, SplitManifest
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"DocXtract",
|
|
31
|
+
"ExtractionResult",
|
|
32
|
+
"SplitManifest",
|
|
33
|
+
"Chunk",
|
|
34
|
+
"DocXtractError",
|
|
35
|
+
"AuthenticationError",
|
|
36
|
+
"QuotaError",
|
|
37
|
+
"RateLimitError",
|
|
38
|
+
"RequestError",
|
|
39
|
+
"ExtractionFailedError",
|
|
40
|
+
"JobError",
|
|
41
|
+
"ServerError",
|
|
42
|
+
"TransportError",
|
|
43
|
+
"to_error",
|
|
44
|
+
"__version__",
|
|
45
|
+
]
|
docxtract/_http.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""HTTP transport built on urllib — no third-party dependency.
|
|
2
|
+
|
|
3
|
+
Date: 2026-08-23 | Author: Alok | File: _http.py
|
|
4
|
+
Multipart bodies are encoded by hand because urllib has no equivalent of requests' `files=`.
|
|
5
|
+
Keeping the SDK dependency-free means `pip install docxtract` cannot conflict with a
|
|
6
|
+
project's pinned requests/httpx.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import mimetypes
|
|
13
|
+
import os
|
|
14
|
+
import urllib.error
|
|
15
|
+
import urllib.parse
|
|
16
|
+
import urllib.request
|
|
17
|
+
import uuid
|
|
18
|
+
from typing import Any, Dict, Optional, Tuple
|
|
19
|
+
|
|
20
|
+
from .errors import TransportError, to_error
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def encode_multipart(
|
|
24
|
+
fields: Dict[str, str],
|
|
25
|
+
file: Optional[Tuple[str, bytes, str]] = None,
|
|
26
|
+
) -> Tuple[bytes, str]:
|
|
27
|
+
"""Encode a multipart/form-data body.
|
|
28
|
+
|
|
29
|
+
``file`` is ``(field_name, contents, filename)``. Returns ``(body, content_type)``.
|
|
30
|
+
"""
|
|
31
|
+
boundary = uuid.uuid4().hex
|
|
32
|
+
parts: list[bytes] = []
|
|
33
|
+
|
|
34
|
+
for name, value in fields.items():
|
|
35
|
+
parts += [
|
|
36
|
+
f"--{boundary}\r\n".encode(),
|
|
37
|
+
f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode(),
|
|
38
|
+
f"{value}\r\n".encode(),
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
if file is not None:
|
|
42
|
+
name, content, filename = file
|
|
43
|
+
ctype = mimetypes.guess_type(filename)[0] or "application/octet-stream"
|
|
44
|
+
parts += [
|
|
45
|
+
f"--{boundary}\r\n".encode(),
|
|
46
|
+
f'Content-Disposition: form-data; name="{name}"; filename="{os.path.basename(filename)}"\r\n'.encode(),
|
|
47
|
+
f"Content-Type: {ctype}\r\n\r\n".encode(),
|
|
48
|
+
content,
|
|
49
|
+
b"\r\n",
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
parts.append(f"--{boundary}--\r\n".encode())
|
|
53
|
+
return b"".join(parts), f"multipart/form-data; boundary={boundary}"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Transport:
|
|
57
|
+
def __init__(self, api_key: str, base_url: str, timeout: int, user_agent: str) -> None:
|
|
58
|
+
self.api_key = api_key
|
|
59
|
+
self.base_url = base_url.rstrip("/")
|
|
60
|
+
self.timeout = timeout
|
|
61
|
+
self.user_agent = user_agent
|
|
62
|
+
|
|
63
|
+
def request(
|
|
64
|
+
self,
|
|
65
|
+
method: str,
|
|
66
|
+
path: str,
|
|
67
|
+
query: Optional[Dict[str, Any]] = None,
|
|
68
|
+
body: Optional[bytes] = None,
|
|
69
|
+
content_type: Optional[str] = None,
|
|
70
|
+
) -> Tuple[int, Dict[str, Any], Dict[str, str]]:
|
|
71
|
+
url = f"{self.base_url}/{path.lstrip('/')}"
|
|
72
|
+
if query:
|
|
73
|
+
clean = {k: str(v) for k, v in query.items() if v is not None}
|
|
74
|
+
if clean:
|
|
75
|
+
url += "?" + urllib.parse.urlencode(clean)
|
|
76
|
+
|
|
77
|
+
req = urllib.request.Request(url, data=body, method=method)
|
|
78
|
+
req.add_header("Authorization", f"Bearer {self.api_key}")
|
|
79
|
+
req.add_header("Accept", "application/json")
|
|
80
|
+
# urllib's default UA is "Python-urllib/3.x", which WAFs treat as suspicious and
|
|
81
|
+
# which tells you nothing in server logs.
|
|
82
|
+
req.add_header("User-Agent", self.user_agent)
|
|
83
|
+
if content_type:
|
|
84
|
+
req.add_header("Content-Type", content_type)
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
|
88
|
+
status, raw, headers = resp.status, resp.read(), dict(resp.headers)
|
|
89
|
+
except urllib.error.HTTPError as exc:
|
|
90
|
+
# 4xx/5xx still carry a JSON body we want.
|
|
91
|
+
status, raw, headers = exc.code, exc.read(), dict(exc.headers or {})
|
|
92
|
+
except urllib.error.URLError as exc:
|
|
93
|
+
raise TransportError(
|
|
94
|
+
f"Request to {url} failed: {exc.reason}", code="transport_error"
|
|
95
|
+
) from exc
|
|
96
|
+
except TimeoutError as exc:
|
|
97
|
+
raise TransportError(
|
|
98
|
+
f"Request to {url} timed out after {self.timeout}s", code="transport_error"
|
|
99
|
+
) from exc
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
parsed = json.loads(raw.decode("utf-8", "replace"))
|
|
103
|
+
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
|
104
|
+
snippet = raw.decode("utf-8", "replace").strip()[:120] or "an empty body"
|
|
105
|
+
raise TransportError(
|
|
106
|
+
f"Expected JSON from {url} but got {snippet} (HTTP {status}). "
|
|
107
|
+
"Check the base URL — note there is no /api prefix.",
|
|
108
|
+
code="transport_error",
|
|
109
|
+
status=status,
|
|
110
|
+
) from exc
|
|
111
|
+
|
|
112
|
+
if not isinstance(parsed, dict):
|
|
113
|
+
raise TransportError(
|
|
114
|
+
f"Expected a JSON object from {url}, got {type(parsed).__name__}.",
|
|
115
|
+
code="transport_error",
|
|
116
|
+
status=status,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
if parsed.get("success") is False or status >= 400:
|
|
120
|
+
err = parsed.get("error") or {}
|
|
121
|
+
reset = _header_int(headers, "X-RateLimit-Reset")
|
|
122
|
+
raise to_error(
|
|
123
|
+
err.get("message") or f"Request failed with HTTP {status}",
|
|
124
|
+
err.get("code") or "server_error",
|
|
125
|
+
status,
|
|
126
|
+
err.get("details") or {},
|
|
127
|
+
reset,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
return status, parsed, headers
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _header_int(headers: Dict[str, str], name: str) -> Optional[int]:
|
|
134
|
+
"""Header lookup that is case-insensitive, since servers vary."""
|
|
135
|
+
for key, value in headers.items():
|
|
136
|
+
if key.lower() == name.lower():
|
|
137
|
+
try:
|
|
138
|
+
return int(value)
|
|
139
|
+
except (TypeError, ValueError):
|
|
140
|
+
return None
|
|
141
|
+
return None
|
docxtract/client.py
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""DocXtract API client.
|
|
2
|
+
|
|
3
|
+
Date: 2026-08-23 | Author: Alok | File: client.py
|
|
4
|
+
``extract()`` is transparent over the sync and multi-page paths; the raw three calls stay
|
|
5
|
+
public for callers who want to drive the flow themselves.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import mimetypes
|
|
12
|
+
import os
|
|
13
|
+
import time
|
|
14
|
+
from typing import Any, Callable, Dict, List, Optional, Union
|
|
15
|
+
|
|
16
|
+
from ._http import Transport, encode_multipart
|
|
17
|
+
from .errors import DocXtractError, JobError, RateLimitError, RequestError
|
|
18
|
+
from .models import ExtractionResult, SplitManifest
|
|
19
|
+
|
|
20
|
+
__version__ = "1.0.0"
|
|
21
|
+
|
|
22
|
+
# Mirrors the server's ALLOWED_FILE_TYPES so a bad file fails before the network call.
|
|
23
|
+
_ACCEPTED = frozenset({"application/pdf", "image/jpeg", "image/png"})
|
|
24
|
+
_MAX_BYTES = 10 * 1024 * 1024
|
|
25
|
+
|
|
26
|
+
ProgressCallback = Callable[[int, int, str], None]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class DocXtract:
|
|
30
|
+
"""Client for the DocXtract v3.1 API.
|
|
31
|
+
|
|
32
|
+
``base_path`` defaults to ``/v3.1``. Set ``/v3`` only if pinned to the older version —
|
|
33
|
+
it has no ``models`` and no multi-page support, and accepts PDF only.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
api_key: str,
|
|
39
|
+
base_url: str = "https://api.docxtract.io",
|
|
40
|
+
base_path: str = "/v3.1",
|
|
41
|
+
timeout: int = 120,
|
|
42
|
+
max_retries: int = 3,
|
|
43
|
+
chunk_pause_ms: int = 0,
|
|
44
|
+
user_agent: Optional[str] = None,
|
|
45
|
+
) -> None:
|
|
46
|
+
if not api_key:
|
|
47
|
+
raise RequestError("An api_key is required.", code="invalid_api_key")
|
|
48
|
+
if api_key.startswith("sk-"):
|
|
49
|
+
# One-character mistake that would otherwise surface as an opaque 401. Several
|
|
50
|
+
# other API providers use the sk- form.
|
|
51
|
+
raise RequestError(
|
|
52
|
+
"DocXtract keys start with an underscore: sk_... The value given uses a "
|
|
53
|
+
"hyphen, so it belongs to a different provider.",
|
|
54
|
+
code="invalid_api_key",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
self.base_path = base_path.rstrip("/")
|
|
58
|
+
self.max_retries = max(0, max_retries)
|
|
59
|
+
self.chunk_pause_ms = chunk_pause_ms
|
|
60
|
+
self._http = Transport(
|
|
61
|
+
api_key,
|
|
62
|
+
base_url,
|
|
63
|
+
timeout,
|
|
64
|
+
user_agent or f"docxtract-python/{__version__} (+https://docxtract.io)",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
# ── the one call most integrations need ───────────────────────────────────
|
|
68
|
+
|
|
69
|
+
def extract(
|
|
70
|
+
self,
|
|
71
|
+
file: str,
|
|
72
|
+
model: Optional[str] = None,
|
|
73
|
+
on_progress: Optional[ProgressCallback] = None,
|
|
74
|
+
**options: Any,
|
|
75
|
+
) -> ExtractionResult:
|
|
76
|
+
"""Extract a document, transparently handling the multi-page split.
|
|
77
|
+
|
|
78
|
+
A PDF over the server's page threshold is split into chunks by the API; this method
|
|
79
|
+
processes every chunk and returns the stitched result, so one call works at any page
|
|
80
|
+
count.
|
|
81
|
+
"""
|
|
82
|
+
opts = self._options(model, options)
|
|
83
|
+
status, body, _ = self._upload(file, opts)
|
|
84
|
+
|
|
85
|
+
if status != 202:
|
|
86
|
+
if on_progress:
|
|
87
|
+
on_progress(1, 1, "complete")
|
|
88
|
+
return ExtractionResult.from_body(body)
|
|
89
|
+
|
|
90
|
+
manifest = SplitManifest.from_body(body)
|
|
91
|
+
self.process_manifest(manifest, model=model, on_progress=on_progress, **options)
|
|
92
|
+
return self.collect_result(manifest.job_id)
|
|
93
|
+
|
|
94
|
+
# ── raw three-call path ───────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
def split_document(self, file: str, model: Optional[str] = None, **options: Any) -> SplitManifest:
|
|
97
|
+
"""POST documents and require the split branch."""
|
|
98
|
+
status, body, _ = self._upload(file, self._options(model, options))
|
|
99
|
+
if status != 202:
|
|
100
|
+
raise RequestError(
|
|
101
|
+
"Document was processed synchronously (under the page threshold) and produced "
|
|
102
|
+
"no split manifest. Use extract().",
|
|
103
|
+
code="invalid_request",
|
|
104
|
+
status=status,
|
|
105
|
+
)
|
|
106
|
+
return SplitManifest.from_body(body)
|
|
107
|
+
|
|
108
|
+
def process_chunk(self, chunk_job_id: str, model: Optional[str] = None, **options: Any) -> Dict[str, Any]:
|
|
109
|
+
"""POST process for one chunk.
|
|
110
|
+
|
|
111
|
+
Idempotent — replaying a completed chunk is neither an error nor charged again.
|
|
112
|
+
"""
|
|
113
|
+
body, ctype = encode_multipart({"options": json.dumps(self._options(model, options))})
|
|
114
|
+
_, parsed, _ = self._http.request(
|
|
115
|
+
"POST", self._path("process"),
|
|
116
|
+
query={"job_id": chunk_job_id}, body=body, content_type=ctype,
|
|
117
|
+
)
|
|
118
|
+
return {k: v for k, v in parsed.items() if k not in ("success", "data")}
|
|
119
|
+
|
|
120
|
+
def collect_result(self, job_id: str, finalize: bool = False) -> ExtractionResult:
|
|
121
|
+
"""GET result for the parent job.
|
|
122
|
+
|
|
123
|
+
``finalize=True`` is IRREVERSIBLE: it permanently deletes all extracted data for the
|
|
124
|
+
job after responding. Only pass it once the result is stored on your side.
|
|
125
|
+
"""
|
|
126
|
+
query: Dict[str, Any] = {"job_id": job_id}
|
|
127
|
+
if finalize:
|
|
128
|
+
query["finalize"] = "true"
|
|
129
|
+
_, body, _ = self._http.request("GET", self._path("result"), query=query)
|
|
130
|
+
return ExtractionResult.from_body(body)
|
|
131
|
+
|
|
132
|
+
def process_manifest(
|
|
133
|
+
self,
|
|
134
|
+
manifest: SplitManifest,
|
|
135
|
+
model: Optional[str] = None,
|
|
136
|
+
on_progress: Optional[ProgressCallback] = None,
|
|
137
|
+
**options: Any,
|
|
138
|
+
) -> None:
|
|
139
|
+
"""Process every chunk in page order, with retry.
|
|
140
|
+
|
|
141
|
+
Sequential by design: the API's default limit is 10 requests/minute, so parallel
|
|
142
|
+
chunk calls do not finish sooner — they turn the work into 429s. Use
|
|
143
|
+
``chunk_pause_ms`` to pace calls on a tighter key.
|
|
144
|
+
"""
|
|
145
|
+
total = manifest.chunk_count
|
|
146
|
+
|
|
147
|
+
for i, chunk in enumerate(manifest.chunks):
|
|
148
|
+
if manifest.expired:
|
|
149
|
+
raise JobError(
|
|
150
|
+
f"Job {manifest.job_id} expired before all chunks were processed "
|
|
151
|
+
f"({i} of {total} done). Re-upload the document.",
|
|
152
|
+
code="job_expired",
|
|
153
|
+
status=410,
|
|
154
|
+
details={"job_id": manifest.job_id, "chunks_done": i, "chunks_total": total},
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
self._with_retry(lambda c=chunk: self.process_chunk(c.job_id, model, **options))
|
|
158
|
+
if on_progress:
|
|
159
|
+
on_progress(i + 1, total, "chunk")
|
|
160
|
+
|
|
161
|
+
if self.chunk_pause_ms > 0 and i + 1 < total:
|
|
162
|
+
time.sleep(self.chunk_pause_ms / 1000)
|
|
163
|
+
|
|
164
|
+
# ── discovery ─────────────────────────────────────────────────────────────
|
|
165
|
+
|
|
166
|
+
def models(self) -> List[Any]:
|
|
167
|
+
"""Document types this key may use. Costs no credits — safe to call freely."""
|
|
168
|
+
if self.base_path != "/v3.1":
|
|
169
|
+
raise RequestError(
|
|
170
|
+
"models exists only in v3.1. Remove the base_path override to use it.",
|
|
171
|
+
code="method_not_allowed",
|
|
172
|
+
)
|
|
173
|
+
_, body, _ = self._http.request("GET", self._path("models"))
|
|
174
|
+
return list((body.get("data") or {}).get("models") or [])
|
|
175
|
+
|
|
176
|
+
def health(self) -> Dict[str, Any]:
|
|
177
|
+
_, body, _ = self._http.request("GET", self._path("health"))
|
|
178
|
+
return body
|
|
179
|
+
|
|
180
|
+
def authorised(self) -> bool:
|
|
181
|
+
"""Whether the configured key is currently active."""
|
|
182
|
+
try:
|
|
183
|
+
_, body, _ = self._http.request("GET", self._path("authorised"))
|
|
184
|
+
return body.get("success") is True
|
|
185
|
+
except DocXtractError:
|
|
186
|
+
return False
|
|
187
|
+
|
|
188
|
+
# ── internals ─────────────────────────────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
def _path(self, endpoint: str) -> str:
|
|
191
|
+
return f"{self.base_path}/{endpoint}"
|
|
192
|
+
|
|
193
|
+
@staticmethod
|
|
194
|
+
def _options(model: Optional[str], extra: Dict[str, Any]) -> Dict[str, Any]:
|
|
195
|
+
opts = dict(extra)
|
|
196
|
+
if model:
|
|
197
|
+
opts["model"] = model
|
|
198
|
+
return opts
|
|
199
|
+
|
|
200
|
+
def _upload(self, file: str, options: Dict[str, Any]):
|
|
201
|
+
content, filename = self._read_file(file)
|
|
202
|
+
body, ctype = encode_multipart(
|
|
203
|
+
{"options": json.dumps(options)}, ("file", content, filename)
|
|
204
|
+
)
|
|
205
|
+
return self._http.request(
|
|
206
|
+
"POST", self._path("documents"), body=body, content_type=ctype
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
def _read_file(self, file: str):
|
|
210
|
+
"""Validate locally so an unsupported file never costs a round trip."""
|
|
211
|
+
if not os.path.isfile(file):
|
|
212
|
+
raise RequestError(f"File not found or not readable: {file}", code="invalid_file")
|
|
213
|
+
|
|
214
|
+
size = os.path.getsize(file)
|
|
215
|
+
if size > _MAX_BYTES:
|
|
216
|
+
raise RequestError(
|
|
217
|
+
f"File is {size / 1048576:.1f} MB; the limit is {_MAX_BYTES // 1048576} MB.",
|
|
218
|
+
code="file_too_large",
|
|
219
|
+
details={"size": size},
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
guessed = mimetypes.guess_type(file)[0]
|
|
223
|
+
if guessed not in _ACCEPTED:
|
|
224
|
+
raise RequestError(
|
|
225
|
+
f"Unsupported file type {guessed or 'unknown'!r}. Accepted: PDF, JPG, PNG.",
|
|
226
|
+
code="invalid_file_type",
|
|
227
|
+
details={"uploaded_type": guessed, "accepted_types": sorted(_ACCEPTED)},
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
with open(file, "rb") as fh:
|
|
231
|
+
return fh.read(), file
|
|
232
|
+
|
|
233
|
+
def _with_retry(self, call: Callable[[], Any]) -> Any:
|
|
234
|
+
"""Retry retryable failures, honouring X-RateLimit-Reset over a guessed backoff."""
|
|
235
|
+
for attempt in range(self.max_retries + 1):
|
|
236
|
+
try:
|
|
237
|
+
return call()
|
|
238
|
+
except DocXtractError as exc:
|
|
239
|
+
if attempt >= self.max_retries or not exc.retryable:
|
|
240
|
+
raise
|
|
241
|
+
wait = exc.retry_after if isinstance(exc, RateLimitError) and exc.retry_after is not None else 2 ** attempt
|
|
242
|
+
time.sleep(min(wait, 60))
|
docxtract/errors.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Typed exceptions mirroring the API's stable error codes.
|
|
2
|
+
|
|
3
|
+
Date: 2026-08-23 | Author: Alok | File: errors.py
|
|
4
|
+
Branch on the exception class or ``code``, never on the message — wording is not stable.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import time
|
|
10
|
+
from typing import Any, Dict, Optional, Type
|
|
11
|
+
|
|
12
|
+
# Retrying these could plausibly succeed. Deliberately conservative: caller input errors,
|
|
13
|
+
# dead jobs and exhausted credits are excluded.
|
|
14
|
+
_RETRYABLE = frozenset({
|
|
15
|
+
"rate_limit_exceeded",
|
|
16
|
+
"chunk_in_progress",
|
|
17
|
+
"extraction_failed",
|
|
18
|
+
"persist_failed",
|
|
19
|
+
"server_error",
|
|
20
|
+
"server_busy",
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class DocXtractError(Exception):
|
|
25
|
+
"""Base for every API error."""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
message: str,
|
|
30
|
+
code: str = "server_error",
|
|
31
|
+
status: int = 0,
|
|
32
|
+
details: Optional[Dict[str, Any]] = None,
|
|
33
|
+
) -> None:
|
|
34
|
+
super().__init__(message)
|
|
35
|
+
self.message = message
|
|
36
|
+
self.code = code
|
|
37
|
+
self.status = status
|
|
38
|
+
self.details = details or {}
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def retryable(self) -> bool:
|
|
42
|
+
return self.code in _RETRYABLE
|
|
43
|
+
|
|
44
|
+
def __repr__(self) -> str:
|
|
45
|
+
return f"{type(self).__name__}(code={self.code!r}, status={self.status}, message={self.message!r})"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class AuthenticationError(DocXtractError):
|
|
49
|
+
"""invalid_api_key, expired_api_key (401)."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class QuotaError(DocXtractError):
|
|
53
|
+
"""usage_limit_exceeded, insufficient_credits (402)."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class RequestError(DocXtractError):
|
|
57
|
+
"""Caller input: invalid_request, invalid_file, invalid_file_type, file_too_large,
|
|
58
|
+
invalid_options, unknown_model, page_limit_exceeded, method_not_allowed."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class ExtractionFailedError(DocXtractError):
|
|
62
|
+
"""extraction_failed (422). Note this is billed on the synchronous path."""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class JobError(DocXtractError):
|
|
66
|
+
"""job_not_found, job_expired, chunk_in_progress, chunk_source_lost."""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class ServerError(DocXtractError):
|
|
70
|
+
"""server_error, persist_failed, server_busy (5xx)."""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class RateLimitError(DocXtractError):
|
|
74
|
+
"""rate_limit_exceeded, too_many_open_jobs (429)."""
|
|
75
|
+
|
|
76
|
+
def __init__(self, *args: Any, reset_at: Optional[int] = None, **kwargs: Any) -> None:
|
|
77
|
+
super().__init__(*args, **kwargs)
|
|
78
|
+
self.reset_at = reset_at
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def retry_after(self) -> Optional[int]:
|
|
82
|
+
"""Seconds to wait, or None when the server sent no reset header.
|
|
83
|
+
|
|
84
|
+
Returns None rather than a guess so callers use their own backoff knowingly.
|
|
85
|
+
"""
|
|
86
|
+
if self.reset_at is None:
|
|
87
|
+
return None
|
|
88
|
+
return max(0, self.reset_at - int(time.time()))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class TransportError(DocXtractError):
|
|
92
|
+
"""Network-level failure or a non-JSON body — the API never answered."""
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def retryable(self) -> bool:
|
|
96
|
+
return True
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
_MAP: Dict[str, Type[DocXtractError]] = {
|
|
100
|
+
"invalid_api_key": AuthenticationError,
|
|
101
|
+
"expired_api_key": AuthenticationError,
|
|
102
|
+
"usage_limit_exceeded": QuotaError,
|
|
103
|
+
"insufficient_credits": QuotaError,
|
|
104
|
+
"rate_limit_exceeded": RateLimitError,
|
|
105
|
+
"too_many_open_jobs": RateLimitError,
|
|
106
|
+
"invalid_request": RequestError,
|
|
107
|
+
"invalid_file": RequestError,
|
|
108
|
+
"invalid_file_type": RequestError,
|
|
109
|
+
"file_too_large": RequestError,
|
|
110
|
+
"invalid_options": RequestError,
|
|
111
|
+
"unknown_model": RequestError,
|
|
112
|
+
"page_limit_exceeded": RequestError,
|
|
113
|
+
"method_not_allowed": RequestError,
|
|
114
|
+
"extraction_failed": ExtractionFailedError,
|
|
115
|
+
"job_not_found": JobError,
|
|
116
|
+
"job_expired": JobError,
|
|
117
|
+
"chunk_in_progress": JobError,
|
|
118
|
+
"chunk_source_lost": JobError,
|
|
119
|
+
"server_error": ServerError,
|
|
120
|
+
"persist_failed": ServerError,
|
|
121
|
+
"server_busy": ServerError,
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def to_error(
|
|
126
|
+
message: str,
|
|
127
|
+
code: str,
|
|
128
|
+
status: int,
|
|
129
|
+
details: Optional[Dict[str, Any]] = None,
|
|
130
|
+
reset_at: Optional[int] = None,
|
|
131
|
+
) -> DocXtractError:
|
|
132
|
+
"""Build the typed exception for an API error code.
|
|
133
|
+
|
|
134
|
+
An unrecognised code falls back to the base class rather than raising, so a new
|
|
135
|
+
server-side code cannot break a deployed copy of this SDK.
|
|
136
|
+
"""
|
|
137
|
+
cls = _MAP.get(code, DocXtractError)
|
|
138
|
+
if cls is RateLimitError:
|
|
139
|
+
return RateLimitError(message, code=code, status=status, details=details, reset_at=reset_at)
|
|
140
|
+
return cls(message, code=code, status=status, details=details)
|
docxtract/models.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Result and manifest wrappers.
|
|
2
|
+
|
|
3
|
+
Date: 2026-08-23 | Author: Alok | File: models.py
|
|
4
|
+
The API places metadata at the root level beside ``data``, so both are exposed without
|
|
5
|
+
pretending ``data`` has a fixed schema — its shape varies by document type.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from typing import Any, Dict, List, Optional
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ExtractionResult:
|
|
15
|
+
"""An extraction result — covers both the sync response and the stitched multi-page one."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, data: Dict[str, Any], meta: Optional[Dict[str, Any]] = None) -> None:
|
|
18
|
+
self.data = data
|
|
19
|
+
self.meta = meta or {}
|
|
20
|
+
|
|
21
|
+
@classmethod
|
|
22
|
+
def from_body(cls, body: Dict[str, Any]) -> "ExtractionResult":
|
|
23
|
+
meta = {k: v for k, v in body.items() if k not in ("data", "success")}
|
|
24
|
+
data = body.get("data") or {}
|
|
25
|
+
if not isinstance(data, dict):
|
|
26
|
+
data = {"value": data}
|
|
27
|
+
return cls(data, meta)
|
|
28
|
+
|
|
29
|
+
def get(self, path: str, default: Any = None) -> Any:
|
|
30
|
+
"""Dot-path read: get('Header.pages'), get('line_items.0.hsn')."""
|
|
31
|
+
node: Any = self.data
|
|
32
|
+
for seg in path.split("."):
|
|
33
|
+
if isinstance(node, dict) and seg in node:
|
|
34
|
+
node = node[seg]
|
|
35
|
+
elif isinstance(node, list) and seg.isdigit() and int(seg) < len(node):
|
|
36
|
+
node = node[int(seg)]
|
|
37
|
+
else:
|
|
38
|
+
return default
|
|
39
|
+
return node
|
|
40
|
+
|
|
41
|
+
# ── metadata ──────────────────────────────────────────────────────────────
|
|
42
|
+
@property
|
|
43
|
+
def pages(self) -> Optional[int]:
|
|
44
|
+
return self.meta.get("pages")
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def processing_time_ms(self) -> Optional[int]:
|
|
48
|
+
return self.meta.get("processing_time_ms")
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def model_used(self) -> Optional[str]:
|
|
52
|
+
return self.meta.get("model_used")
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def extraction_id(self) -> Optional[str]:
|
|
56
|
+
"""Present only when store_db was true, which is not the default server-side."""
|
|
57
|
+
return self.meta.get("extraction_id")
|
|
58
|
+
|
|
59
|
+
# ── multi-page ────────────────────────────────────────────────────────────
|
|
60
|
+
@property
|
|
61
|
+
def job_id(self) -> Optional[str]:
|
|
62
|
+
return self.meta.get("job_id")
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def complete(self) -> bool:
|
|
66
|
+
"""A synchronous result is complete by definition; only a partial collect says no."""
|
|
67
|
+
return self.meta.get("status", "complete") == "complete"
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def pending_pages(self) -> List[int]:
|
|
71
|
+
return list(self.meta.get("pending_pages") or [])
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def failed_pages(self) -> List[int]:
|
|
75
|
+
return list(self.meta.get("failed_pages") or [])
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def warnings(self) -> List[str]:
|
|
79
|
+
return list(self.meta.get("warnings") or [])
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def credits_used(self) -> Optional[int]:
|
|
83
|
+
return self.meta.get("credits_used")
|
|
84
|
+
|
|
85
|
+
# ── convenience ───────────────────────────────────────────────────────────
|
|
86
|
+
def __getitem__(self, key: str) -> Any:
|
|
87
|
+
return self.data[key]
|
|
88
|
+
|
|
89
|
+
def __contains__(self, key: object) -> bool:
|
|
90
|
+
return key in self.data
|
|
91
|
+
|
|
92
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
93
|
+
return {"data": self.data, **self.meta}
|
|
94
|
+
|
|
95
|
+
def to_dataframe(self, path: Optional[str] = None):
|
|
96
|
+
"""Row-shaped extractions (invoice line items, bank statement rows) as a DataFrame.
|
|
97
|
+
|
|
98
|
+
Requires pandas: ``pip install 'docxtract[pandas]'``. Pass ``path`` to point at the
|
|
99
|
+
list, or leave it out to use the first list of dicts found in ``data``.
|
|
100
|
+
"""
|
|
101
|
+
try:
|
|
102
|
+
import pandas as pd
|
|
103
|
+
except ImportError as exc: # pragma: no cover - depends on the environment
|
|
104
|
+
raise ImportError(
|
|
105
|
+
"to_dataframe() needs pandas. Install with: pip install 'docxtract[pandas]'"
|
|
106
|
+
) from exc
|
|
107
|
+
|
|
108
|
+
rows = self.get(path) if path else None
|
|
109
|
+
if rows is None:
|
|
110
|
+
rows = next(
|
|
111
|
+
(v for v in self.data.values()
|
|
112
|
+
if isinstance(v, list) and v and isinstance(v[0], dict)),
|
|
113
|
+
None,
|
|
114
|
+
)
|
|
115
|
+
if rows is None:
|
|
116
|
+
raise ValueError(
|
|
117
|
+
"No row-shaped list found in the extracted data. Pass an explicit path, "
|
|
118
|
+
"e.g. to_dataframe('line_items')."
|
|
119
|
+
)
|
|
120
|
+
return pd.DataFrame(rows)
|
|
121
|
+
|
|
122
|
+
def __repr__(self) -> str:
|
|
123
|
+
return f"ExtractionResult(fields={len(self.data)}, complete={self.complete})"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class Chunk:
|
|
127
|
+
"""One entry from the 202 split manifest."""
|
|
128
|
+
|
|
129
|
+
__slots__ = ("job_id", "pages")
|
|
130
|
+
|
|
131
|
+
def __init__(self, job_id: str, pages: str) -> None:
|
|
132
|
+
self.job_id = job_id
|
|
133
|
+
self.pages = pages
|
|
134
|
+
|
|
135
|
+
def __repr__(self) -> str:
|
|
136
|
+
return f"Chunk(job_id={self.job_id!r}, pages={self.pages!r})"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class SplitManifest:
|
|
140
|
+
"""The 202 response when a PDF exceeds the server's page threshold."""
|
|
141
|
+
|
|
142
|
+
def __init__(self, job_id: str, pages: int, chunks: List[Chunk], expires_at: Optional[str]) -> None:
|
|
143
|
+
self.job_id = job_id
|
|
144
|
+
self.pages = pages
|
|
145
|
+
self.chunks = chunks
|
|
146
|
+
self.expires_at = expires_at
|
|
147
|
+
|
|
148
|
+
@classmethod
|
|
149
|
+
def from_body(cls, body: Dict[str, Any]) -> "SplitManifest":
|
|
150
|
+
raw = body.get("data") or []
|
|
151
|
+
chunks = [
|
|
152
|
+
Chunk(str(c.get("job_id", "")), str(c.get("pages", "")))
|
|
153
|
+
for c in raw
|
|
154
|
+
if isinstance(c, dict)
|
|
155
|
+
]
|
|
156
|
+
return cls(
|
|
157
|
+
str(body.get("job_id", "")),
|
|
158
|
+
int(body.get("pages") or 0),
|
|
159
|
+
chunks,
|
|
160
|
+
body.get("expires_at"),
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
@property
|
|
164
|
+
def expires_at_unix(self) -> Optional[int]:
|
|
165
|
+
if not self.expires_at:
|
|
166
|
+
return None
|
|
167
|
+
try:
|
|
168
|
+
dt = datetime.fromisoformat(str(self.expires_at).replace("Z", "+00:00"))
|
|
169
|
+
except ValueError:
|
|
170
|
+
return None
|
|
171
|
+
if dt.tzinfo is None:
|
|
172
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
173
|
+
return int(dt.timestamp())
|
|
174
|
+
|
|
175
|
+
@property
|
|
176
|
+
def expired(self) -> bool:
|
|
177
|
+
"""An absent expiry must not read as expired — that would abandon a valid job."""
|
|
178
|
+
ts = self.expires_at_unix
|
|
179
|
+
if ts is None:
|
|
180
|
+
return False
|
|
181
|
+
return datetime.now(timezone.utc).timestamp() >= ts
|
|
182
|
+
|
|
183
|
+
@property
|
|
184
|
+
def chunk_count(self) -> int:
|
|
185
|
+
return len(self.chunks)
|
|
186
|
+
|
|
187
|
+
def __repr__(self) -> str:
|
|
188
|
+
return f"SplitManifest(job_id={self.job_id!r}, pages={self.pages}, chunks={self.chunk_count})"
|
docxtract/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: docxtract-sdk
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Official Python SDK for the DocXtract document extraction API
|
|
5
|
+
Project-URL: Homepage, https://docxtract.io
|
|
6
|
+
Project-URL: Documentation, https://docs.docxtract.io
|
|
7
|
+
Project-URL: Source, https://github.com/docxtractio/python-sdk
|
|
8
|
+
Project-URL: Issues, https://github.com/docxtractio/python-sdk/issues
|
|
9
|
+
Author: RPATech
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: document-extraction,docxtract,idp,invoice,kyc,ocr
|
|
13
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Topic :: Text Processing
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Python: >=3.9
|
|
20
|
+
Provides-Extra: pandas
|
|
21
|
+
Requires-Dist: pandas>=1.3; extra == 'pandas'
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# docxtract
|
|
25
|
+
|
|
26
|
+
Official Python client for the [DocXtract](https://docxtract.io) document extraction API.
|
|
27
|
+
|
|
28
|
+
## Requirements
|
|
29
|
+
|
|
30
|
+
Python 3.9+. **No dependencies** — standard library only (`urllib`), so `pip install
|
|
31
|
+
docxtract` pulls nothing and cannot conflict with your project's pinned `requests` or
|
|
32
|
+
`httpx`.
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install docxtract-sdk
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
> **Note** — the package is `docxtract-sdk` but the import is `docxtract`. The shorter name
|
|
41
|
+
> was already taken on PyPI by an unrelated DOCX text extractor.
|
|
42
|
+
|
|
43
|
+
## Get an API key
|
|
44
|
+
|
|
45
|
+
The SDK is free and open source. The API it talks to needs an account.
|
|
46
|
+
|
|
47
|
+
1. Go to **[docxtract.io](https://docxtract.io)** and choose **Start Free Trial**
|
|
48
|
+
2. Credentials arrive by email — no card required, no sales call
|
|
49
|
+
3. Sign in at **[app.docxtract.io](https://app.docxtract.io)** and copy your key from **Settings**
|
|
50
|
+
|
|
51
|
+
Keep the key in an environment variable, never in source control:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
export DOCXTRACT_API_KEY=sk_your_api_key
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Two calls cost no credits, so you can confirm setup before spending anything:
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
dx.authorised() # is the key active?
|
|
61
|
+
dx.models() # which document types may it use?
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Need higher limits, more credits, or a custom document type? support@docxtract.io.
|
|
65
|
+
|
|
66
|
+
## Quickstart
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
import os
|
|
70
|
+
from docxtract import DocXtract
|
|
71
|
+
|
|
72
|
+
dx = DocXtract(os.environ["DOCXTRACT_API_KEY"])
|
|
73
|
+
|
|
74
|
+
result = dx.extract("invoice.pdf", model="invoice")
|
|
75
|
+
|
|
76
|
+
print(result["vendor"])
|
|
77
|
+
print(result.get("line_items.0.hsn")) # dot paths for nested values
|
|
78
|
+
print(result.pages, result.extraction_id)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
> DocXtract keys use an **underscore** (`sk_`). A hyphen after `sk` means the key belongs to
|
|
82
|
+
> a different API provider — the SDK rejects it up front rather than letting you debug a 401.
|
|
83
|
+
|
|
84
|
+
## Large PDFs are the point of this SDK
|
|
85
|
+
|
|
86
|
+
The API does not process a PDF over 3 pages synchronously. It splits the document and returns
|
|
87
|
+
`202` with a chunk manifest; you then call `process` per chunk and `result` to
|
|
88
|
+
collect, handling retries, single-flight conflicts, a 2-hour job TTL, and partial results.
|
|
89
|
+
|
|
90
|
+
`extract()` does all of it:
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
result = dx.extract("500-page-statement.pdf", model="bank_statement")
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Same call, any page count. With progress:
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
dx.extract("big.pdf", model="invoice",
|
|
100
|
+
on_progress=lambda done, total, stage: print(f"{done}/{total}"))
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Why chunks run sequentially
|
|
104
|
+
|
|
105
|
+
The API's default rate limit is 10 requests per minute, so parallel chunk calls do not finish
|
|
106
|
+
sooner — they turn the work into `429`s. Pace them if your key is tighter:
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
dx = DocXtract(key, chunk_pause_ms=500)
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### Manual control
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
manifest = dx.split_document("big.pdf", model="invoice")
|
|
116
|
+
|
|
117
|
+
for chunk in manifest.chunks:
|
|
118
|
+
dx.process_chunk(chunk.job_id, model="invoice") # safe to retry
|
|
119
|
+
|
|
120
|
+
result = dx.collect_result(manifest.job_id)
|
|
121
|
+
|
|
122
|
+
if not result.complete:
|
|
123
|
+
print(result.failed_pages, result.pending_pages)
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
`collect_result()` is a pure read, re-fetchable within the TTL — usable as a progress poll
|
|
127
|
+
from a separate worker.
|
|
128
|
+
|
|
129
|
+
> **`collect_result(job_id, finalize=True)` is irreversible.** It permanently deletes the
|
|
130
|
+
> job's extracted data. Only pass it once the result is stored on your side.
|
|
131
|
+
|
|
132
|
+
## Tabular extractions
|
|
133
|
+
|
|
134
|
+
Invoice line items and bank statement rows come back as lists of dicts:
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
df = result.to_dataframe("line_items") # needs: pip install 'docxtract-sdk[pandas]'
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
With no argument it uses the first row-shaped list it finds in the data.
|
|
141
|
+
|
|
142
|
+
## Discovering document types
|
|
143
|
+
|
|
144
|
+
```python
|
|
145
|
+
dx.models() # costs no credits — safe to call freely
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
## Error handling
|
|
149
|
+
|
|
150
|
+
```python
|
|
151
|
+
from docxtract import DocXtractError, RateLimitError, QuotaError
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
dx.extract("invoice.pdf", model="invoice")
|
|
155
|
+
except RateLimitError as exc:
|
|
156
|
+
time.sleep(exc.retry_after or 30) # from X-RateLimit-Reset
|
|
157
|
+
except QuotaError:
|
|
158
|
+
pass # out of credits — do not retry
|
|
159
|
+
except DocXtractError as exc:
|
|
160
|
+
if exc.retryable:
|
|
161
|
+
requeue()
|
|
162
|
+
else:
|
|
163
|
+
raise
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Or branch on the code:
|
|
167
|
+
|
|
168
|
+
```python
|
|
169
|
+
except DocXtractError as exc:
|
|
170
|
+
match exc.code:
|
|
171
|
+
case "insufficient_credits": notify_billing()
|
|
172
|
+
case "unknown_model": report_bad_model(exc.details)
|
|
173
|
+
case _:
|
|
174
|
+
if not exc.retryable:
|
|
175
|
+
raise
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
| Exception | Codes |
|
|
179
|
+
|---|---|
|
|
180
|
+
| `AuthenticationError` | `invalid_api_key`, `expired_api_key` |
|
|
181
|
+
| `QuotaError` | `usage_limit_exceeded`, `insufficient_credits` |
|
|
182
|
+
| `RateLimitError` | `rate_limit_exceeded`, `too_many_open_jobs` |
|
|
183
|
+
| `RequestError` | `invalid_request`, `invalid_file`, `invalid_file_type`, `file_too_large`, `invalid_options`, `unknown_model`, `page_limit_exceeded`, `method_not_allowed` |
|
|
184
|
+
| `ExtractionFailedError` | `extraction_failed` |
|
|
185
|
+
| `JobError` | `job_not_found`, `job_expired`, `chunk_in_progress`, `chunk_source_lost` |
|
|
186
|
+
| `ServerError` | `server_error`, `persist_failed`, `server_busy` |
|
|
187
|
+
| `TransportError` | network failure — the API never answered |
|
|
188
|
+
|
|
189
|
+
An unrecognised code falls back to `DocXtractError` rather than raising, so a new server-side
|
|
190
|
+
code cannot break a deployed copy.
|
|
191
|
+
|
|
192
|
+
> **Billing note.** `extraction_failed` on the synchronous path **still deducts 1 credit**. On
|
|
193
|
+
> the multi-page path the chunk stays available and is not charged until it succeeds.
|
|
194
|
+
> `persist_failed` charges nothing.
|
|
195
|
+
|
|
196
|
+
## Configuration
|
|
197
|
+
|
|
198
|
+
```python
|
|
199
|
+
dx = DocXtract(
|
|
200
|
+
api_key=os.environ["DOCXTRACT_API_KEY"],
|
|
201
|
+
base_url="https://api.docxtract.io", # default — no /api prefix
|
|
202
|
+
base_path="/v3.1", # default
|
|
203
|
+
timeout=120, # seconds
|
|
204
|
+
max_retries=3,
|
|
205
|
+
chunk_pause_ms=0,
|
|
206
|
+
)
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
> If you see a `TransportError` about non-JSON output, the base URL is usually wrong. `/api`
|
|
210
|
+
> is the server's docroot, not part of the public path.
|
|
211
|
+
|
|
212
|
+
`base_path="/v3"` exists for customers still pinned to the old version. v3 has no
|
|
213
|
+
`models` and no multi-page support; `models()` raises a clear error rather than a
|
|
214
|
+
confusing 404.
|
|
215
|
+
|
|
216
|
+
## Tests
|
|
217
|
+
|
|
218
|
+
```bash
|
|
219
|
+
python3 -m unittest discover -s tests
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
Offline: no API key or network needed.
|
|
223
|
+
|
|
224
|
+
## Links
|
|
225
|
+
|
|
226
|
+
- Documentation — https://docs.docxtract.io
|
|
227
|
+
- Interactive API reference — https://app.docxtract.io/api-reference.php
|
|
228
|
+
- Support — support@docxtract.io
|
|
229
|
+
|
|
230
|
+
---
|
|
231
|
+
|
|
232
|
+
**Built by RPATech** | [docxtract.io](https://docxtract.io)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
docxtract/__init__.py,sha256=YuW6hV62zBER6pT5EjLeOw-DlmAI1RzVz4SsIDFAvP4,987
|
|
2
|
+
docxtract/_http.py,sha256=c8IvqC6UQ4N1dtnp3NlCzOrhyAm7_d3GKPWoXwHYG2U,5179
|
|
3
|
+
docxtract/client.py,sha256=Khv3lEp4eDQlehX4EgRQky1FSk-Ei5wZGw7sx656Gc8,9973
|
|
4
|
+
docxtract/errors.py,sha256=yWYo0WH4LbtjO_rIDXNJJZFNs2AOOV06xDUMkNbrbg4,4261
|
|
5
|
+
docxtract/models.py,sha256=q89byoURzy6pOKXGw71vB82QG7VK-wF430vpM0xl79E,6738
|
|
6
|
+
docxtract/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
docxtract_sdk-1.0.0.dist-info/METADATA,sha256=MGA2PCC5P7eM6qBy5_96wFlbAPeiiZRmqq7RkwrczU0,7182
|
|
8
|
+
docxtract_sdk-1.0.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
9
|
+
docxtract_sdk-1.0.0.dist-info/licenses/LICENSE,sha256=dWyRX2BAzXjLwqp3epPqXGvFarLWzY3vgDKvsaR0Uyg,1064
|
|
10
|
+
docxtract_sdk-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 RPATech
|
|
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.
|