pdftagger 0.1.1__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. {pdftagger-0.1.1 → pdftagger-0.2.0}/PKG-INFO +30 -3
  2. {pdftagger-0.1.1 → pdftagger-0.2.0}/README.md +29 -2
  3. pdftagger-0.2.0/RELEASING.md +29 -0
  4. {pdftagger-0.1.1 → pdftagger-0.2.0}/examples/quickstart.py +6 -1
  5. {pdftagger-0.1.1 → pdftagger-0.2.0}/src/pdftagger/__init__.py +17 -1
  6. pdftagger-0.2.0/src/pdftagger/_version.py +1 -0
  7. {pdftagger-0.1.1 → pdftagger-0.2.0}/src/pdftagger/client.py +28 -3
  8. {pdftagger-0.1.1 → pdftagger-0.2.0}/src/pdftagger/models.py +82 -0
  9. pdftagger-0.2.0/tests/fixtures/job_report.json +2877 -0
  10. pdftagger-0.2.0/tests/test_report.py +111 -0
  11. pdftagger-0.1.1/RELEASING.md +0 -15
  12. pdftagger-0.1.1/src/pdftagger/_version.py +0 -1
  13. {pdftagger-0.1.1 → pdftagger-0.2.0}/.github/workflows/publish.yml +0 -0
  14. {pdftagger-0.1.1 → pdftagger-0.2.0}/.github/workflows/test.yml +0 -0
  15. {pdftagger-0.1.1 → pdftagger-0.2.0}/.gitignore +0 -0
  16. {pdftagger-0.1.1 → pdftagger-0.2.0}/LICENSE +0 -0
  17. {pdftagger-0.1.1 → pdftagger-0.2.0}/examples/webhook_flask.py +0 -0
  18. {pdftagger-0.1.1 → pdftagger-0.2.0}/pyproject.toml +0 -0
  19. {pdftagger-0.1.1 → pdftagger-0.2.0}/src/pdftagger/exceptions.py +0 -0
  20. {pdftagger-0.1.1 → pdftagger-0.2.0}/src/pdftagger/py.typed +0 -0
  21. {pdftagger-0.1.1 → pdftagger-0.2.0}/src/pdftagger/webhooks.py +0 -0
  22. {pdftagger-0.1.1 → pdftagger-0.2.0}/tests/_sign_reference.mjs +0 -0
  23. {pdftagger-0.1.1 → pdftagger-0.2.0}/tests/test_batches.py +0 -0
  24. {pdftagger-0.1.1 → pdftagger-0.2.0}/tests/test_client.py +0 -0
  25. {pdftagger-0.1.1 → pdftagger-0.2.0}/tests/test_webhook_cross_lang.py +0 -0
  26. {pdftagger-0.1.1 → pdftagger-0.2.0}/tests/test_webhooks.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pdftagger
3
- Version: 0.1.1
3
+ Version: 0.2.0
4
4
  Summary: Official Python SDK for the PDF Tagger API — auto-tag PDFs for PDF/UA accessibility.
5
5
  Project-URL: Homepage, https://pdf-tagger.com
6
6
  Project-URL: Documentation, https://pdf-tagger.com/api/reference
