pdfops 0.2.0__tar.gz → 0.3.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.
- {pdfops-0.2.0 → pdfops-0.3.0}/PKG-INFO +4 -4
- {pdfops-0.2.0 → pdfops-0.3.0}/README.md +3 -3
- {pdfops-0.2.0 → pdfops-0.3.0}/pyproject.toml +1 -1
- {pdfops-0.2.0 → pdfops-0.3.0}/src/pdfops/__init__.py +26 -8
- {pdfops-0.2.0 → pdfops-0.3.0}/tests/test_client.py +22 -2
- {pdfops-0.2.0 → pdfops-0.3.0}/.gitignore +0 -0
- {pdfops-0.2.0 → pdfops-0.3.0}/LICENSE +0 -0
- {pdfops-0.2.0 → pdfops-0.3.0}/src/pdfops/py.typed +0 -0
- {pdfops-0.2.0 → pdfops-0.3.0}/tests/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: pdfops
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Python client for the PDFops API: fill AcroForm PDFs, merge and split PDFs, page operations (extract, delete, rotate, watermark, page numbers), protect and unlock, edit metadata, inspect form fields, generate invoices. One HTTP call, no native deps, no headless browser.
|
|
5
5
|
Project-URL: Homepage, https://pdfops.dev
|
|
6
6
|
Project-URL: Documentation, https://pdfops.dev/docs
|
|
@@ -93,9 +93,9 @@ PDF inputs may be `bytes`, a path (`str` / `pathlib.Path`), or a binary file obj
|
|
|
93
93
|
|
|
94
94
|
| Method | Endpoint | Returns |
|
|
95
95
|
|---|---|---|
|
|
96
|
-
| `inspect(pdf)` | `POST /api/inspect` | `dict` — `count`, `hasXFA`, `fields[]`, `fillTemplate` |
|
|
97
|
-
| `fill_form(pdf, fields, *, flatten=False)` | `POST /api/fill-form` | filled PDF `bytes` |
|
|
98
|
-
| `merge([pdf, pdf, ...])` | `POST /api/merge` | merged PDF `bytes` |
|
|
96
|
+
| `inspect(pdf, *, password=None)` | `POST /api/inspect` | `dict` — `count`, `hasXFA`, `fields[]`, `fillTemplate` |
|
|
97
|
+
| `fill_form(pdf, fields, *, flatten=False, password=None)` | `POST /api/fill-form` | filled PDF `bytes` |
|
|
98
|
+
| `merge([pdf, pdf, ...], *, password=None)` | `POST /api/merge` | merged PDF `bytes` |
|
|
99
99
|
| `invoice(request)` | `POST /api/invoice` | invoice PDF `bytes` |
|
|
100
100
|
| `extract_pages(pdf, pages, *, password=None)` | `POST /api/extract-pages` | PDF `bytes` with only those pages, in that order |
|
|
101
101
|
| `delete_pages(pdf, pages, *, password=None)` | `POST /api/delete-pages` | PDF `bytes` without those pages |
|
|
@@ -63,9 +63,9 @@ PDF inputs may be `bytes`, a path (`str` / `pathlib.Path`), or a binary file obj
|
|
|
63
63
|
|
|
64
64
|
| Method | Endpoint | Returns |
|
|
65
65
|
|---|---|---|
|
|
66
|
-
| `inspect(pdf)` | `POST /api/inspect` | `dict` — `count`, `hasXFA`, `fields[]`, `fillTemplate` |
|
|
67
|
-
| `fill_form(pdf, fields, *, flatten=False)` | `POST /api/fill-form` | filled PDF `bytes` |
|
|
68
|
-
| `merge([pdf, pdf, ...])` | `POST /api/merge` | merged PDF `bytes` |
|
|
66
|
+
| `inspect(pdf, *, password=None)` | `POST /api/inspect` | `dict` — `count`, `hasXFA`, `fields[]`, `fillTemplate` |
|
|
67
|
+
| `fill_form(pdf, fields, *, flatten=False, password=None)` | `POST /api/fill-form` | filled PDF `bytes` |
|
|
68
|
+
| `merge([pdf, pdf, ...], *, password=None)` | `POST /api/merge` | merged PDF `bytes` |
|
|
69
69
|
| `invoice(request)` | `POST /api/invoice` | invoice PDF `bytes` |
|
|
70
70
|
| `extract_pages(pdf, pages, *, password=None)` | `POST /api/extract-pages` | PDF `bytes` with only those pages, in that order |
|
|
71
71
|
| `delete_pages(pdf, pages, *, password=None)` | `POST /api/delete-pages` | PDF `bytes` without those pages |
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "pdfops"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
8
8
|
description = "Python client for the PDFops API: fill AcroForm PDFs, merge and split PDFs, page operations (extract, delete, rotate, watermark, page numbers), protect and unlock, edit metadata, inspect form fields, generate invoices. One HTTP call, no native deps, no headless browser."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -26,7 +26,7 @@ from urllib import error as urlerror
|
|
|
26
26
|
from urllib import request as urlrequest
|
|
27
27
|
|
|
28
28
|
__all__ = ["PdfOps", "PdfOpsError", "PdfInput", "SplitPart", "SplitResult", "UnlockResult", "__version__"]
|
|
29
|
-
__version__ = "0.
|
|
29
|
+
__version__ = "0.3.0"
|
|
30
30
|
|
|
31
31
|
#: A PDF given as raw bytes, a filesystem path, or a binary file object.
|
|
32
32
|
PdfInput = Union[bytes, bytearray, memoryview, str, "os.PathLike[str]", IO[bytes]]
|
|
@@ -152,6 +152,10 @@ class PdfOps:
|
|
|
152
152
|
h["X-API-Key"] = self.api_key
|
|
153
153
|
if self.client_tag:
|
|
154
154
|
h["X-Pdfops-Client"] = self.client_tag
|
|
155
|
+
# Same rule as the TS SDK: the package version describes the "py"
|
|
156
|
+
# tag only; an embedding tool's tag has its own version.
|
|
157
|
+
if self.client_tag == "py":
|
|
158
|
+
h["X-Pdfops-Client-Version"] = __version__
|
|
155
159
|
if extra:
|
|
156
160
|
h.update(extra)
|
|
157
161
|
return h
|
|
@@ -193,22 +197,29 @@ class PdfOps:
|
|
|
193
197
|
|
|
194
198
|
# ── API ─────────────────────────────────────────────────────────────────
|
|
195
199
|
|
|
196
|
-
def inspect(self, pdf: PdfInput) -> Dict[str, Any]:
|
|
200
|
+
def inspect(self, pdf: PdfInput, *, password: Optional[str] = None) -> Dict[str, Any]:
|
|
197
201
|
"""List a PDF's AcroForm fields plus a paste-ready ``fillTemplate``.
|
|
198
202
|
|
|
199
203
|
Returns ``{"count", "hasXFA", "fields": [...], "fillTemplate": {...}}``.
|
|
200
|
-
A PDF with no form returns ``count == 0`` — not an error.
|
|
204
|
+
A PDF with no form returns ``count == 0`` — not an error. ``password``
|
|
205
|
+
opens a user-password PDF.
|
|
201
206
|
"""
|
|
202
|
-
|
|
207
|
+
parts: List[Tuple[str, Optional[str], bytes, str]] = [("pdf", "input.pdf", _read_pdf(pdf), "application/pdf")]
|
|
208
|
+
if password is not None:
|
|
209
|
+
parts.append(("password", None, password.encode("utf-8"), "text/plain"))
|
|
210
|
+
data = self._post_pdf("/api/inspect", parts)
|
|
203
211
|
return json.loads(data.decode("utf-8"))
|
|
204
212
|
|
|
205
|
-
def fill_form(
|
|
213
|
+
def fill_form(
|
|
214
|
+
self, pdf: PdfInput, fields: Mapping[str, str], *, flatten: bool = False, password: Optional[str] = None
|
|
215
|
+
) -> bytes:
|
|
206
216
|
"""Fill AcroForm fields; returns the filled PDF bytes.
|
|
207
217
|
|
|
208
218
|
Field names must exist in the PDF — use :meth:`inspect` to discover
|
|
209
219
|
them. Checkbox values are the strings ``"true"``/``"false"``; choice
|
|
210
220
|
fields take one of their options. ``flatten=True`` bakes values into
|
|
211
221
|
the page content and drops the form so fields are no longer editable.
|
|
222
|
+
``password`` opens a user-password PDF.
|
|
212
223
|
"""
|
|
213
224
|
parts: List[Tuple[str, Optional[str], bytes, str]] = [
|
|
214
225
|
("pdf", "input.pdf", _read_pdf(pdf), "application/pdf"),
|
|
@@ -216,13 +227,20 @@ class PdfOps:
|
|
|
216
227
|
]
|
|
217
228
|
if flatten:
|
|
218
229
|
parts.append(("flatten", None, b"true", "text/plain"))
|
|
230
|
+
if password is not None:
|
|
231
|
+
parts.append(("password", None, password.encode("utf-8"), "text/plain"))
|
|
219
232
|
return self._post_pdf("/api/fill-form", parts)
|
|
220
233
|
|
|
221
|
-
def merge(self, pdfs: Sequence[PdfInput]) -> bytes:
|
|
222
|
-
"""Merge two or more PDFs, in order; returns the combined PDF bytes.
|
|
234
|
+
def merge(self, pdfs: Sequence[PdfInput], *, password: Optional[str] = None) -> bytes:
|
|
235
|
+
"""Merge two or more PDFs, in order; returns the combined PDF bytes.
|
|
236
|
+
|
|
237
|
+
``password`` opens user-password inputs and applies to every input.
|
|
238
|
+
"""
|
|
223
239
|
if len(pdfs) < 2:
|
|
224
240
|
raise PdfOpsError(400, "too_few_pdfs", "merge needs at least 2 PDFs")
|
|
225
|
-
parts = [("pdf", f"part-{i}.pdf", _read_pdf(p), "application/pdf") for i, p in enumerate(pdfs)]
|
|
241
|
+
parts: List[Tuple[str, Optional[str], bytes, str]] = [("pdf", f"part-{i}.pdf", _read_pdf(p), "application/pdf") for i, p in enumerate(pdfs)]
|
|
242
|
+
if password is not None:
|
|
243
|
+
parts.append(("password", None, password.encode("utf-8"), "text/plain"))
|
|
226
244
|
return self._post_pdf("/api/merge", parts)
|
|
227
245
|
|
|
228
246
|
def invoice(self, request: Mapping[str, Any]) -> bytes:
|
|
@@ -13,6 +13,7 @@ import sys
|
|
|
13
13
|
import tempfile
|
|
14
14
|
import unittest
|
|
15
15
|
from typing import List, Optional, Tuple
|
|
16
|
+
from unittest import mock
|
|
16
17
|
|
|
17
18
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
|
|
18
19
|
|
|
@@ -54,6 +55,7 @@ class HeadersAndInputs(unittest.TestCase):
|
|
|
54
55
|
self.assertEqual(url, "https://pdfops.dev/api/fill-form")
|
|
55
56
|
self.assertEqual(headers["X-API-Key"], "pdfops_live_x")
|
|
56
57
|
self.assertEqual(headers["X-Pdfops-Client"], "py")
|
|
58
|
+
self.assertEqual(headers["X-Pdfops-Client-Version"], __version__)
|
|
57
59
|
self.assertEqual(headers["User-Agent"], f"pdfops-python/{__version__}")
|
|
58
60
|
|
|
59
61
|
def test_keyless_and_custom_tag_and_base_url(self):
|
|
@@ -114,6 +116,20 @@ class Endpoints(unittest.TestCase):
|
|
|
114
116
|
_, _, headers, body = t.calls[0]
|
|
115
117
|
self.assertEqual([p[0] for p in parts_of(body, headers["Content-Type"])], ["pdf", "fields"])
|
|
116
118
|
|
|
119
|
+
def test_password_forwarded_on_fill_inspect_merge(self):
|
|
120
|
+
for call, path in (
|
|
121
|
+
(lambda c: c.fill_form(PDF, {"a": "b"}, password="pw"), "/api/fill-form"),
|
|
122
|
+
(lambda c: c.merge([PDF, PDF], password="pw"), "/api/merge"),
|
|
123
|
+
(lambda c: c.inspect(PDF, password="pw"), "/api/inspect"),
|
|
124
|
+
):
|
|
125
|
+
t = FakeTransport(body=b"{}" if path == "/api/inspect" else PDF)
|
|
126
|
+
call(PdfOps(transport=t))
|
|
127
|
+
_, url, headers, body = t.calls[0]
|
|
128
|
+
self.assertTrue(url.endswith(path))
|
|
129
|
+
parts = parts_of(body, headers["Content-Type"])
|
|
130
|
+
self.assertEqual(parts[-1][0], "password")
|
|
131
|
+
self.assertEqual(parts[-1][3], b"pw")
|
|
132
|
+
|
|
117
133
|
def test_merge_order_and_minimum(self):
|
|
118
134
|
t = FakeTransport(body=PDF)
|
|
119
135
|
PdfOps(transport=t).merge([PDF, b"%PDF-2", b"%PDF-3"])
|
|
@@ -135,8 +151,12 @@ class Endpoints(unittest.TestCase):
|
|
|
135
151
|
self.assertEqual(json.loads(body), req)
|
|
136
152
|
|
|
137
153
|
def test_usage_requires_key_and_parses_json(self):
|
|
138
|
-
|
|
139
|
-
|
|
154
|
+
# Isolated from a real PDFOPS_API_KEY sitting in the ambient
|
|
155
|
+
# environment (e.g. this box's cron env), which would otherwise
|
|
156
|
+
# make the client think a key was supplied.
|
|
157
|
+
with mock.patch.dict(os.environ, {"PDFOPS_API_KEY": ""}):
|
|
158
|
+
with self.assertRaises(PdfOpsError) as cm:
|
|
159
|
+
PdfOps(transport=FakeTransport()).usage()
|
|
140
160
|
self.assertEqual(cm.exception.code, "missing_api_key")
|
|
141
161
|
t = FakeTransport(body=json.dumps({"tier": "free", "limit": 250, "used": 3, "remaining": 247}).encode())
|
|
142
162
|
u = PdfOps("k", transport=t).usage()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|