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 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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.