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.
@@ -0,0 +1,6 @@
1
+ __pycache__/
2
+ *.pyc
3
+ /dist/
4
+ /build/
5
+ *.egg-info/
6
+ .venv/
@@ -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