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