pdftagger 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pdftagger/__init__.py +65 -0
- pdftagger/_version.py +1 -0
- pdftagger/client.py +483 -0
- pdftagger/exceptions.py +107 -0
- pdftagger/models.py +282 -0
- pdftagger/py.typed +0 -0
- pdftagger/webhooks.py +97 -0
- pdftagger-0.1.0.dist-info/METADATA +202 -0
- pdftagger-0.1.0.dist-info/RECORD +11 -0
- pdftagger-0.1.0.dist-info/WHEEL +4 -0
- pdftagger-0.1.0.dist-info/licenses/LICENSE +21 -0
pdftagger/__init__.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Official Python SDK for the PDF Tagger API.
|
|
2
|
+
|
|
3
|
+
Auto-tag PDFs for PDF/UA accessibility from your own code:
|
|
4
|
+
|
|
5
|
+
>>> from pdftagger import PdfTaggerClient
|
|
6
|
+
>>> client = PdfTaggerClient(api_key="sk_live_...")
|
|
7
|
+
>>> job = client.tag_pdf("report.pdf", ocr=True)
|
|
8
|
+
>>> client.download_result(job, "report.tagged.pdf")
|
|
9
|
+
|
|
10
|
+
Many PDFs at once, as one batch:
|
|
11
|
+
|
|
12
|
+
>>> batch = client.tag_pdfs(["a.pdf", "b.pdf", "c.pdf"])
|
|
13
|
+
>>> client.download_batch_results(batch, "tagged/")
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from ._version import __version__
|
|
19
|
+
from .client import DEFAULT_BASE_URL, PdfTaggerClient
|
|
20
|
+
from .exceptions import (
|
|
21
|
+
APIError,
|
|
22
|
+
AuthenticationError,
|
|
23
|
+
BadRequestError,
|
|
24
|
+
ConflictError,
|
|
25
|
+
InsufficientCreditsError,
|
|
26
|
+
JobFailed,
|
|
27
|
+
JobTimeout,
|
|
28
|
+
NotFoundError,
|
|
29
|
+
PdfTaggerError,
|
|
30
|
+
RateLimitError,
|
|
31
|
+
WebhookVerificationError,
|
|
32
|
+
)
|
|
33
|
+
from .models import Account, Batch, BatchCounts, BatchEvent, BatchItem, Job, JobResult, Upload, WebhookEvent
|
|
34
|
+
from .webhooks import SIGNATURE_HEADER, verify_signature
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"__version__",
|
|
38
|
+
"PdfTaggerClient",
|
|
39
|
+
"DEFAULT_BASE_URL",
|
|
40
|
+
# models
|
|
41
|
+
"Account",
|
|
42
|
+
"Upload",
|
|
43
|
+
"Job",
|
|
44
|
+
"JobResult",
|
|
45
|
+
"Batch",
|
|
46
|
+
"BatchItem",
|
|
47
|
+
"BatchCounts",
|
|
48
|
+
"BatchEvent",
|
|
49
|
+
"WebhookEvent",
|
|
50
|
+
# webhooks
|
|
51
|
+
"verify_signature",
|
|
52
|
+
"SIGNATURE_HEADER",
|
|
53
|
+
# exceptions
|
|
54
|
+
"PdfTaggerError",
|
|
55
|
+
"APIError",
|
|
56
|
+
"BadRequestError",
|
|
57
|
+
"AuthenticationError",
|
|
58
|
+
"InsufficientCreditsError",
|
|
59
|
+
"NotFoundError",
|
|
60
|
+
"ConflictError",
|
|
61
|
+
"RateLimitError",
|
|
62
|
+
"JobTimeout",
|
|
63
|
+
"JobFailed",
|
|
64
|
+
"WebhookVerificationError",
|
|
65
|
+
]
|
pdftagger/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
pdftagger/client.py
ADDED
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
"""The synchronous PDF Tagger API client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, BinaryIO, Dict, List, Optional, Sequence, Union
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from ._version import __version__
|
|
12
|
+
from .exceptions import (
|
|
13
|
+
APIError,
|
|
14
|
+
AuthenticationError,
|
|
15
|
+
BadRequestError,
|
|
16
|
+
ConflictError,
|
|
17
|
+
InsufficientCreditsError,
|
|
18
|
+
JobFailed,
|
|
19
|
+
JobTimeout,
|
|
20
|
+
NotFoundError,
|
|
21
|
+
RateLimitError,
|
|
22
|
+
)
|
|
23
|
+
from .models import Account, Batch, BatchItem, BatchItemInput, Job, JobResult, Upload
|
|
24
|
+
|
|
25
|
+
DEFAULT_BASE_URL = "https://api.pdf-tagger.com"
|
|
26
|
+
|
|
27
|
+
#: Seconds to wait for ``POST /v1/batches``. The API reads every file before it answers, so a
|
|
28
|
+
#: batch takes longer to accept than one job.
|
|
29
|
+
BATCH_SUBMIT_TIMEOUT = 300.0
|
|
30
|
+
|
|
31
|
+
# status code -> exception class (429 is handled separately to read Retry-After)
|
|
32
|
+
_ERROR_BY_STATUS = {
|
|
33
|
+
400: BadRequestError,
|
|
34
|
+
401: AuthenticationError,
|
|
35
|
+
402: InsufficientCreditsError,
|
|
36
|
+
404: NotFoundError,
|
|
37
|
+
409: ConflictError,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
FileInput = Union[str, Path, BinaryIO]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class PdfTaggerClient:
|
|
44
|
+
"""A client for the PDF Tagger API.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
api_key: Your API key (``sk_live_…``).
|
|
48
|
+
base_url: Override the API base URL (for testing or a private deployment).
|
|
49
|
+
timeout: Per-request timeout in seconds.
|
|
50
|
+
http_client: Supply your own ``httpx.Client`` (its lifecycle is then yours).
|
|
51
|
+
|
|
52
|
+
Example:
|
|
53
|
+
>>> from pdftagger import PdfTaggerClient
|
|
54
|
+
>>> with PdfTaggerClient(api_key="sk_live_...") as client:
|
|
55
|
+
... job = client.tag_pdf("report.pdf", ocr=True)
|
|
56
|
+
... client.download_result(job, "report.tagged.pdf")
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def __init__(
|
|
60
|
+
self,
|
|
61
|
+
api_key: str,
|
|
62
|
+
*,
|
|
63
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
64
|
+
timeout: float = 30.0,
|
|
65
|
+
http_client: Optional[httpx.Client] = None,
|
|
66
|
+
) -> None:
|
|
67
|
+
if not api_key:
|
|
68
|
+
raise ValueError("api_key is required")
|
|
69
|
+
self._api_key = api_key
|
|
70
|
+
self._base_url = base_url.rstrip("/")
|
|
71
|
+
self._owns_client = http_client is None
|
|
72
|
+
self._http = http_client or httpx.Client(timeout=timeout)
|
|
73
|
+
|
|
74
|
+
# -- lifecycle ---------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
def __enter__(self) -> "PdfTaggerClient":
|
|
77
|
+
return self
|
|
78
|
+
|
|
79
|
+
def __exit__(self, *exc: object) -> None:
|
|
80
|
+
self.close()
|
|
81
|
+
|
|
82
|
+
def close(self) -> None:
|
|
83
|
+
"""Close the underlying HTTP client (only if the SDK created it)."""
|
|
84
|
+
if self._owns_client:
|
|
85
|
+
self._http.close()
|
|
86
|
+
|
|
87
|
+
# -- low-level request plumbing ---------------------------------------------
|
|
88
|
+
|
|
89
|
+
def _auth_headers(self, extra: Optional[Dict[str, str]] = None) -> Dict[str, str]:
|
|
90
|
+
headers = {
|
|
91
|
+
"Authorization": f"Bearer {self._api_key}",
|
|
92
|
+
"User-Agent": f"pdftagger-python/{__version__}",
|
|
93
|
+
"Accept": "application/json",
|
|
94
|
+
}
|
|
95
|
+
if extra:
|
|
96
|
+
headers.update(extra)
|
|
97
|
+
return headers
|
|
98
|
+
|
|
99
|
+
def _request(
|
|
100
|
+
self,
|
|
101
|
+
method: str,
|
|
102
|
+
path: str,
|
|
103
|
+
*,
|
|
104
|
+
json: Optional[Dict[str, Any]] = None,
|
|
105
|
+
headers: Optional[Dict[str, str]] = None,
|
|
106
|
+
timeout: Optional[float] = None,
|
|
107
|
+
) -> Dict[str, Any]:
|
|
108
|
+
url = f"{self._base_url}{path}"
|
|
109
|
+
extra: Dict[str, Any] = {"timeout": timeout} if timeout is not None else {}
|
|
110
|
+
try:
|
|
111
|
+
response = self._http.request(method, url, json=json, headers=self._auth_headers(headers), **extra)
|
|
112
|
+
except httpx.HTTPError as exc:
|
|
113
|
+
raise APIError(f"HTTP request to {path} failed: {exc}") from exc
|
|
114
|
+
return self._handle_response(response)
|
|
115
|
+
|
|
116
|
+
def _handle_response(self, response: httpx.Response) -> Dict[str, Any]:
|
|
117
|
+
request_id = response.headers.get("X-Request-Id")
|
|
118
|
+
|
|
119
|
+
if response.is_success:
|
|
120
|
+
if response.status_code == 204 or not response.content:
|
|
121
|
+
return {}
|
|
122
|
+
try:
|
|
123
|
+
return response.json()
|
|
124
|
+
except ValueError:
|
|
125
|
+
return {}
|
|
126
|
+
|
|
127
|
+
error_type: Optional[str] = None
|
|
128
|
+
message: Optional[str] = None
|
|
129
|
+
job_errors: List[Dict[str, Any]] = []
|
|
130
|
+
try:
|
|
131
|
+
body = response.json()
|
|
132
|
+
error = body.get("error") or {}
|
|
133
|
+
error_type = error.get("type")
|
|
134
|
+
message = error.get("message")
|
|
135
|
+
request_id = error.get("request_id") or request_id
|
|
136
|
+
job_errors = list(error.get("errors") or [])
|
|
137
|
+
except ValueError:
|
|
138
|
+
message = response.text or None
|
|
139
|
+
|
|
140
|
+
message = message or f"HTTP {response.status_code}"
|
|
141
|
+
common = {
|
|
142
|
+
"status_code": response.status_code,
|
|
143
|
+
"type": error_type,
|
|
144
|
+
"request_id": request_id,
|
|
145
|
+
"errors": job_errors,
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if response.status_code == 429:
|
|
149
|
+
raw_retry = response.headers.get("Retry-After")
|
|
150
|
+
retry_after = int(raw_retry) if raw_retry and raw_retry.isdigit() else None
|
|
151
|
+
raise RateLimitError(message, retry_after=retry_after, **common)
|
|
152
|
+
|
|
153
|
+
error_cls = _ERROR_BY_STATUS.get(response.status_code, APIError)
|
|
154
|
+
raise error_cls(message, **common)
|
|
155
|
+
|
|
156
|
+
# -- endpoints (1:1 with the API) -------------------------------------------
|
|
157
|
+
|
|
158
|
+
def get_account(self) -> Account:
|
|
159
|
+
"""Return the authenticated account and its credit balance."""
|
|
160
|
+
return Account.from_dict(self._request("GET", "/v1/me"))
|
|
161
|
+
|
|
162
|
+
def create_upload(self, filename: Optional[str] = None) -> Upload:
|
|
163
|
+
"""Create a presigned upload target. Follow with :meth:`upload_file`."""
|
|
164
|
+
payload: Dict[str, Any] = {}
|
|
165
|
+
if filename:
|
|
166
|
+
payload["filename"] = filename
|
|
167
|
+
return Upload.from_dict(self._request("POST", "/v1/uploads", json=payload))
|
|
168
|
+
|
|
169
|
+
def upload_file(self, upload: Upload, file: FileInput) -> None:
|
|
170
|
+
"""Upload the PDF bytes to the presigned target from :meth:`create_upload`.
|
|
171
|
+
|
|
172
|
+
The presigned POST requires every field from ``upload.fields`` to precede the
|
|
173
|
+
file part, which this method guarantees.
|
|
174
|
+
"""
|
|
175
|
+
fh: BinaryIO
|
|
176
|
+
should_close = False
|
|
177
|
+
if isinstance(file, (str, Path)):
|
|
178
|
+
fh = open(file, "rb")
|
|
179
|
+
should_close = True
|
|
180
|
+
filename = Path(file).name
|
|
181
|
+
else:
|
|
182
|
+
fh = file
|
|
183
|
+
filename = getattr(file, "name", None) or "document.pdf"
|
|
184
|
+
filename = Path(str(filename)).name
|
|
185
|
+
|
|
186
|
+
try:
|
|
187
|
+
# httpx serializes `data` (the presigned fields) before `files`, so the file
|
|
188
|
+
# part comes last — which is what S3 requires.
|
|
189
|
+
files = {"file": (filename, fh, "application/pdf")}
|
|
190
|
+
response = self._http.post(upload.upload_url, data=dict(upload.fields), files=files)
|
|
191
|
+
except httpx.HTTPError as exc:
|
|
192
|
+
raise APIError(f"Upload to storage failed: {exc}") from exc
|
|
193
|
+
finally:
|
|
194
|
+
if should_close:
|
|
195
|
+
fh.close()
|
|
196
|
+
|
|
197
|
+
if not response.is_success:
|
|
198
|
+
raise APIError(
|
|
199
|
+
f"Upload to storage failed: HTTP {response.status_code}",
|
|
200
|
+
status_code=response.status_code,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
def submit_job(
|
|
204
|
+
self,
|
|
205
|
+
*,
|
|
206
|
+
key: str,
|
|
207
|
+
ocr: bool = False,
|
|
208
|
+
embed_fonts: bool = False,
|
|
209
|
+
filename: Optional[str] = None,
|
|
210
|
+
idempotency_key: Optional[str] = None,
|
|
211
|
+
) -> Job:
|
|
212
|
+
"""Submit a tagging job for an uploaded ``key``. Charges one credit per page."""
|
|
213
|
+
options: Dict[str, bool] = {}
|
|
214
|
+
if ocr:
|
|
215
|
+
options["ocr"] = True
|
|
216
|
+
if embed_fonts:
|
|
217
|
+
options["embed_fonts"] = True
|
|
218
|
+
|
|
219
|
+
payload: Dict[str, Any] = {"key": key}
|
|
220
|
+
if filename:
|
|
221
|
+
payload["filename"] = filename
|
|
222
|
+
if options:
|
|
223
|
+
payload["options"] = options
|
|
224
|
+
|
|
225
|
+
headers: Dict[str, str] = {}
|
|
226
|
+
if idempotency_key:
|
|
227
|
+
headers["Idempotency-Key"] = idempotency_key
|
|
228
|
+
|
|
229
|
+
return Job.from_dict(self._request("POST", "/v1/jobs", json=payload, headers=headers))
|
|
230
|
+
|
|
231
|
+
def get_job(self, job: Union[str, Job]) -> Job:
|
|
232
|
+
"""Fetch a job's current status."""
|
|
233
|
+
return Job.from_dict(self._request("GET", f"/v1/jobs/{_job_id(job)}"))
|
|
234
|
+
|
|
235
|
+
def get_job_result(self, job: Union[str, Job]) -> JobResult:
|
|
236
|
+
"""Get a short-lived download URL for a completed job."""
|
|
237
|
+
return JobResult.from_dict(self._request("GET", f"/v1/jobs/{_job_id(job)}/result"))
|
|
238
|
+
|
|
239
|
+
def submit_batch(
|
|
240
|
+
self,
|
|
241
|
+
items: Sequence[BatchItemInput],
|
|
242
|
+
*,
|
|
243
|
+
name: Optional[str] = None,
|
|
244
|
+
idempotency_key: Optional[str] = None,
|
|
245
|
+
timeout: float = BATCH_SUBMIT_TIMEOUT,
|
|
246
|
+
) -> Batch:
|
|
247
|
+
"""Submit 1 to 100 uploaded files as one batch. Charges one credit per page.
|
|
248
|
+
|
|
249
|
+
Each item is an upload ``key`` or a :class:`~pdftagger.models.BatchItem` (to set a
|
|
250
|
+
filename or options). All or nothing: if any job is bad, this raises
|
|
251
|
+
:class:`~pdftagger.exceptions.BadRequestError` with one entry in ``errors`` for each
|
|
252
|
+
bad job, and nothing is charged; too few credits raises
|
|
253
|
+
:class:`~pdftagger.exceptions.InsufficientCreditsError`.
|
|
254
|
+
"""
|
|
255
|
+
jobs = [BatchItem(key=i) if isinstance(i, str) else i for i in items]
|
|
256
|
+
payload: Dict[str, Any] = {"jobs": [j.to_payload() for j in jobs]}
|
|
257
|
+
if name:
|
|
258
|
+
payload["name"] = name
|
|
259
|
+
headers: Dict[str, str] = {}
|
|
260
|
+
if idempotency_key:
|
|
261
|
+
headers["Idempotency-Key"] = idempotency_key
|
|
262
|
+
return Batch.from_dict(
|
|
263
|
+
self._request("POST", "/v1/batches", json=payload, headers=headers, timeout=timeout)
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
def get_batch(self, batch: Union[str, Batch]) -> Batch:
|
|
267
|
+
"""Fetch a batch's status, counts and jobs."""
|
|
268
|
+
return Batch.from_dict(self._request("GET", f"/v1/batches/{_batch_id(batch)}"))
|
|
269
|
+
|
|
270
|
+
# -- high-level helpers ------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
def tag_pdf(
|
|
273
|
+
self,
|
|
274
|
+
file: FileInput,
|
|
275
|
+
*,
|
|
276
|
+
ocr: bool = False,
|
|
277
|
+
embed_fonts: bool = False,
|
|
278
|
+
filename: Optional[str] = None,
|
|
279
|
+
idempotency_key: Optional[str] = None,
|
|
280
|
+
wait: bool = True,
|
|
281
|
+
poll_interval: float = 3.0,
|
|
282
|
+
poll_timeout: Optional[float] = None,
|
|
283
|
+
) -> Job:
|
|
284
|
+
"""Upload, submit, and (by default) wait for a PDF to be tagged.
|
|
285
|
+
|
|
286
|
+
Args:
|
|
287
|
+
file: A path or open binary file for the PDF to tag.
|
|
288
|
+
ocr: Force OCR on image-only pages.
|
|
289
|
+
embed_fonts: Embed a non-embedded font where safe.
|
|
290
|
+
filename: Name to record for the job (defaults to the file's name).
|
|
291
|
+
idempotency_key: Make the submit safe to retry without double-charging.
|
|
292
|
+
wait: If True, poll until the job finishes; if False, return the accepted job.
|
|
293
|
+
poll_interval: Seconds between status checks while waiting.
|
|
294
|
+
poll_timeout: Give up waiting after this many seconds. The default (``None``)
|
|
295
|
+
waits until the job is terminal — the service guarantees a job reaches
|
|
296
|
+
``completed`` or ``failed`` within 24 hours. Set a bound if you need one.
|
|
297
|
+
|
|
298
|
+
Returns:
|
|
299
|
+
The :class:`~pdftagger.models.Job` — completed when ``wait`` is True.
|
|
300
|
+
|
|
301
|
+
Raises:
|
|
302
|
+
JobFailed: The job finished with a ``failed`` status.
|
|
303
|
+
JobTimeout: ``wait`` is True and the job did not finish within ``poll_timeout``.
|
|
304
|
+
"""
|
|
305
|
+
default_name = Path(file).name if isinstance(file, (str, Path)) else None
|
|
306
|
+
upload = self.create_upload(filename=filename or default_name)
|
|
307
|
+
self.upload_file(upload, file)
|
|
308
|
+
job = self.submit_job(
|
|
309
|
+
key=upload.key,
|
|
310
|
+
ocr=ocr,
|
|
311
|
+
embed_fonts=embed_fonts,
|
|
312
|
+
filename=filename or default_name,
|
|
313
|
+
idempotency_key=idempotency_key,
|
|
314
|
+
)
|
|
315
|
+
if not wait:
|
|
316
|
+
return job
|
|
317
|
+
return self.wait_for_job(job, poll_interval=poll_interval, poll_timeout=poll_timeout)
|
|
318
|
+
|
|
319
|
+
def wait_for_job(
|
|
320
|
+
self,
|
|
321
|
+
job: Union[str, Job],
|
|
322
|
+
*,
|
|
323
|
+
poll_interval: float = 3.0,
|
|
324
|
+
poll_timeout: Optional[float] = None,
|
|
325
|
+
) -> Job:
|
|
326
|
+
"""Poll a job until it is completed, raising on failure or timeout.
|
|
327
|
+
|
|
328
|
+
``poll_timeout=None`` (the default) waits until the job is terminal; the service
|
|
329
|
+
guarantees that within 24 hours.
|
|
330
|
+
"""
|
|
331
|
+
job_id = _job_id(job)
|
|
332
|
+
deadline = time.monotonic() + poll_timeout if poll_timeout is not None else None
|
|
333
|
+
|
|
334
|
+
while True:
|
|
335
|
+
try:
|
|
336
|
+
current = self.get_job(job_id)
|
|
337
|
+
except RateLimitError as exc:
|
|
338
|
+
time.sleep(exc.retry_after or poll_interval)
|
|
339
|
+
continue
|
|
340
|
+
|
|
341
|
+
if current.is_terminal:
|
|
342
|
+
if current.status == "failed":
|
|
343
|
+
raise JobFailed(f"Job {current.id} failed", job=current)
|
|
344
|
+
return current
|
|
345
|
+
|
|
346
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
347
|
+
raise JobTimeout(
|
|
348
|
+
f"Job {job_id} did not finish within {poll_timeout}s", job=current
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
time.sleep(poll_interval)
|
|
352
|
+
|
|
353
|
+
def tag_pdfs(
|
|
354
|
+
self,
|
|
355
|
+
files: Sequence[FileInput],
|
|
356
|
+
*,
|
|
357
|
+
name: Optional[str] = None,
|
|
358
|
+
ocr: bool = False,
|
|
359
|
+
embed_fonts: bool = False,
|
|
360
|
+
idempotency_key: Optional[str] = None,
|
|
361
|
+
wait: bool = True,
|
|
362
|
+
poll_interval: float = 5.0,
|
|
363
|
+
poll_timeout: Optional[float] = None,
|
|
364
|
+
) -> Batch:
|
|
365
|
+
"""Upload many PDFs, submit them as one batch, and (by default) wait for all of them.
|
|
366
|
+
|
|
367
|
+
Unlike :meth:`tag_pdf`, a failed job does not raise: a batch can finish with some
|
|
368
|
+
jobs completed and some failed. Check :attr:`Batch.failed_jobs`, then save the rest
|
|
369
|
+
with :meth:`download_batch_results`.
|
|
370
|
+
|
|
371
|
+
Args:
|
|
372
|
+
files: 1 to 100 paths or open binary files.
|
|
373
|
+
name: A name for the batch (the API picks one if omitted).
|
|
374
|
+
ocr: Force OCR on image-only pages, for every file.
|
|
375
|
+
embed_fonts: Embed a non-embedded font where safe, for every file.
|
|
376
|
+
idempotency_key: Make the submit safe to retry without double-charging.
|
|
377
|
+
wait: If True, poll until every job has finished.
|
|
378
|
+
poll_interval: Seconds between status checks while waiting.
|
|
379
|
+
poll_timeout: Give up waiting after this many seconds (``None`` waits until done).
|
|
380
|
+
"""
|
|
381
|
+
items: List[BatchItem] = []
|
|
382
|
+
for file in files:
|
|
383
|
+
filename = Path(file).name if isinstance(file, (str, Path)) else None
|
|
384
|
+
upload = self.create_upload(filename=filename)
|
|
385
|
+
self.upload_file(upload, file)
|
|
386
|
+
items.append(BatchItem(key=upload.key, filename=filename, ocr=ocr, embed_fonts=embed_fonts))
|
|
387
|
+
batch = self.submit_batch(items, name=name, idempotency_key=idempotency_key)
|
|
388
|
+
if not wait:
|
|
389
|
+
return batch
|
|
390
|
+
return self.wait_for_batch(batch, poll_interval=poll_interval, poll_timeout=poll_timeout)
|
|
391
|
+
|
|
392
|
+
def wait_for_batch(
|
|
393
|
+
self,
|
|
394
|
+
batch: Union[str, Batch],
|
|
395
|
+
*,
|
|
396
|
+
poll_interval: float = 5.0,
|
|
397
|
+
poll_timeout: Optional[float] = None,
|
|
398
|
+
) -> Batch:
|
|
399
|
+
"""Poll a batch until every job has finished. Failed jobs do not raise.
|
|
400
|
+
|
|
401
|
+
Raises:
|
|
402
|
+
JobTimeout: The batch did not finish within ``poll_timeout`` seconds.
|
|
403
|
+
"""
|
|
404
|
+
batch_id = _batch_id(batch)
|
|
405
|
+
deadline = time.monotonic() + poll_timeout if poll_timeout is not None else None
|
|
406
|
+
while True:
|
|
407
|
+
try:
|
|
408
|
+
current = self.get_batch(batch_id)
|
|
409
|
+
except RateLimitError as exc:
|
|
410
|
+
time.sleep(exc.retry_after or poll_interval)
|
|
411
|
+
continue
|
|
412
|
+
if current.is_terminal:
|
|
413
|
+
return current
|
|
414
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
415
|
+
raise JobTimeout(f"Batch {batch_id} did not finish within {poll_timeout}s")
|
|
416
|
+
time.sleep(poll_interval)
|
|
417
|
+
|
|
418
|
+
def download_batch_results(self, batch: Union[str, Batch], dest_dir: Union[str, Path]) -> List[Path]:
|
|
419
|
+
"""Save every completed job's tagged PDF in ``dest_dir`` and return the paths.
|
|
420
|
+
|
|
421
|
+
Files keep their uploaded names; a repeated name gets a number (``report (2).pdf``).
|
|
422
|
+
Failed jobs are skipped.
|
|
423
|
+
"""
|
|
424
|
+
current = batch if isinstance(batch, Batch) else self.get_batch(batch)
|
|
425
|
+
folder = Path(dest_dir)
|
|
426
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
427
|
+
names = _unique_names([j.filename or f"{j.id}.pdf" for j in current.completed_jobs])
|
|
428
|
+
return [
|
|
429
|
+
self.download_result(job, folder / filename) # type: ignore[misc]
|
|
430
|
+
for job, filename in zip(current.completed_jobs, names)
|
|
431
|
+
]
|
|
432
|
+
|
|
433
|
+
def download_result(
|
|
434
|
+
self,
|
|
435
|
+
job: Union[str, Job],
|
|
436
|
+
dest: Optional[Union[str, Path]] = None,
|
|
437
|
+
) -> Union[bytes, Path]:
|
|
438
|
+
"""Download a completed job's tagged PDF.
|
|
439
|
+
|
|
440
|
+
Returns the written :class:`~pathlib.Path` when ``dest`` is given, otherwise the
|
|
441
|
+
PDF bytes.
|
|
442
|
+
"""
|
|
443
|
+
result = self.get_job_result(job)
|
|
444
|
+
try:
|
|
445
|
+
response = self._http.get(result.result_url)
|
|
446
|
+
except httpx.HTTPError as exc:
|
|
447
|
+
raise APIError(f"Download failed: {exc}") from exc
|
|
448
|
+
if not response.is_success:
|
|
449
|
+
raise APIError(
|
|
450
|
+
f"Download failed: HTTP {response.status_code}", status_code=response.status_code
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
if dest is None:
|
|
454
|
+
return response.content
|
|
455
|
+
path = Path(dest)
|
|
456
|
+
path.write_bytes(response.content)
|
|
457
|
+
return path
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _job_id(job: Union[str, Job]) -> str:
|
|
461
|
+
return job.id if isinstance(job, Job) else job
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _batch_id(batch: Union[str, Batch]) -> str:
|
|
465
|
+
return batch.id if isinstance(batch, Batch) else batch
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _unique_names(names: List[str]) -> List[str]:
|
|
469
|
+
"""Make file names unique, so two jobs with one name do not overwrite each other."""
|
|
470
|
+
used = set()
|
|
471
|
+
out = []
|
|
472
|
+
for raw in names:
|
|
473
|
+
name = Path(raw.replace("\\", "_")).name or "document.pdf"
|
|
474
|
+
stem, dot, ext = name.rpartition(".")
|
|
475
|
+
if not dot:
|
|
476
|
+
stem, ext = name, "pdf"
|
|
477
|
+
candidate, n = name, 2
|
|
478
|
+
while candidate.lower() in used:
|
|
479
|
+
candidate = f"{stem} ({n}).{ext}"
|
|
480
|
+
n += 1
|
|
481
|
+
used.add(candidate.lower())
|
|
482
|
+
out.append(candidate)
|
|
483
|
+
return out
|
pdftagger/exceptions.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Exception hierarchy for the PDF Tagger SDK.
|
|
2
|
+
|
|
3
|
+
Every error the SDK raises derives from :class:`PdfTaggerError`, so a caller can catch
|
|
4
|
+
that one type to handle anything the SDK can throw. API responses map to a specific
|
|
5
|
+
subclass of :class:`APIError` by status code.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import TYPE_CHECKING, Any, Dict, List, Optional
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING: # avoid a runtime import cycle (models imports nothing from here)
|
|
13
|
+
from .models import Job
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PdfTaggerError(Exception):
|
|
17
|
+
"""Base class for every error raised by the SDK."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class APIError(PdfTaggerError):
|
|
21
|
+
"""An error returned by the API, or an unexpected HTTP/transport failure.
|
|
22
|
+
|
|
23
|
+
Attributes:
|
|
24
|
+
status_code: The HTTP status code, when the failure came from a response.
|
|
25
|
+
type: The API error ``type`` string, when the body carried one.
|
|
26
|
+
request_id: The request id for support, from the body or the ``X-Request-Id`` header.
|
|
27
|
+
errors: On a refused batch (``POST /v1/batches``), one entry for each bad job:
|
|
28
|
+
``{"index": <place in jobs, from 0>, "type": ..., "message": ...}``. Otherwise empty.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
message: str,
|
|
34
|
+
*,
|
|
35
|
+
status_code: Optional[int] = None,
|
|
36
|
+
type: Optional[str] = None,
|
|
37
|
+
request_id: Optional[str] = None,
|
|
38
|
+
errors: Optional[List[Dict[str, Any]]] = None,
|
|
39
|
+
) -> None:
|
|
40
|
+
super().__init__(message)
|
|
41
|
+
self.message = message
|
|
42
|
+
self.status_code = status_code
|
|
43
|
+
self.type = type
|
|
44
|
+
self.request_id = request_id
|
|
45
|
+
self.errors: List[Dict[str, Any]] = list(errors or [])
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class BadRequestError(APIError):
|
|
49
|
+
"""HTTP 400 — the request was invalid."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class AuthenticationError(APIError):
|
|
53
|
+
"""HTTP 401 — the API key is missing, invalid, or revoked."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class InsufficientCreditsError(APIError):
|
|
57
|
+
"""HTTP 402 — the account does not have enough credits for the job."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class NotFoundError(APIError):
|
|
61
|
+
"""HTTP 404 — no such resource for this account."""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class ConflictError(APIError):
|
|
65
|
+
"""HTTP 409 — an idempotency conflict, or a resource not ready yet."""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class RateLimitError(APIError):
|
|
69
|
+
"""HTTP 429 — the rate limit was exceeded.
|
|
70
|
+
|
|
71
|
+
Attributes:
|
|
72
|
+
retry_after: Seconds to wait before retrying, from the ``Retry-After`` header.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def __init__(self, message: str, *, retry_after: Optional[int] = None, **kwargs) -> None:
|
|
76
|
+
super().__init__(message, **kwargs)
|
|
77
|
+
self.retry_after = retry_after
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class JobTimeout(PdfTaggerError):
|
|
81
|
+
"""A job did not reach a terminal state within the poll timeout.
|
|
82
|
+
|
|
83
|
+
Attributes:
|
|
84
|
+
job: The last :class:`~pdftagger.models.Job` observed, if any.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
def __init__(self, message: str, *, job: "Optional[Job]" = None) -> None:
|
|
88
|
+
super().__init__(message)
|
|
89
|
+
self.job = job
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class JobFailed(PdfTaggerError):
|
|
93
|
+
"""A job reached a terminal ``failed`` status while waiting for it.
|
|
94
|
+
|
|
95
|
+
Credits for a failed job are refunded automatically by the service.
|
|
96
|
+
|
|
97
|
+
Attributes:
|
|
98
|
+
job: The failed :class:`~pdftagger.models.Job`.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
def __init__(self, message: str, *, job: "Optional[Job]" = None) -> None:
|
|
102
|
+
super().__init__(message)
|
|
103
|
+
self.job = job
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class WebhookVerificationError(PdfTaggerError):
|
|
107
|
+
"""A webhook signature could not be verified (bad signature, format, or replay)."""
|
pdftagger/models.py
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
"""Typed data models returned by the SDK.
|
|
2
|
+
|
|
3
|
+
Each model keeps the full server payload in ``raw`` so a new API field is always
|
|
4
|
+
reachable even before the SDK adds a typed attribute for it.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Any, Dict, List, Mapping, Optional, Union
|
|
11
|
+
|
|
12
|
+
# Terminal job statuses (the job will not change again).
|
|
13
|
+
COMPLETED = "completed"
|
|
14
|
+
FAILED = "failed"
|
|
15
|
+
PROCESSING = "processing"
|
|
16
|
+
TERMINAL_STATUSES = (COMPLETED, FAILED)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class Account:
|
|
21
|
+
"""The authenticated account and its credit balance (``GET /v1/me``)."""
|
|
22
|
+
|
|
23
|
+
account_id: Optional[str] = None
|
|
24
|
+
plan: Optional[str] = None
|
|
25
|
+
subscription_status: Optional[str] = None
|
|
26
|
+
credits_available: Optional[int] = None
|
|
27
|
+
request_id: Optional[str] = None
|
|
28
|
+
raw: Dict[str, Any] = field(default_factory=dict, repr=False)
|
|
29
|
+
|
|
30
|
+
@classmethod
|
|
31
|
+
def from_dict(cls, data: Mapping[str, Any]) -> "Account":
|
|
32
|
+
return cls(
|
|
33
|
+
account_id=data.get("account_id"),
|
|
34
|
+
plan=data.get("plan"),
|
|
35
|
+
subscription_status=data.get("subscription_status"),
|
|
36
|
+
credits_available=data.get("credits_available"),
|
|
37
|
+
request_id=data.get("request_id"),
|
|
38
|
+
raw=dict(data),
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class Upload:
|
|
44
|
+
"""A presigned upload target (``POST /v1/uploads``).
|
|
45
|
+
|
|
46
|
+
Pass ``key`` to :meth:`~pdftagger.client.PdfTaggerClient.submit_job` after the file
|
|
47
|
+
has been uploaded to ``upload_url`` with every entry in ``fields``.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
upload_url: str
|
|
51
|
+
fields: Dict[str, str]
|
|
52
|
+
key: str
|
|
53
|
+
request_id: Optional[str] = None
|
|
54
|
+
raw: Dict[str, Any] = field(default_factory=dict, repr=False)
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
def from_dict(cls, data: Mapping[str, Any]) -> "Upload":
|
|
58
|
+
return cls(
|
|
59
|
+
upload_url=data["upload_url"],
|
|
60
|
+
fields=dict(data.get("fields") or {}),
|
|
61
|
+
key=data["key"],
|
|
62
|
+
request_id=data.get("request_id"),
|
|
63
|
+
raw=dict(data),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class Job:
|
|
69
|
+
"""A tagging job (``POST /v1/jobs`` and ``GET /v1/jobs/{id}``)."""
|
|
70
|
+
|
|
71
|
+
id: str
|
|
72
|
+
status: str
|
|
73
|
+
filename: Optional[str] = None
|
|
74
|
+
credits_used: Optional[int] = None
|
|
75
|
+
options: Optional[Dict[str, Any]] = None
|
|
76
|
+
created_at: Optional[str] = None
|
|
77
|
+
request_id: Optional[str] = None
|
|
78
|
+
#: Why the job failed. Set on the jobs inside a :class:`Batch` and in job webhook events.
|
|
79
|
+
error_code: Optional[str] = None
|
|
80
|
+
error_message: Optional[str] = None
|
|
81
|
+
raw: Dict[str, Any] = field(default_factory=dict, repr=False)
|
|
82
|
+
|
|
83
|
+
@classmethod
|
|
84
|
+
def from_dict(cls, data: Mapping[str, Any]) -> "Job":
|
|
85
|
+
return cls(
|
|
86
|
+
id=data["id"],
|
|
87
|
+
status=data["status"],
|
|
88
|
+
filename=data.get("filename"),
|
|
89
|
+
credits_used=data.get("credits_used"),
|
|
90
|
+
options=data.get("options"),
|
|
91
|
+
created_at=data.get("created_at"),
|
|
92
|
+
request_id=data.get("request_id"),
|
|
93
|
+
error_code=data.get("error_code"),
|
|
94
|
+
error_message=data.get("error_message"),
|
|
95
|
+
raw=dict(data),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def is_terminal(self) -> bool:
|
|
100
|
+
"""True once the job is ``completed`` or ``failed``."""
|
|
101
|
+
return self.status in TERMINAL_STATUSES
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def succeeded(self) -> bool:
|
|
105
|
+
"""True only when the job is ``completed``."""
|
|
106
|
+
return self.status == COMPLETED
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass
|
|
110
|
+
class JobResult:
|
|
111
|
+
"""A short-lived download link for a finished job (``GET /v1/jobs/{id}/result``)."""
|
|
112
|
+
|
|
113
|
+
result_url: str
|
|
114
|
+
expires_in: Optional[int] = None
|
|
115
|
+
request_id: Optional[str] = None
|
|
116
|
+
raw: Dict[str, Any] = field(default_factory=dict, repr=False)
|
|
117
|
+
|
|
118
|
+
@classmethod
|
|
119
|
+
def from_dict(cls, data: Mapping[str, Any]) -> "JobResult":
|
|
120
|
+
return cls(
|
|
121
|
+
result_url=data["result_url"],
|
|
122
|
+
expires_in=data.get("expires_in"),
|
|
123
|
+
request_id=data.get("request_id"),
|
|
124
|
+
raw=dict(data),
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass
|
|
129
|
+
class BatchItem:
|
|
130
|
+
"""One file in a batch for :meth:`~pdftagger.client.PdfTaggerClient.submit_batch`.
|
|
131
|
+
|
|
132
|
+
``key`` comes from :meth:`~pdftagger.client.PdfTaggerClient.create_upload`.
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
key: str
|
|
136
|
+
filename: Optional[str] = None
|
|
137
|
+
ocr: bool = False
|
|
138
|
+
embed_fonts: bool = False
|
|
139
|
+
|
|
140
|
+
def to_payload(self) -> Dict[str, Any]:
|
|
141
|
+
payload: Dict[str, Any] = {"key": self.key}
|
|
142
|
+
if self.filename:
|
|
143
|
+
payload["filename"] = self.filename
|
|
144
|
+
options: Dict[str, bool] = {}
|
|
145
|
+
if self.ocr:
|
|
146
|
+
options["ocr"] = True
|
|
147
|
+
if self.embed_fonts:
|
|
148
|
+
options["embed_fonts"] = True
|
|
149
|
+
if options:
|
|
150
|
+
payload["options"] = options
|
|
151
|
+
return payload
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@dataclass
|
|
155
|
+
class BatchCounts:
|
|
156
|
+
"""How many jobs in a batch are in each status."""
|
|
157
|
+
|
|
158
|
+
total: int = 0
|
|
159
|
+
processing: int = 0
|
|
160
|
+
completed: int = 0
|
|
161
|
+
failed: int = 0
|
|
162
|
+
|
|
163
|
+
@classmethod
|
|
164
|
+
def from_dict(cls, data: Mapping[str, Any]) -> "BatchCounts":
|
|
165
|
+
return cls(
|
|
166
|
+
total=data.get("total", 0),
|
|
167
|
+
processing=data.get("processing", 0),
|
|
168
|
+
completed=data.get("completed", 0),
|
|
169
|
+
failed=data.get("failed", 0),
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@dataclass
|
|
174
|
+
class Batch:
|
|
175
|
+
"""A batch of jobs (``POST /v1/batches`` and ``GET /v1/batches/{id}``).
|
|
176
|
+
|
|
177
|
+
``status`` is ``completed`` once every job has finished, whether each job completed
|
|
178
|
+
or failed. ``jobs`` keeps the order of the request.
|
|
179
|
+
"""
|
|
180
|
+
|
|
181
|
+
id: str
|
|
182
|
+
status: str
|
|
183
|
+
name: Optional[str] = None
|
|
184
|
+
counts: BatchCounts = field(default_factory=BatchCounts)
|
|
185
|
+
credits_used: Optional[int] = None
|
|
186
|
+
created_at: Optional[str] = None
|
|
187
|
+
completed_at: Optional[str] = None
|
|
188
|
+
jobs: List[Job] = field(default_factory=list)
|
|
189
|
+
request_id: Optional[str] = None
|
|
190
|
+
raw: Dict[str, Any] = field(default_factory=dict, repr=False)
|
|
191
|
+
|
|
192
|
+
@classmethod
|
|
193
|
+
def from_dict(cls, data: Mapping[str, Any]) -> "Batch":
|
|
194
|
+
return cls(
|
|
195
|
+
id=data["id"],
|
|
196
|
+
status=data["status"],
|
|
197
|
+
name=data.get("name"),
|
|
198
|
+
counts=BatchCounts.from_dict(data.get("counts") or {}),
|
|
199
|
+
credits_used=data.get("credits_used"),
|
|
200
|
+
created_at=data.get("created_at"),
|
|
201
|
+
completed_at=data.get("completed_at"),
|
|
202
|
+
jobs=[Job.from_dict(j) for j in data.get("jobs") or []],
|
|
203
|
+
request_id=data.get("request_id"),
|
|
204
|
+
raw=dict(data),
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
@property
|
|
208
|
+
def is_terminal(self) -> bool:
|
|
209
|
+
"""True once every job in the batch has finished."""
|
|
210
|
+
return self.status == COMPLETED
|
|
211
|
+
|
|
212
|
+
@property
|
|
213
|
+
def completed_jobs(self) -> List[Job]:
|
|
214
|
+
"""The jobs whose tagged PDF is ready to download."""
|
|
215
|
+
return [j for j in self.jobs if j.status == COMPLETED]
|
|
216
|
+
|
|
217
|
+
@property
|
|
218
|
+
def failed_jobs(self) -> List[Job]:
|
|
219
|
+
"""The jobs that failed. Their credits are refunded."""
|
|
220
|
+
return [j for j in self.jobs if j.status == FAILED]
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@dataclass
|
|
224
|
+
class BatchEvent:
|
|
225
|
+
"""The batch data in a ``batch.completed`` webhook event."""
|
|
226
|
+
|
|
227
|
+
id: str
|
|
228
|
+
name: Optional[str] = None
|
|
229
|
+
status: Optional[str] = None
|
|
230
|
+
total: int = 0
|
|
231
|
+
completed: int = 0
|
|
232
|
+
failed: int = 0
|
|
233
|
+
created_at: Optional[str] = None
|
|
234
|
+
completed_at: Optional[str] = None
|
|
235
|
+
|
|
236
|
+
@classmethod
|
|
237
|
+
def from_dict(cls, data: Mapping[str, Any]) -> "BatchEvent":
|
|
238
|
+
return cls(
|
|
239
|
+
id=data["id"],
|
|
240
|
+
name=data.get("name"),
|
|
241
|
+
status=data.get("status"),
|
|
242
|
+
total=data.get("total", 0),
|
|
243
|
+
completed=data.get("completed", 0),
|
|
244
|
+
failed=data.get("failed", 0),
|
|
245
|
+
created_at=data.get("created_at"),
|
|
246
|
+
completed_at=data.get("completed_at"),
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
@dataclass
|
|
251
|
+
class WebhookEvent:
|
|
252
|
+
"""A verified webhook event: ``job.completed``, ``job.failed`` or ``batch.completed``.
|
|
253
|
+
|
|
254
|
+
A job event sets ``job``; a ``batch.completed`` event sets ``batch``. The event carries
|
|
255
|
+
no result URL — fetch it with
|
|
256
|
+
:meth:`~pdftagger.client.PdfTaggerClient.get_job_result`, or read a batch's jobs with
|
|
257
|
+
:meth:`~pdftagger.client.PdfTaggerClient.get_batch`.
|
|
258
|
+
"""
|
|
259
|
+
|
|
260
|
+
id: Optional[str]
|
|
261
|
+
type: str
|
|
262
|
+
created: Optional[int]
|
|
263
|
+
job: Optional[Job]
|
|
264
|
+
raw: Dict[str, Any] = field(default_factory=dict, repr=False)
|
|
265
|
+
batch: Optional[BatchEvent] = None
|
|
266
|
+
|
|
267
|
+
@classmethod
|
|
268
|
+
def from_dict(cls, data: Mapping[str, Any]) -> "WebhookEvent":
|
|
269
|
+
body = data.get("data") or {}
|
|
270
|
+
job_data = body.get("job")
|
|
271
|
+
batch_data = body.get("batch")
|
|
272
|
+
return cls(
|
|
273
|
+
id=data.get("id"),
|
|
274
|
+
type=data.get("type", ""),
|
|
275
|
+
created=data.get("created"),
|
|
276
|
+
job=Job.from_dict(job_data) if job_data else None,
|
|
277
|
+
raw=dict(data),
|
|
278
|
+
batch=BatchEvent.from_dict(batch_data) if batch_data else None,
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
BatchItemInput = Union[str, BatchItem]
|
pdftagger/py.typed
ADDED
|
File without changes
|
pdftagger/webhooks.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Verify PDF Tagger webhook deliveries.
|
|
2
|
+
|
|
3
|
+
Every delivery carries an ``X-PDFTagger-Signature`` header of the form
|
|
4
|
+
``t=<unixSeconds>,v1=<hex-hmac>``, where the HMAC is SHA-256 over ``"<t>.<rawBody>"``
|
|
5
|
+
keyed by the endpoint secret (``whsec_…``). :func:`verify_signature` recomputes the HMAC
|
|
6
|
+
over the exact bytes you received, compares it in constant time, and rejects a stale
|
|
7
|
+
timestamp (replay). This mirrors the server's ``signWebhook`` implementation.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import hmac
|
|
14
|
+
import json
|
|
15
|
+
import time
|
|
16
|
+
from typing import Dict, Optional, Union
|
|
17
|
+
|
|
18
|
+
from .exceptions import WebhookVerificationError
|
|
19
|
+
from .models import WebhookEvent
|
|
20
|
+
|
|
21
|
+
#: Header the server sends the signature in.
|
|
22
|
+
SIGNATURE_HEADER = "X-PDFTagger-Signature"
|
|
23
|
+
#: Default replay window, in seconds.
|
|
24
|
+
DEFAULT_TOLERANCE_SECONDS = 300
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _parse_signature_header(header: str) -> Dict[str, str]:
|
|
28
|
+
parts: Dict[str, str] = {}
|
|
29
|
+
for item in header.split(","):
|
|
30
|
+
item = item.strip()
|
|
31
|
+
if "=" in item:
|
|
32
|
+
key, value = item.split("=", 1)
|
|
33
|
+
parts[key.strip()] = value.strip()
|
|
34
|
+
return parts
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def verify_signature(
|
|
38
|
+
payload: Union[bytes, bytearray, str],
|
|
39
|
+
header: Optional[str],
|
|
40
|
+
secret: str,
|
|
41
|
+
*,
|
|
42
|
+
tolerance: Optional[int] = DEFAULT_TOLERANCE_SECONDS,
|
|
43
|
+
now: Optional[int] = None,
|
|
44
|
+
) -> WebhookEvent:
|
|
45
|
+
"""Verify a webhook signature and return the parsed event.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
payload: The **raw** request body, exactly as received (bytes are preferred; a
|
|
49
|
+
str is encoded as UTF-8). Do not re-serialize a parsed JSON object — a
|
|
50
|
+
different byte layout will not match the signature.
|
|
51
|
+
header: The ``X-PDFTagger-Signature`` header value.
|
|
52
|
+
secret: The endpoint signing secret (``whsec_…``).
|
|
53
|
+
tolerance: Max allowed difference between the signed timestamp and now, in
|
|
54
|
+
seconds. Pass ``None`` to skip the replay check.
|
|
55
|
+
now: Current unix time, for testing. Defaults to :func:`time.time`.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
The verified :class:`~pdftagger.models.WebhookEvent`.
|
|
59
|
+
|
|
60
|
+
Raises:
|
|
61
|
+
WebhookVerificationError: If the header is missing or malformed, the signature
|
|
62
|
+
does not match, the timestamp is outside ``tolerance``, or the body is not
|
|
63
|
+
valid JSON.
|
|
64
|
+
"""
|
|
65
|
+
payload_bytes = payload.encode("utf-8") if isinstance(payload, str) else bytes(payload)
|
|
66
|
+
|
|
67
|
+
if not header:
|
|
68
|
+
raise WebhookVerificationError("Missing signature header")
|
|
69
|
+
|
|
70
|
+
parts = _parse_signature_header(header)
|
|
71
|
+
timestamp = parts.get("t")
|
|
72
|
+
provided = parts.get("v1")
|
|
73
|
+
if not timestamp or not provided:
|
|
74
|
+
raise WebhookVerificationError("Malformed signature header")
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
timestamp_int = int(timestamp)
|
|
78
|
+
except ValueError as exc:
|
|
79
|
+
raise WebhookVerificationError("Invalid timestamp in signature header") from exc
|
|
80
|
+
|
|
81
|
+
signed = f"{timestamp_int}.".encode("utf-8") + payload_bytes
|
|
82
|
+
expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
|
|
83
|
+
|
|
84
|
+
if not hmac.compare_digest(expected, provided):
|
|
85
|
+
raise WebhookVerificationError("Signature mismatch")
|
|
86
|
+
|
|
87
|
+
if tolerance is not None:
|
|
88
|
+
current = int(now if now is not None else time.time())
|
|
89
|
+
if abs(current - timestamp_int) > tolerance:
|
|
90
|
+
raise WebhookVerificationError("Timestamp outside tolerance (possible replay)")
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
data = json.loads(payload_bytes.decode("utf-8"))
|
|
94
|
+
except ValueError as exc:
|
|
95
|
+
raise WebhookVerificationError(f"Payload is not valid JSON: {exc}") from exc
|
|
96
|
+
|
|
97
|
+
return WebhookEvent.from_dict(data)
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pdftagger
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python SDK for the PDF Tagger API — auto-tag PDFs for PDF/UA accessibility.
|
|
5
|
+
Project-URL: Homepage, https://pdf-tagger.com
|
|
6
|
+
Project-URL: Documentation, https://pdf-tagger.com/api/reference
|
|
7
|
+
Project-URL: Source, https://github.com/rachel-ackeret/pdftagger-python
|
|
8
|
+
Project-URL: Issues, https://github.com/rachel-ackeret/pdftagger-python/issues
|
|
9
|
+
Author: PDF Tagger
|
|
10
|
+
License: MIT License
|
|
11
|
+
|
|
12
|
+
Copyright (c) 2026 PDF Tagger
|
|
13
|
+
|
|
14
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
15
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
16
|
+
in the Software without restriction, including without limitation the rights
|
|
17
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
18
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
19
|
+
furnished to do so, subject to the following conditions:
|
|
20
|
+
|
|
21
|
+
The above copyright notice and this permission notice shall be included in all
|
|
22
|
+
copies or substantial portions of the Software.
|
|
23
|
+
|
|
24
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
25
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
26
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
27
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
28
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
29
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
30
|
+
SOFTWARE.
|
|
31
|
+
License-File: LICENSE
|
|
32
|
+
Keywords: accessibility,auto-tag,pdf,pdf-ua,pdf/ua,sdk,section-508,tagging,wcag
|
|
33
|
+
Classifier: Development Status :: 4 - Beta
|
|
34
|
+
Classifier: Intended Audience :: Developers
|
|
35
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
36
|
+
Classifier: Operating System :: OS Independent
|
|
37
|
+
Classifier: Programming Language :: Python :: 3
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
39
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
40
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
41
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
42
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
43
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
44
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
45
|
+
Classifier: Typing :: Typed
|
|
46
|
+
Requires-Python: >=3.9
|
|
47
|
+
Requires-Dist: httpx>=0.24
|
|
48
|
+
Provides-Extra: dev
|
|
49
|
+
Requires-Dist: build>=1.0; extra == 'dev'
|
|
50
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
51
|
+
Requires-Dist: respx>=0.20; extra == 'dev'
|
|
52
|
+
Description-Content-Type: text/markdown
|
|
53
|
+
|
|
54
|
+
# pdftagger — auto-tag PDFs for accessibility from Python
|
|
55
|
+
|
|
56
|
+
Official Python SDK for the [PDF Tagger](https://pdf-tagger.com) API. Upload a PDF,
|
|
57
|
+
submit a tagging job, and download a **PDF/UA-tagged, accessible** PDF — with correct
|
|
58
|
+
reading order, headings, tables, and alt text. Tag one PDF, or up to 100 at once as a batch.
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
pip install pdftagger
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## How to auto-tag PDFs with the PDF Tagger SDK
|
|
65
|
+
|
|
66
|
+
Get an API key from the [Developers page](https://pdf-tagger.com/developers), then:
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from pdftagger import PdfTaggerClient
|
|
70
|
+
|
|
71
|
+
client = PdfTaggerClient(api_key="sk_live_...")
|
|
72
|
+
|
|
73
|
+
# Upload, submit, and wait for the tagged PDF — one call.
|
|
74
|
+
job = client.tag_pdf("report.pdf", ocr=True)
|
|
75
|
+
|
|
76
|
+
# Save the accessible, tagged PDF.
|
|
77
|
+
client.download_result(job, "report.tagged.pdf")
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
That's the whole flow. `tag_pdf()` uploads the file, submits the job, polls until it
|
|
81
|
+
finishes, and returns the completed job. Each job costs one credit per page, drawn from
|
|
82
|
+
the same balance as the web app.
|
|
83
|
+
|
|
84
|
+
### Options
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
job = client.tag_pdf(
|
|
88
|
+
"scan.pdf",
|
|
89
|
+
ocr=True, # recover text from image-only / scanned pages
|
|
90
|
+
embed_fonts=True, # embed a non-embedded font where it is safe to do so
|
|
91
|
+
idempotency_key="order-1234", # safe to retry without double-charging
|
|
92
|
+
)
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Don't want to block? Submit and poll yourself:
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
job = client.tag_pdf("report.pdf", wait=False) # returns immediately
|
|
99
|
+
job = client.wait_for_job(job) # ...poll later
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Many PDFs at once: batches
|
|
103
|
+
|
|
104
|
+
Tag up to 100 PDFs as one batch. `tag_pdfs()` uploads every file, submits them in one
|
|
105
|
+
request, and waits until every job has finished:
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
batch = client.tag_pdfs(["a.pdf", "b.pdf", "c.pdf"], name="Q3 reports", ocr=True)
|
|
109
|
+
|
|
110
|
+
print(batch.counts) # BatchCounts(total=3, processing=0, completed=3, failed=0)
|
|
111
|
+
for job in batch.failed_jobs: # a failed job does not raise for a batch
|
|
112
|
+
print(job.filename, job.error_code, job.error_message)
|
|
113
|
+
|
|
114
|
+
client.download_batch_results(batch, "tagged/") # saves each completed PDF
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
A batch is **all or nothing**: if any file is not a PDF, has more than 750 pages, or the
|
|
118
|
+
credits do not cover every page, nothing is charged and the call raises.
|
|
119
|
+
`BadRequestError.errors` lists each bad file by its position:
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
from pdftagger import BadRequestError
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
client.submit_batch([upload_a.key, upload_b.key])
|
|
126
|
+
except BadRequestError as e:
|
|
127
|
+
for problem in e.errors: # {"index": 1, "type": "too_many_pages", "message": "..."}
|
|
128
|
+
print(problem["index"], problem["message"])
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Drive it yourself with `submit_batch()` (upload keys or `BatchItem`s), `get_batch()` and
|
|
132
|
+
`wait_for_batch()`. Each job in a batch is a normal job, so `get_job()`,
|
|
133
|
+
`get_job_result()` and `download_result()` work on it.
|
|
134
|
+
|
|
135
|
+
## Verifying webhooks
|
|
136
|
+
|
|
137
|
+
Register a webhook endpoint on the Developers page to be notified when a job finishes.
|
|
138
|
+
Verify every delivery before trusting it — pass the **raw** request body:
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
from pdftagger import verify_signature
|
|
142
|
+
from pdftagger.webhooks import SIGNATURE_HEADER
|
|
143
|
+
|
|
144
|
+
event = verify_signature(
|
|
145
|
+
payload=raw_body_bytes, # the exact bytes you received
|
|
146
|
+
header=request.headers[SIGNATURE_HEADER],
|
|
147
|
+
secret="whsec_...", # the endpoint secret
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
if event.type == "job.completed":
|
|
151
|
+
url = client.get_job_result(event.job.id).result_url
|
|
152
|
+
elif event.type == "batch.completed":
|
|
153
|
+
batch = client.get_batch(event.batch.id) # every job in the batch has finished
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
`verify_signature` recomputes the HMAC-SHA256 signature over the raw body, compares it in
|
|
157
|
+
constant time, and rejects a stale timestamp (replay). It raises
|
|
158
|
+
`WebhookVerificationError` on any failure. A delivery can arrive more than once, so
|
|
159
|
+
dedupe on `event.id`.
|
|
160
|
+
|
|
161
|
+
## Low-level API
|
|
162
|
+
|
|
163
|
+
Every endpoint is available directly if you want to drive the flow yourself:
|
|
164
|
+
|
|
165
|
+
```python
|
|
166
|
+
client.get_account() # account + credit balance
|
|
167
|
+
upload = client.create_upload("report.pdf")
|
|
168
|
+
client.upload_file(upload, "report.pdf")
|
|
169
|
+
job = client.submit_job(key=upload.key, ocr=True)
|
|
170
|
+
job = client.get_job(job.id) # processing / completed / failed
|
|
171
|
+
result = client.get_job_result(job.id) # short-lived download URL
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
## Errors
|
|
175
|
+
|
|
176
|
+
All errors derive from `PdfTaggerError`.
|
|
177
|
+
|
|
178
|
+
| Exception | When |
|
|
179
|
+
|---|---|
|
|
180
|
+
| `AuthenticationError` | 401 — key missing, invalid, or revoked |
|
|
181
|
+
| `BadRequestError` | 400 — invalid request (for a batch, `.errors` lists each bad file) |
|
|
182
|
+
| `InsufficientCreditsError` | 402 — not enough credits (a batch charges nothing) |
|
|
183
|
+
| `NotFoundError` | 404 — no such job/resource for this account |
|
|
184
|
+
| `ConflictError` | 409 — idempotency conflict, or result not ready |
|
|
185
|
+
| `RateLimitError` | 429 — rate limited (`.retry_after` seconds) |
|
|
186
|
+
| `JobFailed` | the job finished with a `failed` status (credits are refunded) |
|
|
187
|
+
| `JobTimeout` | the job, or the batch, did not finish within the poll timeout |
|
|
188
|
+
| `WebhookVerificationError` | a webhook signature could not be verified |
|
|
189
|
+
|
|
190
|
+
## Requirements
|
|
191
|
+
|
|
192
|
+
- Python 3.9+
|
|
193
|
+
- [`httpx`](https://www.python-httpx.org/) (installed automatically)
|
|
194
|
+
|
|
195
|
+
## Links
|
|
196
|
+
|
|
197
|
+
- API reference: https://pdf-tagger.com/api/reference
|
|
198
|
+
- Create an API key: https://pdf-tagger.com/developers
|
|
199
|
+
|
|
200
|
+
## License
|
|
201
|
+
|
|
202
|
+
MIT
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
pdftagger/__init__.py,sha256=16gtKU5MizUa3-YEMbaMp3EmT3l7xe1Kx6D8yByF-QQ,1593
|
|
2
|
+
pdftagger/_version.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
3
|
+
pdftagger/client.py,sha256=vDX9B7EYOmdvm7I9YO0xXt23DEEbdnR66xeZwGqF52w,18246
|
|
4
|
+
pdftagger/exceptions.py,sha256=7s3LwoG4151-O4On_oIjpABtXAYeYsgsXwHqC6heEoE,3384
|
|
5
|
+
pdftagger/models.py,sha256=VpYwvUvmaXal1BkfXQbJNuMt5Jdi7dKFR6DR-Zf7B-A,8735
|
|
6
|
+
pdftagger/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
pdftagger/webhooks.py,sha256=oFMGOloMrf_uF9lIOqqvy5Ro3QRR5R5lUU6qko7ddcM,3575
|
|
8
|
+
pdftagger-0.1.0.dist-info/METADATA,sha256=5AYnTCZ_JTigSpLEnbB9MCDNa1V1fhxW5-xwFdOd8uY,7904
|
|
9
|
+
pdftagger-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
10
|
+
pdftagger-0.1.0.dist-info/licenses/LICENSE,sha256=_n5jnbVB4wUObSq7ulIoCWxXqVkKNgZc_b0Mm9pMY7E,1067
|
|
11
|
+
pdftagger-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 PDF Tagger
|
|
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.
|