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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pdfops
3
- Version: 0.2.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.2.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.2.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
- data = self._post_pdf("/api/inspect", [("pdf", "input.pdf", _read_pdf(pdf), "application/pdf")])
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(self, pdf: PdfInput, fields: Mapping[str, str], *, flatten: bool = False) -> bytes:
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
- with self.assertRaises(PdfOpsError) as cm:
139
- PdfOps(transport=FakeTransport()).usage()
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