pdfops 0.1.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.
@@ -0,0 +1,4 @@
1
+ dist/
2
+ .venv/
3
+ __pycache__/
4
+ *.egg-info/
pdfops-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 PDFops (pdfops.dev)
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.
pdfops-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,120 @@
1
+ Metadata-Version: 2.5
2
+ Name: pdfops
3
+ Version: 0.1.0
4
+ Summary: Python client for the PDFops API: fill AcroForm PDFs, merge PDFs, inspect form fields, generate invoices. One HTTP call, no native deps, no headless browser.
5
+ Project-URL: Homepage, https://pdfops.dev
6
+ Project-URL: Documentation, https://pdfops.dev/docs
7
+ Project-URL: Fill a PDF in Python, https://pdfops.dev/fill-pdf-in-python
8
+ Project-URL: Source, https://github.com/pdfops/pdfops-python
9
+ Project-URL: Issues, https://github.com/pdfops/pdfops-python/issues
10
+ Author-email: PDFops <hello@pdfops.dev>
11
+ License-Expression: MIT
12
+ License-File: LICENSE
13
+ Keywords: acroform,fill-pdf,invoice,merge-pdf,pdf,pdf-api,pdf-forms,pypdf
14
+ Classifier: Development Status :: 4 - Beta
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3 :: Only
20
+ Classifier: Programming Language :: Python :: 3.9
21
+ Classifier: Programming Language :: Python :: 3.10
22
+ Classifier: Programming Language :: Python :: 3.11
23
+ Classifier: Programming Language :: Python :: 3.12
24
+ Classifier: Programming Language :: Python :: 3.13
25
+ Classifier: Topic :: Office/Business
26
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
27
+ Classifier: Typing :: Typed
28
+ Requires-Python: >=3.9
29
+ Description-Content-Type: text/markdown
30
+
31
+ # pdfops
32
+
33
+ Python client for the [PDFops API](https://pdfops.dev): **fill** AcroForm PDFs, **merge** PDFs, **inspect** form fields, and **generate invoices** with one HTTP call. No native dependencies, no headless browser, nothing to install but this package.
34
+
35
+ Built for the moment `pypdf` / `PyPDFForm` stop cooperating — appearance streams that never render, `NeedAppearances` flags, encrypted government blanks, checkboxes that will not tick — and for runtimes where a PDF toolchain does not fit: Lambda, Cloud Functions, Airflow tasks, notebooks.
36
+
37
+ ```bash
38
+ pip install pdfops
39
+ ```
40
+
41
+ Zero dependencies (standard-library `urllib`), Python 3.9+, fully typed.
42
+
43
+ ## Quick start
44
+
45
+ ```python
46
+ from pdfops import PdfOps
47
+
48
+ client = PdfOps(api_key="pdfops_live_...") # or PdfOps() for the keyless trial (100 req/IP/month)
49
+
50
+ # 1. Discover the field names — you cannot fill fields you cannot name.
51
+ info = client.inspect("blank-w9.pdf")
52
+ print(info["count"], "fields")
53
+ fields = info["fillTemplate"] # paste-ready {name: ""} dict
54
+
55
+ # 2. Fill, optionally flattening so the result is no longer editable.
56
+ fields["Name"] = "Ada Lovelace"
57
+ fields["Agree"] = "true" # checkboxes take "true" / "false"
58
+ pdf_bytes = client.fill_form("blank-w9.pdf", fields, flatten=True)
59
+ open("w9-filled.pdf", "wb").write(pdf_bytes)
60
+
61
+ # 3. Merge, in order.
62
+ packet = client.merge(["cover.pdf", pdf_bytes, "terms.pdf"])
63
+
64
+ # 4. Invoice from data — deterministic, safe to re-render from a webhook.
65
+ invoice = client.invoice({
66
+ "from": "Acme Ltd",
67
+ "to": {"name": "Ada Lovelace", "lines": ["12 Analytical Way", "London"]},
68
+ "items": [{"description": "Consulting", "quantity": 3, "unit_price": 120.0}],
69
+ "invoice_number": "2026-0042",
70
+ "date": "2026-09-05",
71
+ "currency": "GBP",
72
+ })
73
+ ```
74
+
75
+ PDF inputs may be `bytes`, a path (`str` / `pathlib.Path`), or a binary file object. Every PDF-producing call returns `bytes`.
76
+
77
+ ## API
78
+
79
+ | Method | Endpoint | Returns |
80
+ |---|---|---|
81
+ | `inspect(pdf)` | `POST /api/inspect` | `dict` — `count`, `hasXFA`, `fields[]`, `fillTemplate` |
82
+ | `fill_form(pdf, fields, *, flatten=False)` | `POST /api/fill-form` | filled PDF `bytes` |
83
+ | `merge([pdf, pdf, ...])` | `POST /api/merge` | merged PDF `bytes` |
84
+ | `invoice(request)` | `POST /api/invoice` | invoice PDF `bytes` |
85
+ | `usage()` | `GET /api/usage` | `dict` — tier, limit, used, remaining, resets_at |
86
+ | `signup(email)` | `POST /api/signup` | `None` — the key is emailed |
87
+
88
+ Full request/response schemas: [pdfops.dev/docs](https://pdfops.dev/docs) · [OpenAPI](https://pdfops.dev/openapi.json).
89
+
90
+ ## Errors
91
+
92
+ Every non-2xx response raises `PdfOpsError` carrying the API's stable error slug:
93
+
94
+ ```python
95
+ from pdfops import PdfOps, PdfOpsError
96
+
97
+ try:
98
+ client.fill_form("form.pdf", {"Nmae": "typo"})
99
+ except PdfOpsError as e:
100
+ print(e.status, e.code, e.details) # 422 unknown_field No field named Nmae
101
+ ```
102
+
103
+ Common codes: `unknown_field`, `exceeds_max_length`, `invalid_option`, `encrypted_pdf`, `rate_limited`, `invalid_api_key`.
104
+
105
+ ## Configuration
106
+
107
+ - `PdfOps(api_key=...)` or the `PDFOPS_API_KEY` environment variable. A free key (250 req/month, no card) is one field at [pdfops.dev/pricing](https://pdfops.dev/pricing).
108
+ - `client_tag="..."` sets the `X-Pdfops-Client` header for anonymous usage attribution when you embed this client in another tool. Defaults to `"py"`.
109
+ - `timeout=60.0` seconds per request; `base_url=` overrides the API origin for testing.
110
+ - `transport=` injects a `(method, url, headers, body) -> (status, headers, body)` callable — the test suite uses this; no network needed.
111
+
112
+ ## Why an API instead of a library
113
+
114
+ `pdf-lib` and `pypdf` are excellent when the PDF is well-behaved. PDFops exists for the other ones: it renders appearance streams server-side so every viewer shows the values, handles the encrypted-with-empty-password blanks governments publish, flattens deterministically, and is byte-for-byte reproducible — the same request yields the same file. The [compatibility registry](https://pdfops.dev/registry) records which public forms fill cleanly with which toolchain.
115
+
116
+ ## Links
117
+
118
+ [Fill a PDF in Python](https://pdfops.dev/fill-pdf-in-python) · [Docs](https://pdfops.dev/docs) · [MCP server for AI agents](https://www.npmjs.com/package/pdfops-mcp) · [TypeScript SDK](https://www.npmjs.com/package/pdfops-sdk) · [n8n node](https://www.npmjs.com/package/n8n-nodes-pdfops) · hello@pdfops.dev
119
+
120
+ MIT © PDFops
pdfops-0.1.0/README.md ADDED
@@ -0,0 +1,90 @@
1
+ # pdfops
2
+
3
+ Python client for the [PDFops API](https://pdfops.dev): **fill** AcroForm PDFs, **merge** PDFs, **inspect** form fields, and **generate invoices** with one HTTP call. No native dependencies, no headless browser, nothing to install but this package.
4
+
5
+ Built for the moment `pypdf` / `PyPDFForm` stop cooperating — appearance streams that never render, `NeedAppearances` flags, encrypted government blanks, checkboxes that will not tick — and for runtimes where a PDF toolchain does not fit: Lambda, Cloud Functions, Airflow tasks, notebooks.
6
+
7
+ ```bash
8
+ pip install pdfops
9
+ ```
10
+
11
+ Zero dependencies (standard-library `urllib`), Python 3.9+, fully typed.
12
+
13
+ ## Quick start
14
+
15
+ ```python
16
+ from pdfops import PdfOps
17
+
18
+ client = PdfOps(api_key="pdfops_live_...") # or PdfOps() for the keyless trial (100 req/IP/month)
19
+
20
+ # 1. Discover the field names — you cannot fill fields you cannot name.
21
+ info = client.inspect("blank-w9.pdf")
22
+ print(info["count"], "fields")
23
+ fields = info["fillTemplate"] # paste-ready {name: ""} dict
24
+
25
+ # 2. Fill, optionally flattening so the result is no longer editable.
26
+ fields["Name"] = "Ada Lovelace"
27
+ fields["Agree"] = "true" # checkboxes take "true" / "false"
28
+ pdf_bytes = client.fill_form("blank-w9.pdf", fields, flatten=True)
29
+ open("w9-filled.pdf", "wb").write(pdf_bytes)
30
+
31
+ # 3. Merge, in order.
32
+ packet = client.merge(["cover.pdf", pdf_bytes, "terms.pdf"])
33
+
34
+ # 4. Invoice from data — deterministic, safe to re-render from a webhook.
35
+ invoice = client.invoice({
36
+ "from": "Acme Ltd",
37
+ "to": {"name": "Ada Lovelace", "lines": ["12 Analytical Way", "London"]},
38
+ "items": [{"description": "Consulting", "quantity": 3, "unit_price": 120.0}],
39
+ "invoice_number": "2026-0042",
40
+ "date": "2026-09-05",
41
+ "currency": "GBP",
42
+ })
43
+ ```
44
+
45
+ PDF inputs may be `bytes`, a path (`str` / `pathlib.Path`), or a binary file object. Every PDF-producing call returns `bytes`.
46
+
47
+ ## API
48
+
49
+ | Method | Endpoint | Returns |
50
+ |---|---|---|
51
+ | `inspect(pdf)` | `POST /api/inspect` | `dict` — `count`, `hasXFA`, `fields[]`, `fillTemplate` |
52
+ | `fill_form(pdf, fields, *, flatten=False)` | `POST /api/fill-form` | filled PDF `bytes` |
53
+ | `merge([pdf, pdf, ...])` | `POST /api/merge` | merged PDF `bytes` |
54
+ | `invoice(request)` | `POST /api/invoice` | invoice PDF `bytes` |
55
+ | `usage()` | `GET /api/usage` | `dict` — tier, limit, used, remaining, resets_at |
56
+ | `signup(email)` | `POST /api/signup` | `None` — the key is emailed |
57
+
58
+ Full request/response schemas: [pdfops.dev/docs](https://pdfops.dev/docs) · [OpenAPI](https://pdfops.dev/openapi.json).
59
+
60
+ ## Errors
61
+
62
+ Every non-2xx response raises `PdfOpsError` carrying the API's stable error slug:
63
+
64
+ ```python
65
+ from pdfops import PdfOps, PdfOpsError
66
+
67
+ try:
68
+ client.fill_form("form.pdf", {"Nmae": "typo"})
69
+ except PdfOpsError as e:
70
+ print(e.status, e.code, e.details) # 422 unknown_field No field named Nmae
71
+ ```
72
+
73
+ Common codes: `unknown_field`, `exceeds_max_length`, `invalid_option`, `encrypted_pdf`, `rate_limited`, `invalid_api_key`.
74
+
75
+ ## Configuration
76
+
77
+ - `PdfOps(api_key=...)` or the `PDFOPS_API_KEY` environment variable. A free key (250 req/month, no card) is one field at [pdfops.dev/pricing](https://pdfops.dev/pricing).
78
+ - `client_tag="..."` sets the `X-Pdfops-Client` header for anonymous usage attribution when you embed this client in another tool. Defaults to `"py"`.
79
+ - `timeout=60.0` seconds per request; `base_url=` overrides the API origin for testing.
80
+ - `transport=` injects a `(method, url, headers, body) -> (status, headers, body)` callable — the test suite uses this; no network needed.
81
+
82
+ ## Why an API instead of a library
83
+
84
+ `pdf-lib` and `pypdf` are excellent when the PDF is well-behaved. PDFops exists for the other ones: it renders appearance streams server-side so every viewer shows the values, handles the encrypted-with-empty-password blanks governments publish, flattens deterministically, and is byte-for-byte reproducible — the same request yields the same file. The [compatibility registry](https://pdfops.dev/registry) records which public forms fill cleanly with which toolchain.
85
+
86
+ ## Links
87
+
88
+ [Fill a PDF in Python](https://pdfops.dev/fill-pdf-in-python) · [Docs](https://pdfops.dev/docs) · [MCP server for AI agents](https://www.npmjs.com/package/pdfops-mcp) · [TypeScript SDK](https://www.npmjs.com/package/pdfops-sdk) · [n8n node](https://www.npmjs.com/package/n8n-nodes-pdfops) · hello@pdfops.dev
89
+
90
+ MIT © PDFops
@@ -0,0 +1,43 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.25"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "pdfops"
7
+ version = "0.1.0"
8
+ description = "Python client for the PDFops API: fill AcroForm PDFs, merge PDFs, inspect form fields, generate invoices. One HTTP call, no native deps, no headless browser."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.9"
12
+ authors = [{ name = "PDFops", email = "hello@pdfops.dev" }]
13
+ keywords = ["pdf", "acroform", "fill-pdf", "merge-pdf", "pdf-forms", "invoice", "pypdf", "pdf-api"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Operating System :: OS Independent",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3 :: Only",
21
+ "Programming Language :: Python :: 3.9",
22
+ "Programming Language :: Python :: 3.10",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Programming Language :: Python :: 3.13",
26
+ "Topic :: Office/Business",
27
+ "Topic :: Software Development :: Libraries :: Python Modules",
28
+ "Typing :: Typed",
29
+ ]
30
+ dependencies = []
31
+
32
+ [project.urls]
33
+ Homepage = "https://pdfops.dev"
34
+ Documentation = "https://pdfops.dev/docs"
35
+ "Fill a PDF in Python" = "https://pdfops.dev/fill-pdf-in-python"
36
+ Source = "https://github.com/pdfops/pdfops-python"
37
+ Issues = "https://github.com/pdfops/pdfops-python/issues"
38
+
39
+ [tool.hatch.build.targets.wheel]
40
+ packages = ["src/pdfops"]
41
+
42
+ [tool.hatch.build.targets.sdist]
43
+ include = ["src/pdfops", "README.md", "LICENSE", "pyproject.toml", "tests"]
@@ -0,0 +1,207 @@
1
+ """pdfops — Python client for the PDFops API (https://pdfops.dev).
2
+
3
+ Zero dependencies: standard-library ``urllib`` only, so it runs anywhere
4
+ Python does — Lambda, Cloud Functions, Airflow tasks, a cron, a notebook.
5
+ Errors carry the API's structured ``{"error", "details"}`` body via
6
+ :class:`PdfOpsError`.
7
+
8
+ from pdfops import PdfOps
9
+
10
+ client = PdfOps(api_key="pdfops_live_...") # or PdfOps() for the keyless trial
11
+ fields = client.inspect("blank.pdf")["fillTemplate"]
12
+ fields["Name"] = "Ada Lovelace"
13
+ open("filled.pdf", "wb").write(client.fill_form("blank.pdf", fields, flatten=True))
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import os
20
+ import uuid
21
+ from pathlib import PurePath
22
+ from typing import IO, Any, Callable, Dict, List, Mapping, Optional, Sequence, Tuple, Union
23
+ from urllib import error as urlerror
24
+ from urllib import request as urlrequest
25
+
26
+ __all__ = ["PdfOps", "PdfOpsError", "PdfInput", "__version__"]
27
+ __version__ = "0.1.0"
28
+
29
+ #: A PDF given as raw bytes, a filesystem path, or a binary file object.
30
+ PdfInput = Union[bytes, bytearray, memoryview, str, "os.PathLike[str]", IO[bytes]]
31
+
32
+ #: ``transport(method, url, headers, body) -> (status, headers, body)``.
33
+ #: Injected in tests; the default speaks HTTP through urllib.
34
+ Transport = Callable[[str, str, Mapping[str, str], Optional[bytes]], Tuple[int, Mapping[str, str], bytes]]
35
+
36
+ DEFAULT_BASE_URL = "https://pdfops.dev"
37
+ USER_AGENT = f"pdfops-python/{__version__}"
38
+
39
+
40
+ class PdfOpsError(Exception):
41
+ """Structured API error. ``code`` is the API's stable ``error`` slug."""
42
+
43
+ def __init__(self, status: int, code: str, details: str) -> None:
44
+ super().__init__(f"{code}: {details}")
45
+ self.status = status
46
+ self.code = code
47
+ self.details = details
48
+
49
+
50
+ def _read_pdf(pdf: PdfInput) -> bytes:
51
+ if isinstance(pdf, (bytes, bytearray, memoryview)):
52
+ return bytes(pdf)
53
+ if isinstance(pdf, (str, PurePath)) or hasattr(pdf, "__fspath__"):
54
+ with open(os.fspath(pdf), "rb") as fh: # type: ignore[arg-type]
55
+ return fh.read()
56
+ if hasattr(pdf, "read"):
57
+ data = pdf.read()
58
+ if isinstance(data, str):
59
+ raise TypeError("PDF file objects must be opened in binary mode ('rb')")
60
+ return bytes(data)
61
+ raise TypeError(f"unsupported PDF input type: {type(pdf).__name__}")
62
+
63
+
64
+ def _multipart(parts: Sequence[Tuple[str, Optional[str], bytes, str]]) -> Tuple[str, bytes]:
65
+ """Encode ``(name, filename, data, content_type)`` parts; returns (content-type, body)."""
66
+ boundary = f"----pdfops-{uuid.uuid4().hex}"
67
+ out = bytearray()
68
+ for name, filename, data, ctype in parts:
69
+ out += f"--{boundary}\r\n".encode()
70
+ disp = f'form-data; name="{name}"'
71
+ if filename is not None:
72
+ disp += f'; filename="{filename}"'
73
+ out += f"Content-Disposition: {disp}\r\n".encode()
74
+ out += f"Content-Type: {ctype}\r\n\r\n".encode()
75
+ out += data
76
+ out += b"\r\n"
77
+ out += f"--{boundary}--\r\n".encode()
78
+ return f"multipart/form-data; boundary={boundary}", bytes(out)
79
+
80
+
81
+ def _urllib_transport(timeout: float) -> Transport:
82
+ def send(method: str, url: str, headers: Mapping[str, str], body: Optional[bytes]) -> Tuple[int, Mapping[str, str], bytes]:
83
+ req = urlrequest.Request(url, data=body, method=method, headers=dict(headers))
84
+ try:
85
+ with urlrequest.urlopen(req, timeout=timeout) as res: # noqa: S310 — fixed https origin
86
+ return res.status, dict(res.headers.items()), res.read()
87
+ except urlerror.HTTPError as e:
88
+ return e.code, dict(e.headers.items()), e.read()
89
+
90
+ return send
91
+
92
+
93
+ class PdfOps:
94
+ """Client for the PDFops API.
95
+
96
+ :param api_key: key from https://pdfops.dev/pricing (free tier: 250 req/mo).
97
+ Omit for keyless trial calls (100 req/IP/mo).
98
+ :param base_url: API origin override (testing).
99
+ :param client_tag: integration identifier sent as ``X-Pdfops-Client`` for
100
+ anonymous usage attribution. Defaults to ``"py"``; set it when embedding
101
+ this client inside another tool (e.g. ``"airflow"``).
102
+ :param timeout: per-request timeout in seconds.
103
+ :param transport: custom transport (tests / instrumentation).
104
+ """
105
+
106
+ def __init__(
107
+ self,
108
+ api_key: Optional[str] = None,
109
+ *,
110
+ base_url: str = DEFAULT_BASE_URL,
111
+ client_tag: Optional[str] = "py",
112
+ timeout: float = 60.0,
113
+ transport: Optional[Transport] = None,
114
+ ) -> None:
115
+ self.api_key = api_key or os.environ.get("PDFOPS_API_KEY") or None
116
+ self.base_url = base_url.rstrip("/")
117
+ self.client_tag = client_tag
118
+ self._send = transport or _urllib_transport(timeout)
119
+
120
+ # ── plumbing ────────────────────────────────────────────────────────────
121
+
122
+ def _headers(self, extra: Optional[Mapping[str, str]] = None) -> Dict[str, str]:
123
+ h: Dict[str, str] = {"User-Agent": USER_AGENT, "Accept": "application/pdf, application/json"}
124
+ if self.api_key:
125
+ h["X-API-Key"] = self.api_key
126
+ if self.client_tag:
127
+ h["X-Pdfops-Client"] = self.client_tag
128
+ if extra:
129
+ h.update(extra)
130
+ return h
131
+
132
+ def _request(self, method: str, path: str, headers: Mapping[str, str], body: Optional[bytes]) -> bytes:
133
+ status, _res_headers, data = self._send(method, f"{self.base_url}{path}", headers, body)
134
+ if 200 <= status < 300:
135
+ return data
136
+ code, details = f"http_{status}", (data.decode("utf-8", "replace") or f"HTTP {status}")
137
+ try:
138
+ parsed = json.loads(data.decode("utf-8"))
139
+ if isinstance(parsed, dict):
140
+ code = str(parsed.get("error") or code)
141
+ details = str(parsed.get("details") or details)
142
+ except ValueError:
143
+ pass # non-JSON error body — keep the status fallback
144
+ raise PdfOpsError(status, code, details)
145
+
146
+ def _post_pdf(self, path: str, parts: Sequence[Tuple[str, Optional[str], bytes, str]]) -> bytes:
147
+ ctype, body = _multipart(parts)
148
+ return self._request("POST", path, self._headers({"Content-Type": ctype}), body)
149
+
150
+ # ── API ─────────────────────────────────────────────────────────────────
151
+
152
+ def inspect(self, pdf: PdfInput) -> Dict[str, Any]:
153
+ """List a PDF's AcroForm fields plus a paste-ready ``fillTemplate``.
154
+
155
+ Returns ``{"count", "hasXFA", "fields": [...], "fillTemplate": {...}}``.
156
+ A PDF with no form returns ``count == 0`` — not an error.
157
+ """
158
+ data = self._post_pdf("/api/inspect", [("pdf", "input.pdf", _read_pdf(pdf), "application/pdf")])
159
+ return json.loads(data.decode("utf-8"))
160
+
161
+ def fill_form(self, pdf: PdfInput, fields: Mapping[str, str], *, flatten: bool = False) -> bytes:
162
+ """Fill AcroForm fields; returns the filled PDF bytes.
163
+
164
+ Field names must exist in the PDF — use :meth:`inspect` to discover
165
+ them. Checkbox values are the strings ``"true"``/``"false"``; choice
166
+ fields take one of their options. ``flatten=True`` bakes values into
167
+ the page content and drops the form so fields are no longer editable.
168
+ """
169
+ parts: List[Tuple[str, Optional[str], bytes, str]] = [
170
+ ("pdf", "input.pdf", _read_pdf(pdf), "application/pdf"),
171
+ ("fields", None, json.dumps(dict(fields)).encode("utf-8"), "application/json"),
172
+ ]
173
+ if flatten:
174
+ parts.append(("flatten", None, b"true", "text/plain"))
175
+ return self._post_pdf("/api/fill-form", parts)
176
+
177
+ def merge(self, pdfs: Sequence[PdfInput]) -> bytes:
178
+ """Merge two or more PDFs, in order; returns the combined PDF bytes."""
179
+ if len(pdfs) < 2:
180
+ raise PdfOpsError(400, "too_few_pdfs", "merge needs at least 2 PDFs")
181
+ parts = [("pdf", f"part-{i}.pdf", _read_pdf(p), "application/pdf") for i, p in enumerate(pdfs)]
182
+ return self._post_pdf("/api/merge", parts)
183
+
184
+ def invoice(self, request: Mapping[str, Any]) -> bytes:
185
+ """Generate a complete invoice PDF from structured data.
186
+
187
+ Deterministic: the same request produces byte-identical output, so it
188
+ is safe to re-render from webhooks and retries. Keys: ``from``, ``to``
189
+ (string or ``{"name", "lines"}``), ``items`` (``description``,
190
+ ``unit_price``, optional ``quantity``), optional ``invoice_number``,
191
+ ``date``, ``due``, ``currency``, ``tax_rate``, ``notes``.
192
+ """
193
+ body = json.dumps(dict(request)).encode("utf-8")
194
+ return self._request("POST", "/api/invoice", self._headers({"Content-Type": "application/json"}), body)
195
+
196
+ def usage(self) -> Dict[str, Any]:
197
+ """Quota state for the configured key: tier, limit, used, remaining, period, resets_at."""
198
+ if not self.api_key:
199
+ raise PdfOpsError(401, "missing_api_key", "usage() requires an api_key")
200
+ data = self._request("GET", "/api/usage", self._headers(), None)
201
+ return json.loads(data.decode("utf-8"))
202
+
203
+ def signup(self, email: str) -> None:
204
+ """Request a free API key (250 req/mo). The key is emailed, never returned."""
205
+ body = json.dumps({"email": email}).encode("utf-8")
206
+ headers = {"User-Agent": USER_AGENT, "Content-Type": "application/json"}
207
+ self._request("POST", "/api/signup", headers, body)
File without changes
File without changes
@@ -0,0 +1,177 @@
1
+ """Offline tests for the pdfops client — a fake transport records every request.
2
+
3
+ Run: python3 -m unittest discover -s python/tests -t python
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import io
9
+ import json
10
+ import os
11
+ import re
12
+ import sys
13
+ import tempfile
14
+ import unittest
15
+ from typing import List, Optional, Tuple
16
+
17
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
18
+
19
+ from pdfops import PdfOps, PdfOpsError, __version__ # noqa: E402
20
+
21
+ PDF = b"%PDF-1.4\n%fake\n"
22
+
23
+
24
+ class FakeTransport:
25
+ def __init__(self, status: int = 200, body: bytes = b"", headers: Optional[dict] = None) -> None:
26
+ self.status, self.body, self.headers = status, body, headers or {}
27
+ self.calls: List[Tuple[str, str, dict, Optional[bytes]]] = []
28
+
29
+ def __call__(self, method, url, headers, body):
30
+ self.calls.append((method, url, dict(headers), body))
31
+ return self.status, self.headers, self.body
32
+
33
+
34
+ def parts_of(body: bytes, content_type: str):
35
+ """Decode a multipart body into [(name, filename, ctype, data)]."""
36
+ boundary = re.search(r"boundary=(\S+)", content_type).group(1).encode()
37
+ out = []
38
+ for chunk in body.split(b"--" + boundary)[1:]:
39
+ if chunk.startswith(b"--"):
40
+ break
41
+ head, data = chunk.lstrip(b"\r\n").split(b"\r\n\r\n", 1)
42
+ name = re.search(rb'name="([^"]+)"', head).group(1).decode()
43
+ fn = re.search(rb'filename="([^"]+)"', head)
44
+ ctype = re.search(rb"Content-Type: (\S+)", head).group(1).decode()
45
+ out.append((name, fn.group(1).decode() if fn else None, ctype, data[:-2]))
46
+ return out
47
+
48
+
49
+ class HeadersAndInputs(unittest.TestCase):
50
+ def test_key_and_client_tag_headers(self):
51
+ t = FakeTransport(body=PDF)
52
+ PdfOps("pdfops_live_x", transport=t).fill_form(PDF, {"a": "b"})
53
+ _, url, headers, _ = t.calls[0]
54
+ self.assertEqual(url, "https://pdfops.dev/api/fill-form")
55
+ self.assertEqual(headers["X-API-Key"], "pdfops_live_x")
56
+ self.assertEqual(headers["X-Pdfops-Client"], "py")
57
+ self.assertEqual(headers["User-Agent"], f"pdfops-python/{__version__}")
58
+
59
+ def test_keyless_and_custom_tag_and_base_url(self):
60
+ t = FakeTransport(body=b"{}")
61
+ PdfOps(base_url="http://localhost:3000/", client_tag="airflow", transport=t).inspect(PDF)
62
+ _, url, headers, _ = t.calls[0]
63
+ self.assertEqual(url, "http://localhost:3000/api/inspect")
64
+ self.assertNotIn("X-API-Key", headers)
65
+ self.assertEqual(headers["X-Pdfops-Client"], "airflow")
66
+
67
+ def test_env_key_is_picked_up(self):
68
+ os.environ["PDFOPS_API_KEY"] = "pdfops_live_env"
69
+ try:
70
+ t = FakeTransport(body=b"{}")
71
+ PdfOps(transport=t).inspect(PDF)
72
+ self.assertEqual(t.calls[0][2]["X-API-Key"], "pdfops_live_env")
73
+ finally:
74
+ del os.environ["PDFOPS_API_KEY"]
75
+
76
+ def test_pdf_input_forms(self):
77
+ t = FakeTransport(body=b"{}")
78
+ c = PdfOps(transport=t)
79
+ with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as fh:
80
+ fh.write(PDF)
81
+ path = fh.name
82
+ try:
83
+ c.inspect(PDF) # bytes
84
+ c.inspect(bytearray(PDF)) # bytearray
85
+ c.inspect(path) # str path
86
+ from pathlib import Path
87
+ c.inspect(Path(path)) # PathLike
88
+ c.inspect(io.BytesIO(PDF)) # binary file object
89
+ finally:
90
+ os.unlink(path)
91
+ for _, _, headers, body in t.calls:
92
+ (name, fn, ctype, data), = parts_of(body, headers["Content-Type"])
93
+ self.assertEqual((name, fn, ctype, data), ("pdf", "input.pdf", "application/pdf", PDF))
94
+
95
+ def test_text_mode_file_rejected(self):
96
+ with self.assertRaises(TypeError):
97
+ PdfOps(transport=FakeTransport()).inspect(io.StringIO("%PDF"))
98
+
99
+
100
+ class Endpoints(unittest.TestCase):
101
+ def test_fill_form_parts_and_flatten(self):
102
+ t = FakeTransport(body=PDF)
103
+ out = PdfOps(transport=t).fill_form(PDF, {"Name": "Ada", "Agree": "true"}, flatten=True)
104
+ self.assertEqual(out, PDF)
105
+ _, _, headers, body = t.calls[0]
106
+ parts = parts_of(body, headers["Content-Type"])
107
+ self.assertEqual([p[0] for p in parts], ["pdf", "fields", "flatten"])
108
+ self.assertEqual(json.loads(parts[1][3]), {"Name": "Ada", "Agree": "true"})
109
+ self.assertEqual(parts[2][3], b"true")
110
+
111
+ def test_fill_form_without_flatten_has_no_flatten_part(self):
112
+ t = FakeTransport(body=PDF)
113
+ PdfOps(transport=t).fill_form(PDF, {"a": "b"})
114
+ _, _, headers, body = t.calls[0]
115
+ self.assertEqual([p[0] for p in parts_of(body, headers["Content-Type"])], ["pdf", "fields"])
116
+
117
+ def test_merge_order_and_minimum(self):
118
+ t = FakeTransport(body=PDF)
119
+ PdfOps(transport=t).merge([PDF, b"%PDF-2", b"%PDF-3"])
120
+ _, url, headers, body = t.calls[0]
121
+ self.assertTrue(url.endswith("/api/merge"))
122
+ self.assertEqual([p[1] for p in parts_of(body, headers["Content-Type"])], ["part-0.pdf", "part-1.pdf", "part-2.pdf"])
123
+ with self.assertRaises(PdfOpsError) as cm:
124
+ PdfOps(transport=t).merge([PDF])
125
+ self.assertEqual(cm.exception.code, "too_few_pdfs")
126
+ self.assertEqual(len(t.calls), 1, "no request is sent for an invalid merge")
127
+
128
+ def test_invoice_is_json(self):
129
+ t = FakeTransport(body=PDF)
130
+ req = {"from": "PDFops", "to": "Ada", "items": [{"description": "x", "unit_price": 1}], "invoice_number": "1"}
131
+ self.assertEqual(PdfOps(transport=t).invoice(req), PDF)
132
+ method, url, headers, body = t.calls[0]
133
+ self.assertEqual((method, url), ("POST", "https://pdfops.dev/api/invoice"))
134
+ self.assertEqual(headers["Content-Type"], "application/json")
135
+ self.assertEqual(json.loads(body), req)
136
+
137
+ def test_usage_requires_key_and_parses_json(self):
138
+ with self.assertRaises(PdfOpsError) as cm:
139
+ PdfOps(transport=FakeTransport()).usage()
140
+ self.assertEqual(cm.exception.code, "missing_api_key")
141
+ t = FakeTransport(body=json.dumps({"tier": "free", "limit": 250, "used": 3, "remaining": 247}).encode())
142
+ u = PdfOps("k", transport=t).usage()
143
+ self.assertEqual((t.calls[0][0], u["remaining"]), ("GET", 247))
144
+
145
+ def test_signup_sends_email_without_key_headers(self):
146
+ t = FakeTransport(status=204)
147
+ PdfOps("k", transport=t).signup("dev@example.com")
148
+ method, url, headers, body = t.calls[0]
149
+ self.assertEqual((method, url), ("POST", "https://pdfops.dev/api/signup"))
150
+ self.assertNotIn("X-API-Key", headers)
151
+ self.assertEqual(json.loads(body), {"email": "dev@example.com"})
152
+
153
+
154
+ class Errors(unittest.TestCase):
155
+ def test_structured_api_error(self):
156
+ t = FakeTransport(status=422, body=json.dumps({"error": "unknown_field", "details": "No field named Nmae"}).encode())
157
+ with self.assertRaises(PdfOpsError) as cm:
158
+ PdfOps(transport=t).fill_form(PDF, {"Nmae": "x"})
159
+ e = cm.exception
160
+ self.assertEqual((e.status, e.code, e.details), (422, "unknown_field", "No field named Nmae"))
161
+ self.assertEqual(str(e), "unknown_field: No field named Nmae")
162
+
163
+ def test_non_json_error_falls_back_to_status(self):
164
+ t = FakeTransport(status=502, body=b"Bad Gateway")
165
+ with self.assertRaises(PdfOpsError) as cm:
166
+ PdfOps(transport=t).inspect(PDF)
167
+ self.assertEqual((cm.exception.status, cm.exception.code, cm.exception.details), (502, "http_502", "Bad Gateway"))
168
+
169
+ def test_rate_limit_error_code_surfaces(self):
170
+ t = FakeTransport(status=429, body=json.dumps({"error": "rate_limited", "details": "100/IP/mo exceeded"}).encode())
171
+ with self.assertRaises(PdfOpsError) as cm:
172
+ PdfOps(transport=t).merge([PDF, PDF])
173
+ self.assertEqual(cm.exception.code, "rate_limited")
174
+
175
+
176
+ if __name__ == "__main__":
177
+ unittest.main()