pdfops 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.
pdfops/__init__.py ADDED
@@ -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)
pdfops/py.typed ADDED
File without changes
@@ -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
@@ -0,0 +1,6 @@
1
+ pdfops/__init__.py,sha256=AddfR4yziYcEel-w-t-sXbJVGMAR06dpvWuNjhYzTYY,9592
2
+ pdfops/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ pdfops-0.1.0.dist-info/METADATA,sha256=-8osmGUC78NtU9b8Kbmef1aaR-b0hseXI8xlyYMOTgg,5859
4
+ pdfops-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
5
+ pdfops-0.1.0.dist-info/licenses/LICENSE,sha256=XGtTUurHEstLRQPCVmw_mBkQsMx8lvABFO0l6aNHWXM,1076
6
+ pdfops-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.