@@ -56,6 +56,7 @@ Description-Content-Type: text/markdown
56
56
  Official Python SDK for the [PDF Tagger](https://pdf-tagger.com) API. Upload a PDF,
57
57
  submit a tagging job, and download a **PDF/UA-tagged, accessible** PDF — with correct
58
58
  reading order, headings, tables, and alt text. Tag one PDF, or up to 100 at once as a batch.
59
+ Each job comes with a PDF/UA-1 report: the result for every accessibility requirement in PDF/UA-1.
59
60
 
60
61
  ```bash
61
62
  pip install pdftagger
@@ -99,6 +100,31 @@ job = client.tag_pdf("report.pdf", wait=False) # returns immediately
99
100
  job = client.wait_for_job(job) # ...poll later
100
101
  ```
101
102
 
103
+ ## The PDF/UA-1 report
104
+
105
+ Every completed job has a report: the result and evidence for every accessibility requirement in
106
+ PDF/UA-1, and a report PDF you can keep.
107
+
108
+ ```python
109
+ report = client.get_job_report(job)
110
+ print(report.summary) # {'handled': 169, 'failed': 0, 'needs_review': 0, 'for_information': 0, 'not_applicable': 51}
111
+
112
+ for req in report.failed: # also report.needs_review
113
+ print(req.reference, req.name, req.evidence)
114
+
115
+ client.download_report(job, "report.accessibility-report.pdf")
116
+ ```
117
+
118
+ Each `Requirement` has a `result`:
119
+
120
+ | `result` | Meaning |
121
+ |---|---|
122
+ | `handled` | It passed, or PDF Tagger met or fixed it. |
123
+ | `failed` | A validator found it failing in the file. |
124
+ | `needs_review` | A person must confirm it. |
125
+ | `for_information` | A limit from the source file that you cannot act on. |
126
+ | `not_applicable` | The document has no content of this kind. |
127
+
102
128
  ## Many PDFs at once: batches
103
129
 
104
130
  Tag up to 100 PDFs as one batch. `tag_pdfs()` uploads every file, submits them in one
@@ -169,6 +195,7 @@ client.upload_file(upload, "report.pdf")
169
195
  job = client.submit_job(key=upload.key, ocr=True)
170
196
  job = client.get_job(job.id) # processing / completed / failed
171
197
  result = client.get_job_result(job.id) # short-lived download URL
198
+ report = client.get_job_report(job.id) # the PDF/UA-1 report
172
199
  ```
173
200
 
174
201
  ## Errors
@@ -180,8 +207,8 @@ All errors derive from `PdfTaggerError`.
180
207
  | `AuthenticationError` | 401 — key missing, invalid, or revoked |
181
208
  | `BadRequestError` | 400 — invalid request (for a batch, `.errors` lists each bad file) |
182
209
  | `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 |
210
+ | `NotFoundError` | 404 — no such job/resource for this account, or the job has no report |
211
+ | `ConflictError` | 409 — idempotency conflict, or result or report not ready |
185
212
  | `RateLimitError` | 429 — rate limited (`.retry_after` seconds) |
186
213
  | `JobFailed` | the job finished with a `failed` status (credits are refunded) |
187
214
  | `JobTimeout` | the job, or the batch, did not finish within the poll timeout |
@@ -3,6 +3,7 @@
3
3
  Official Python SDK for the [PDF Tagger](https://pdf-tagger.com) API. Upload a PDF,
4
4
  submit a tagging job, and download a **PDF/UA-tagged, accessible** PDF — with correct
5
5
  reading order, headings, tables, and alt text. Tag one PDF, or up to 100 at once as a batch.
6
+ Each job comes with a PDF/UA-1 report: the result for every accessibility requirement in PDF/UA-1.
6
7
 
7
8
  ```bash
8
9
  pip install pdftagger
@@ -46,6 +47,31 @@ job = client.tag_pdf("report.pdf", wait=False) # returns immediately
46
47
  job = client.wait_for_job(job) # ...poll later
47
48
  ```
48
49
 
50
+ ## The PDF/UA-1 report
51
+
52
+ Every completed job has a report: the result and evidence for every accessibility requirement in
53
+ PDF/UA-1, and a report PDF you can keep.
54
+
55
+ ```python
56
+ report = client.get_job_report(job)
57
+ print(report.summary) # {'handled': 169, 'failed': 0, 'needs_review': 0, 'for_information': 0, 'not_applicable': 51}
58
+
59
+ for req in report.failed: # also report.needs_review
60
+ print(req.reference, req.name, req.evidence)
61
+
62
+ client.download_report(job, "report.accessibility-report.pdf")
63
+ ```
64
+
65
+ Each `Requirement` has a `result`:
66
+
67
+ | `result` | Meaning |
68
+ |---|---|
69
+ | `handled` | It passed, or PDF Tagger met or fixed it. |
70
+ | `failed` | A validator found it failing in the file. |
71
+ | `needs_review` | A person must confirm it. |
72
+ | `for_information` | A limit from the source file that you cannot act on. |
73
+ | `not_applicable` | The document has no content of this kind. |
74
+
49
75
  ## Many PDFs at once: batches
50
76
 
51
77
  Tag up to 100 PDFs as one batch. `tag_pdfs()` uploads every file, submits them in one
@@ -116,6 +142,7 @@ client.upload_file(upload, "report.pdf")
116
142
  job = client.submit_job(key=upload.key, ocr=True)
117
143
  job = client.get_job(job.id) # processing / completed / failed
118
144
  result = client.get_job_result(job.id) # short-lived download URL
145
+ report = client.get_job_report(job.id) # the PDF/UA-1 report
119
146
  ```
120
147
 
121
148
  ## Errors
@@ -127,8 +154,8 @@ All errors derive from `PdfTaggerError`.
127
154
  | `AuthenticationError` | 401 — key missing, invalid, or revoked |
128
155
  | `BadRequestError` | 400 — invalid request (for a batch, `.errors` lists each bad file) |
129
156
  | `InsufficientCreditsError` | 402 — not enough credits (a batch charges nothing) |
130
- | `NotFoundError` | 404 — no such job/resource for this account |
131
- | `ConflictError` | 409 — idempotency conflict, or result not ready |
157
+ | `NotFoundError` | 404 — no such job/resource for this account, or the job has no report |
158
+ | `ConflictError` | 409 — idempotency conflict, or result or report not ready |
132
159
  | `RateLimitError` | 429 — rate limited (`.retry_after` seconds) |
133
160
  | `JobFailed` | the job finished with a `failed` status (credits are refunded) |
134
161
  | `JobTimeout` | the job, or the batch, did not finish within the poll timeout |
@@ -0,0 +1,29 @@
1
+ # Releasing
2
+
3
+ PyPI publishing is automatic: publishing a GitHub release runs `.github/workflows/publish.yml`,
4
+ which builds the package and uploads it with PyPI trusted publishing (no token).
5
+
6
+ 1. Set the new version in `src/pdftagger/_version.py` and merge it to `main`.
7
+ 2. On GitHub, create a release with the tag `v<version>` (for example `v0.1.0`) and publish it.
8
+ 3. Check the "Publish to PyPI" workflow run, then `pip install pdftagger==<version>`.
9
+
10
+ One-time setup (already done once it has been published):
11
+
12
+ - On PyPI, add a trusted publisher for the project `pdftagger`: owner `PDF-Tagger`,
13
+ repository `pdftagger-python`, workflow `publish.yml`, environment `pypi`. Before the first
14
+ release this is a "pending publisher" (Account settings → Publishing).
15
+ - On GitHub, create the environment `pypi` (Settings → Environments).
16
+
17
+ ## The PyPI provenance banner
18
+
19
+ PyPI compares each release with the latest earlier release that has attestations and was made within 14 days. It
20
+ shows a "Change of provenance" warning when the publishing repository or workflow changed. 0.1.1 has that warning
21
+ because 0.1.0 came from `rachel-ackeret/pdftagger-python` and 0.1.1 from `PDF-Tagger/pdftagger-python`. Every later
22
+ release from this repository and `publish.yml` has no warning, so the project page is clean from 0.2.0 on. Keep the
23
+ repository and the workflow file name the same; a change brings the warning back for one release. Do not delete
24
+ 0.1.0 to clear 0.1.1's own page: deletion is permanent and burns the version number.
25
+
26
+ ## Releases that need an API change
27
+
28
+ Publish a release only after every API endpoint it calls is live on api.pdf-tagger.com. 0.2.0 calls
29
+ `GET /v1/jobs/{id}/report`.
@@ -1,4 +1,4 @@
1
- """Tag a PDF and save the accessible result.
1
+ """Tag a PDF, save the accessible result and print its PDF/UA-1 report.
2
2
 
3
3
  Usage:
4
4
  export PDFTAGGER_API_KEY=sk_live_...
@@ -38,6 +38,11 @@ def main() -> int:
38
38
 
39
39
  client.download_result(job, out)
40
40
  print(f"Tagged PDF saved to {out} (job {job.id}, {job.credits_used} credits)")
41
+
42
+ report = client.get_job_report(job)
43
+ print(f"{report.standard}: {report.summary}")
44
+ for req in report.failed + report.needs_review:
45
+ print(f" {req.result}: {req.reference} {req.name}")
41
46
  return 0
42
47
 
43
48
 
@@ -6,6 +6,8 @@ Auto-tag PDFs for PDF/UA accessibility from your own code:
6
6
  >>> client = PdfTaggerClient(api_key="sk_live_...")
7
7
  >>> job = client.tag_pdf("report.pdf", ocr=True)
8
8
  >>> client.download_result(job, "report.tagged.pdf")
9
+ >>> report = client.get_job_report(job) # the PDF/UA-1 report
10
+ >>> report.summary
9
11
 
10
12
  Many PDFs at once, as one batch:
11
13
 
@@ -30,7 +32,19 @@ from .exceptions import (
30
32
  RateLimitError,
31
33
  WebhookVerificationError,
32
34
  )
33
- from .models import Account, Batch, BatchCounts, BatchEvent, BatchItem, Job, JobResult, Upload, WebhookEvent
35
+ from .models import (
36
+ Account,
37
+ Batch,
38
+ BatchCounts,
39
+ BatchEvent,
40
+ BatchItem,
41
+ Job,
42
+ JobReport,
43
+ JobResult,
44
+ Requirement,
45
+ Upload,
46
+ WebhookEvent,
47
+ )
34
48
  from .webhooks import SIGNATURE_HEADER, verify_signature
35
49
 
36
50
  __all__ = [
@@ -42,6 +56,8 @@ __all__ = [
42
56
  "Upload",
43
57
  "Job",
44
58
  "JobResult",
59
+ "JobReport",
60
+ "Requirement",
45
61
  "Batch",
46
62
  "BatchItem",
47
63
  "BatchCounts",
@@ -0,0 +1 @@
1
+ __version__ = "0.2.0"
@@ -20,7 +20,7 @@ from .exceptions import (
20
20
  NotFoundError,
21
21
  RateLimitError,
22
22
  )
23
- from .models import Account, Batch, BatchItem, BatchItemInput, Job, JobResult, Upload
23
+ from .models import Account, Batch, BatchItem, BatchItemInput, Job, JobReport, JobResult, Upload
24
24
 
25
25
  DEFAULT_BASE_URL = "https://api.pdf-tagger.com"
26
26
 
@@ -236,6 +236,14 @@ class PdfTaggerClient:
236
236
  """Get a short-lived download URL for a completed job."""
237
237
  return JobResult.from_dict(self._request("GET", f"/v1/jobs/{_job_id(job)}/result"))
238
238
 
239
+ def get_job_report(self, job: Union[str, Job]) -> JobReport:
240
+ """Get a completed job's PDF/UA-1 report: the result for every accessibility requirement.
241
+
242
+ Raises :class:`ConflictError` while the job is not completed, and :class:`NotFoundError`
243
+ when the job has no PDF/UA-1 report.
244
+ """
245
+ return JobReport.from_dict(self._request("GET", f"/v1/jobs/{_job_id(job)}/report"))
246
+
239
247
  def submit_batch(
240
248
  self,
241
249
  items: Sequence[BatchItemInput],
@@ -440,9 +448,26 @@ class PdfTaggerClient:
440
448
  Returns the written :class:`~pathlib.Path` when ``dest`` is given, otherwise the
441
449
  PDF bytes.
442
450
  """
443
- result = self.get_job_result(job)
451
+ return self._download(self.get_job_result(job).result_url, dest)
452
+
453
+ def download_report(
454
+ self,
455
+ job: Union[str, Job],
456
+ dest: Optional[Union[str, Path]] = None,
457
+ ) -> Union[bytes, Path]:
458
+ """Download a completed job's PDF/UA-1 report PDF.
459
+
460
+ Returns the written :class:`~pathlib.Path` when ``dest`` is given, otherwise the
461
+ PDF bytes. Raises :class:`NotFoundError` when the report PDF is no longer stored.
462
+ """
463
+ report = self.get_job_report(job)
464
+ if not report.report_pdf_url:
465
+ raise NotFoundError("The report PDF for this job is no longer stored.", status_code=404)
466
+ return self._download(report.report_pdf_url, dest)
467
+
468
+ def _download(self, url: str, dest: Optional[Union[str, Path]]) -> Union[bytes, Path]:
444
469
  try:
445
- response = self._http.get(result.result_url)
470
+ response = self._http.get(url)
446
471
  except httpx.HTTPError as exc:
447
472
  raise APIError(f"Download failed: {exc}") from exc
448
473
  if not response.is_success:
@@ -125,6 +125,88 @@ class JobResult:
125
125
  )
126
126
 
127
127
 
128
+ @dataclass
129
+ class Requirement:
130
+ """One accessibility requirement in a job's PDF/UA-1 report, with its result on this document.
131
+
132
+ ``result`` is one of ``handled`` (passed, or PDF Tagger met or fixed it), ``failed`` (a validator
133
+ found it failing in the file), ``needs_review`` (a person must confirm it), ``for_information`` (a
134
+ limit from the source file you cannot act on) or ``not_applicable`` (the document has no content of
135
+ this kind). ``evidence`` is the sentence behind the result, or ``None`` for a plain pass.
136
+ """
137
+
138
+ id: str
139
+ reference: str
140
+ name: str
141
+ result: str
142
+ level: Optional[str] = None
143
+ section_id: Optional[str] = None
144
+ section_title: Optional[str] = None
145
+ category: Optional[str] = None
146
+ evidence: Optional[str] = None
147
+ raw: Dict[str, Any] = field(default_factory=dict, repr=False)
148
+
149
+ @classmethod
150
+ def from_dict(cls, data: Mapping[str, Any]) -> "Requirement":
151
+ section = data.get("section") or {}
152
+ return cls(
153
+ id=data["id"],
154
+ reference=data.get("reference", ""),
155
+ name=data.get("name", ""),
156
+ result=data["result"],
157
+ level=data.get("level"),
158
+ section_id=section.get("id"),
159
+ section_title=section.get("title"),
160
+ category=data.get("category"),
161
+ evidence=data.get("evidence"),
162
+ raw=dict(data),
163
+ )
164
+
165
+
166
+ @dataclass
167
+ class JobReport:
168
+ """A job's PDF/UA-1 report (``GET /v1/jobs/{id}/report``).
169
+
170
+ ``requirements`` lists every accessibility requirement in PDF/UA-1, in the standard's order.
171
+ ``summary`` counts the requirements by result. ``report_pdf_url`` is a short-lived link to the
172
+ report PDF, or ``None`` when the file is no longer stored.
173
+ """
174
+
175
+ job_id: str
176
+ standard: str
177
+ total_requirements: int
178
+ summary: Dict[str, int]
179
+ requirements: List[Requirement]
180
+ report_pdf_url: Optional[str] = None
181
+ expires_in: Optional[int] = None
182
+ request_id: Optional[str] = None
183
+ raw: Dict[str, Any] = field(default_factory=dict, repr=False)
184
+
185
+ @classmethod
186
+ def from_dict(cls, data: Mapping[str, Any]) -> "JobReport":
187
+ return cls(
188
+ job_id=data["job_id"],
189
+ standard=data.get("standard", ""),
190
+ total_requirements=int(data.get("total_requirements", 0)),
191
+ summary=dict(data.get("summary") or {}),
192
+ requirements=[Requirement.from_dict(r) for r in data.get("requirements") or []],
193
+ report_pdf_url=data.get("report_pdf_url"),
194
+ expires_in=data.get("expires_in"),
195
+ request_id=data.get("request_id"),
196
+ raw=dict(data),
197
+ )
198
+
199
+ @property
200
+ def failed(self) -> List[Requirement]:
201
+ """The requirements a validator found failing in the file."""
202
+ return [r for r in self.requirements if r.result == "failed"]
203
+
204
+ @property
205
+ def needs_review(self) -> List[Requirement]:
206
+ """The requirements a person must confirm."""
207
+ return [r for r in self.requirements if r.result == "needs_review"]
208
+
209
+
128
210
  @dataclass
129
211
  class BatchItem:
130
212
  """One file in a batch for :meth:`~pdftagger.client.PdfTaggerClient.submit_batch`.