docxtract-sdk 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- docxtract_sdk-1.0.0/.gitignore +6 -0
- docxtract_sdk-1.0.0/LICENSE +21 -0
- docxtract_sdk-1.0.0/PKG-INFO +232 -0
- docxtract_sdk-1.0.0/README.md +209 -0
- docxtract_sdk-1.0.0/pyproject.toml +39 -0
- docxtract_sdk-1.0.0/src/docxtract/__init__.py +45 -0
- docxtract_sdk-1.0.0/src/docxtract/_http.py +141 -0
- docxtract_sdk-1.0.0/src/docxtract/client.py +242 -0
- docxtract_sdk-1.0.0/src/docxtract/errors.py +140 -0
- docxtract_sdk-1.0.0/src/docxtract/models.py +188 -0
- docxtract_sdk-1.0.0/src/docxtract/py.typed +0 -0
- docxtract_sdk-1.0.0/tests/test_sdk.py +233 -0
|
@@ -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.
|
|
@@ -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,209 @@
|
|
|
1
|
+
# docxtract
|
|
2
|
+
|
|
3
|
+
Official Python client for the [DocXtract](https://docxtract.io) document extraction API.
|
|
4
|
+
|
|
5
|
+
## Requirements
|
|
6
|
+
|
|
7
|
+
Python 3.9+. **No dependencies** — standard library only (`urllib`), so `pip install
|
|
8
|
+
docxtract` pulls nothing and cannot conflict with your project's pinned `requests` or
|
|
9
|
+
`httpx`.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install docxtract-sdk
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
> **Note** — the package is `docxtract-sdk` but the import is `docxtract`. The shorter name
|
|
18
|
+
> was already taken on PyPI by an unrelated DOCX text extractor.
|
|
19
|
+
|
|
20
|
+
## Get an API key
|
|
21
|
+
|
|
22
|
+
The SDK is free and open source. The API it talks to needs an account.
|
|
23
|
+
|
|
24
|
+
1. Go to **[docxtract.io](https://docxtract.io)** and choose **Start Free Trial**
|
|
25
|
+
2. Credentials arrive by email — no card required, no sales call
|
|
26
|
+
3. Sign in at **[app.docxtract.io](https://app.docxtract.io)** and copy your key from **Settings**
|
|
27
|
+
|
|
28
|
+
Keep the key in an environment variable, never in source control:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
export DOCXTRACT_API_KEY=sk_your_api_key
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Two calls cost no credits, so you can confirm setup before spending anything:
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
dx.authorised() # is the key active?
|
|
38
|
+
dx.models() # which document types may it use?
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Need higher limits, more credits, or a custom document type? support@docxtract.io.
|
|
42
|
+
|
|
43
|
+
## Quickstart
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
import os
|
|
47
|
+
from docxtract import DocXtract
|
|
48
|
+
|
|
49
|
+
dx = DocXtract(os.environ["DOCXTRACT_API_KEY"])
|
|
50
|
+
|
|
51
|
+
result = dx.extract("invoice.pdf", model="invoice")
|
|
52
|
+
|
|
53
|
+
print(result["vendor"])
|
|
54
|
+
print(result.get("line_items.0.hsn")) # dot paths for nested values
|
|
55
|
+
print(result.pages, result.extraction_id)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
> DocXtract keys use an **underscore** (`sk_`). A hyphen after `sk` means the key belongs to
|
|
59
|
+
> a different API provider — the SDK rejects it up front rather than letting you debug a 401.
|
|
60
|
+
|
|
61
|
+
## Large PDFs are the point of this SDK
|
|
62
|
+
|
|
63
|
+
The API does not process a PDF over 3 pages synchronously. It splits the document and returns
|
|
64
|
+
`202` with a chunk manifest; you then call `process` per chunk and `result` to
|
|
65
|
+
collect, handling retries, single-flight conflicts, a 2-hour job TTL, and partial results.
|
|
66
|
+
|
|
67
|
+
`extract()` does all of it:
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
result = dx.extract("500-page-statement.pdf", model="bank_statement")
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Same call, any page count. With progress:
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
dx.extract("big.pdf", model="invoice",
|
|
77
|
+
on_progress=lambda done, total, stage: print(f"{done}/{total}"))
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Why chunks run sequentially
|
|
81
|
+
|
|
82
|
+
The API's default rate limit is 10 requests per minute, so parallel chunk calls do not finish
|
|
83
|
+
sooner — they turn the work into `429`s. Pace them if your key is tighter:
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
dx = DocXtract(key, chunk_pause_ms=500)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### Manual control
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
manifest = dx.split_document("big.pdf", model="invoice")
|
|
93
|
+
|
|
94
|
+
for chunk in manifest.chunks:
|
|
95
|
+
dx.process_chunk(chunk.job_id, model="invoice") # safe to retry
|
|
96
|
+
|
|
97
|
+
result = dx.collect_result(manifest.job_id)
|
|
98
|
+
|
|
99
|
+
if not result.complete:
|
|
100
|
+
print(result.failed_pages, result.pending_pages)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
`collect_result()` is a pure read, re-fetchable within the TTL — usable as a progress poll
|
|
104
|
+
from a separate worker.
|
|
105
|
+
|
|
106
|
+
> **`collect_result(job_id, finalize=True)` is irreversible.** It permanently deletes the
|
|
107
|
+
> job's extracted data. Only pass it once the result is stored on your side.
|
|
108
|
+
|
|
109
|
+
## Tabular extractions
|
|
110
|
+
|
|
111
|
+
Invoice line items and bank statement rows come back as lists of dicts:
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
df = result.to_dataframe("line_items") # needs: pip install 'docxtract-sdk[pandas]'
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
With no argument it uses the first row-shaped list it finds in the data.
|
|
118
|
+
|
|
119
|
+
## Discovering document types
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
dx.models() # costs no credits — safe to call freely
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Error handling
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
from docxtract import DocXtractError, RateLimitError, QuotaError
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
dx.extract("invoice.pdf", model="invoice")
|
|
132
|
+
except RateLimitError as exc:
|
|
133
|
+
time.sleep(exc.retry_after or 30) # from X-RateLimit-Reset
|
|
134
|
+
except QuotaError:
|
|
135
|
+
pass # out of credits — do not retry
|
|
136
|
+
except DocXtractError as exc:
|
|
137
|
+
if exc.retryable:
|
|
138
|
+
requeue()
|
|
139
|
+
else:
|
|
140
|
+
raise
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Or branch on the code:
|
|
144
|
+
|
|
145
|
+
```python
|
|
146
|
+
except DocXtractError as exc:
|
|
147
|
+
match exc.code:
|
|
148
|
+
case "insufficient_credits": notify_billing()
|
|
149
|
+
case "unknown_model": report_bad_model(exc.details)
|
|
150
|
+
case _:
|
|
151
|
+
if not exc.retryable:
|
|
152
|
+
raise
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
| Exception | Codes |
|
|
156
|
+
|---|---|
|
|
157
|
+
| `AuthenticationError` | `invalid_api_key`, `expired_api_key` |
|
|
158
|
+
| `QuotaError` | `usage_limit_exceeded`, `insufficient_credits` |
|
|
159
|
+
| `RateLimitError` | `rate_limit_exceeded`, `too_many_open_jobs` |
|
|
160
|
+
| `RequestError` | `invalid_request`, `invalid_file`, `invalid_file_type`, `file_too_large`, `invalid_options`, `unknown_model`, `page_limit_exceeded`, `method_not_allowed` |
|
|
161
|
+
| `ExtractionFailedError` | `extraction_failed` |
|
|
162
|
+
| `JobError` | `job_not_found`, `job_expired`, `chunk_in_progress`, `chunk_source_lost` |
|
|
163
|
+
| `ServerError` | `server_error`, `persist_failed`, `server_busy` |
|
|
164
|
+
| `TransportError` | network failure — the API never answered |
|
|
165
|
+
|
|
166
|
+
An unrecognised code falls back to `DocXtractError` rather than raising, so a new server-side
|
|
167
|
+
code cannot break a deployed copy.
|
|
168
|
+
|
|
169
|
+
> **Billing note.** `extraction_failed` on the synchronous path **still deducts 1 credit**. On
|
|
170
|
+
> the multi-page path the chunk stays available and is not charged until it succeeds.
|
|
171
|
+
> `persist_failed` charges nothing.
|
|
172
|
+
|
|
173
|
+
## Configuration
|
|
174
|
+
|
|
175
|
+
```python
|
|
176
|
+
dx = DocXtract(
|
|
177
|
+
api_key=os.environ["DOCXTRACT_API_KEY"],
|
|
178
|
+
base_url="https://api.docxtract.io", # default — no /api prefix
|
|
179
|
+
base_path="/v3.1", # default
|
|
180
|
+
timeout=120, # seconds
|
|
181
|
+
max_retries=3,
|
|
182
|
+
chunk_pause_ms=0,
|
|
183
|
+
)
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
> If you see a `TransportError` about non-JSON output, the base URL is usually wrong. `/api`
|
|
187
|
+
> is the server's docroot, not part of the public path.
|
|
188
|
+
|
|
189
|
+
`base_path="/v3"` exists for customers still pinned to the old version. v3 has no
|
|
190
|
+
`models` and no multi-page support; `models()` raises a clear error rather than a
|
|
191
|
+
confusing 404.
|
|
192
|
+
|
|
193
|
+
## Tests
|
|
194
|
+
|
|
195
|
+
```bash
|
|
196
|
+
python3 -m unittest discover -s tests
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
Offline: no API key or network needed.
|
|
200
|
+
|
|
201
|
+
## Links
|
|
202
|
+
|
|
203
|
+
- Documentation — https://docs.docxtract.io
|
|
204
|
+
- Interactive API reference — https://app.docxtract.io/api-reference.php
|
|
205
|
+
- Support — support@docxtract.io
|
|
206
|
+
|
|
207
|
+
---
|
|
208
|
+
|
|
209
|
+
**Built by RPATech** | [docxtract.io](https://docxtract.io)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
# Distribution name is docxtract-sdk: plain `docxtract` was already taken on PyPI
|
|
7
|
+
# (an unrelated DOCX text extractor, v0.1.0, 2025). The import name stays `docxtract`.
|
|
8
|
+
name = "docxtract-sdk"
|
|
9
|
+
version = "1.0.0"
|
|
10
|
+
description = "Official Python SDK for the DocXtract document extraction API"
|
|
11
|
+
readme = "README.md"
|
|
12
|
+
requires-python = ">=3.9"
|
|
13
|
+
authors = [{ name = "RPATech" }]
|
|
14
|
+
license = "MIT"
|
|
15
|
+
license-files = ["LICENSE"]
|
|
16
|
+
keywords = ["docxtract", "document-extraction", "idp", "ocr", "invoice", "kyc"]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Development Status :: 5 - Production/Stable",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Intended Audience :: Developers",
|
|
22
|
+
"Topic :: Text Processing",
|
|
23
|
+
"Typing :: Typed",
|
|
24
|
+
]
|
|
25
|
+
# No runtime dependencies: the SDK uses only the standard library, so `pip install docxtract`
|
|
26
|
+
# pulls nothing and cannot conflict with a project's pinned requests/httpx.
|
|
27
|
+
dependencies = []
|
|
28
|
+
|
|
29
|
+
[project.urls]
|
|
30
|
+
Homepage = "https://docxtract.io"
|
|
31
|
+
Documentation = "https://docs.docxtract.io"
|
|
32
|
+
Source = "https://github.com/docxtractio/python-sdk"
|
|
33
|
+
Issues = "https://github.com/docxtractio/python-sdk/issues"
|
|
34
|
+
|
|
35
|
+
[project.optional-dependencies]
|
|
36
|
+
pandas = ["pandas>=1.3"]
|
|
37
|
+
|
|
38
|
+
[tool.hatch.build.targets.wheel]
|
|
39
|
+
packages = ["src/docxtract"]
|
|
@@ -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
|
+
]
|
|
@@ -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
|