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.
- pdfops-0.1.0/.gitignore +4 -0
- pdfops-0.1.0/LICENSE +21 -0
- pdfops-0.1.0/PKG-INFO +120 -0
- pdfops-0.1.0/README.md +90 -0
- pdfops-0.1.0/pyproject.toml +43 -0
- pdfops-0.1.0/src/pdfops/__init__.py +207 -0
- pdfops-0.1.0/src/pdfops/py.typed +0 -0
- pdfops-0.1.0/tests/__init__.py +0 -0
- pdfops-0.1.0/tests/test_client.py +177 -0
pdfops-0.1.0/.gitignore
ADDED
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()
|