formepdf 0.7.9__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.
- formepdf-0.7.9/.gitignore +3 -0
- formepdf-0.7.9/PKG-INFO +118 -0
- formepdf-0.7.9/README.md +92 -0
- formepdf-0.7.9/build_wasm.sh +21 -0
- formepdf-0.7.9/forme/__init__.py +40 -0
- formepdf-0.7.9/forme/client.py +151 -0
- formepdf-0.7.9/forme/py.typed +0 -0
- formepdf-0.7.9/forme/templates.py +1005 -0
- formepdf-0.7.9/forme/wasm.py +147 -0
- formepdf-0.7.9/pyproject.toml +40 -0
- formepdf-0.7.9/tests/__init__.py +0 -0
- formepdf-0.7.9/tests/test_client.py +318 -0
- formepdf-0.7.9/tests/test_templates.py +367 -0
- formepdf-0.7.9/tests/test_wasm.py +112 -0
formepdf-0.7.9/PKG-INFO
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: formepdf
|
|
3
|
+
Version: 0.7.9
|
|
4
|
+
Summary: Python SDK for the Forme hosted PDF API
|
|
5
|
+
Project-URL: Homepage, https://formepdf.com
|
|
6
|
+
Project-URL: Documentation, https://docs.formepdf.com
|
|
7
|
+
Project-URL: Repository, https://github.com/formepdf/forme
|
|
8
|
+
Author-email: Dan Molitor <danmolitor91@gmail.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
Keywords: api,forme,pdf,sdk
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.8
|
|
23
|
+
Provides-Extra: local
|
|
24
|
+
Requires-Dist: wasmtime>=20.0; extra == 'local'
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# forme
|
|
28
|
+
|
|
29
|
+
Python SDK for the [Forme](https://formepdf.com) hosted PDF API.
|
|
30
|
+
|
|
31
|
+
## Installation
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install forme
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Quick Start
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from forme import Forme
|
|
41
|
+
|
|
42
|
+
client = Forme("forme_sk_...")
|
|
43
|
+
|
|
44
|
+
# Render a template to PDF bytes
|
|
45
|
+
pdf = client.render("invoice", {"customer": "Acme", "total": 100})
|
|
46
|
+
|
|
47
|
+
with open("invoice.pdf", "wb") as f:
|
|
48
|
+
f.write(pdf)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## API Reference
|
|
52
|
+
|
|
53
|
+
### `Forme(api_key, base_url="https://api.formepdf.com")`
|
|
54
|
+
|
|
55
|
+
Create a client instance.
|
|
56
|
+
|
|
57
|
+
### `client.render(slug, data=None, *, s3=None)`
|
|
58
|
+
|
|
59
|
+
Render a template synchronously. Returns `bytes` (PDF), or a `dict` with `{"url": "..."}` when `s3` is provided.
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
# Direct PDF bytes
|
|
63
|
+
pdf = client.render("invoice", {"customer": "Acme"})
|
|
64
|
+
|
|
65
|
+
# Upload to S3
|
|
66
|
+
result = client.render("invoice", {"customer": "Acme"}, s3={
|
|
67
|
+
"bucket": "my-bucket",
|
|
68
|
+
"key": "invoices/001.pdf",
|
|
69
|
+
"accessKeyId": "AK...",
|
|
70
|
+
"secretAccessKey": "SK...",
|
|
71
|
+
})
|
|
72
|
+
print(result["url"])
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### `client.render_async(slug, data=None, *, webhook_url=None)`
|
|
76
|
+
|
|
77
|
+
Start an asynchronous render job. Returns `{"jobId": "...", "status": "pending"}`.
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
job = client.render_async("report", data, webhook_url="https://example.com/hook")
|
|
81
|
+
print(job["jobId"])
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### `client.get_job(job_id)`
|
|
85
|
+
|
|
86
|
+
Poll the status of an async job.
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
result = client.get_job("job-123")
|
|
90
|
+
if result["status"] == "complete":
|
|
91
|
+
pdf_b64 = result["pdfBase64"]
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### `client.extract(pdf_bytes)`
|
|
95
|
+
|
|
96
|
+
Extract embedded data from a PDF. Returns the data dict, or `None` if none is embedded.
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
data = client.extract(pdf_bytes)
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Error Handling
|
|
103
|
+
|
|
104
|
+
All methods raise `FormeError` on non-2xx responses:
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
from forme import Forme, FormeError
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
pdf = client.render("invoice", data)
|
|
111
|
+
except FormeError as e:
|
|
112
|
+
print(f"Error {e.status}: {e.message}")
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Requirements
|
|
116
|
+
|
|
117
|
+
- Python 3.8+
|
|
118
|
+
- No dependencies (stdlib only)
|
formepdf-0.7.9/README.md
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# forme
|
|
2
|
+
|
|
3
|
+
Python SDK for the [Forme](https://formepdf.com) hosted PDF API.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install forme
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from forme import Forme
|
|
15
|
+
|
|
16
|
+
client = Forme("forme_sk_...")
|
|
17
|
+
|
|
18
|
+
# Render a template to PDF bytes
|
|
19
|
+
pdf = client.render("invoice", {"customer": "Acme", "total": 100})
|
|
20
|
+
|
|
21
|
+
with open("invoice.pdf", "wb") as f:
|
|
22
|
+
f.write(pdf)
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## API Reference
|
|
26
|
+
|
|
27
|
+
### `Forme(api_key, base_url="https://api.formepdf.com")`
|
|
28
|
+
|
|
29
|
+
Create a client instance.
|
|
30
|
+
|
|
31
|
+
### `client.render(slug, data=None, *, s3=None)`
|
|
32
|
+
|
|
33
|
+
Render a template synchronously. Returns `bytes` (PDF), or a `dict` with `{"url": "..."}` when `s3` is provided.
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
# Direct PDF bytes
|
|
37
|
+
pdf = client.render("invoice", {"customer": "Acme"})
|
|
38
|
+
|
|
39
|
+
# Upload to S3
|
|
40
|
+
result = client.render("invoice", {"customer": "Acme"}, s3={
|
|
41
|
+
"bucket": "my-bucket",
|
|
42
|
+
"key": "invoices/001.pdf",
|
|
43
|
+
"accessKeyId": "AK...",
|
|
44
|
+
"secretAccessKey": "SK...",
|
|
45
|
+
})
|
|
46
|
+
print(result["url"])
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### `client.render_async(slug, data=None, *, webhook_url=None)`
|
|
50
|
+
|
|
51
|
+
Start an asynchronous render job. Returns `{"jobId": "...", "status": "pending"}`.
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
job = client.render_async("report", data, webhook_url="https://example.com/hook")
|
|
55
|
+
print(job["jobId"])
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### `client.get_job(job_id)`
|
|
59
|
+
|
|
60
|
+
Poll the status of an async job.
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
result = client.get_job("job-123")
|
|
64
|
+
if result["status"] == "complete":
|
|
65
|
+
pdf_b64 = result["pdfBase64"]
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### `client.extract(pdf_bytes)`
|
|
69
|
+
|
|
70
|
+
Extract embedded data from a PDF. Returns the data dict, or `None` if none is embedded.
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
data = client.extract(pdf_bytes)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Error Handling
|
|
77
|
+
|
|
78
|
+
All methods raise `FormeError` on non-2xx responses:
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
from forme import Forme, FormeError
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
pdf = client.render("invoice", data)
|
|
85
|
+
except FormeError as e:
|
|
86
|
+
print(f"Error {e.status}: {e.message}")
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Requirements
|
|
90
|
+
|
|
91
|
+
- Python 3.8+
|
|
92
|
+
- No dependencies (stdlib only)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Build the Forme engine as a WASI WASM module for use with Python wasmtime.
|
|
3
|
+
# Output: forme/forme.wasm
|
|
4
|
+
set -euo pipefail
|
|
5
|
+
|
|
6
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
7
|
+
ENGINE_DIR="$SCRIPT_DIR/../../engine"
|
|
8
|
+
|
|
9
|
+
echo "Building Forme WASM (wasm32-wasip1, release)..."
|
|
10
|
+
cargo build \
|
|
11
|
+
--manifest-path "$ENGINE_DIR/Cargo.toml" \
|
|
12
|
+
--lib \
|
|
13
|
+
--target wasm32-wasip1 \
|
|
14
|
+
--release \
|
|
15
|
+
--features wasm-raw
|
|
16
|
+
|
|
17
|
+
WASM_SRC="$ENGINE_DIR/target/wasm32-wasip1/release/forme.wasm"
|
|
18
|
+
WASM_DST="$SCRIPT_DIR/forme/forme.wasm"
|
|
19
|
+
|
|
20
|
+
cp "$WASM_SRC" "$WASM_DST"
|
|
21
|
+
echo "Copied to $WASM_DST ($(du -h "$WASM_DST" | cut -f1))"
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Forme Python SDK — client for the Forme hosted PDF API and local rendering."""
|
|
2
|
+
|
|
3
|
+
from .client import Forme, FormeError
|
|
4
|
+
from .templates import (
|
|
5
|
+
Document,
|
|
6
|
+
Page,
|
|
7
|
+
View,
|
|
8
|
+
Text,
|
|
9
|
+
Image,
|
|
10
|
+
Table,
|
|
11
|
+
Row,
|
|
12
|
+
Cell,
|
|
13
|
+
Svg,
|
|
14
|
+
QrCode,
|
|
15
|
+
Barcode,
|
|
16
|
+
PageBreak,
|
|
17
|
+
Fixed,
|
|
18
|
+
Watermark,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
# API client
|
|
23
|
+
"Forme",
|
|
24
|
+
"FormeError",
|
|
25
|
+
# Template components
|
|
26
|
+
"Document",
|
|
27
|
+
"Page",
|
|
28
|
+
"View",
|
|
29
|
+
"Text",
|
|
30
|
+
"Image",
|
|
31
|
+
"Table",
|
|
32
|
+
"Row",
|
|
33
|
+
"Cell",
|
|
34
|
+
"Svg",
|
|
35
|
+
"QrCode",
|
|
36
|
+
"Barcode",
|
|
37
|
+
"PageBreak",
|
|
38
|
+
"Fixed",
|
|
39
|
+
"Watermark",
|
|
40
|
+
]
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Forme API client. Zero dependencies — uses stdlib urllib + json."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import urllib.error
|
|
5
|
+
import urllib.request
|
|
6
|
+
from typing import Any, Dict, Optional, Union
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class FormeError(Exception):
|
|
10
|
+
"""Raised on non-2xx responses from the Forme API."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, status: int, message: str) -> None:
|
|
13
|
+
self.status = status
|
|
14
|
+
self.message = message
|
|
15
|
+
super().__init__(message)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Forme:
|
|
19
|
+
"""Client for the Forme hosted PDF API.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
api_key: API key (e.g. ``"forme_sk_..."``).
|
|
23
|
+
base_url: Base URL of the API. Defaults to ``https://api.formepdf.com``.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
api_key: str,
|
|
29
|
+
base_url: str = "https://api.formepdf.com",
|
|
30
|
+
) -> None:
|
|
31
|
+
self._api_key = api_key
|
|
32
|
+
self._base_url = base_url.rstrip("/")
|
|
33
|
+
|
|
34
|
+
# ------------------------------------------------------------------
|
|
35
|
+
# Public API
|
|
36
|
+
# ------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
def render(
|
|
39
|
+
self,
|
|
40
|
+
slug: str,
|
|
41
|
+
data: Any = None,
|
|
42
|
+
*,
|
|
43
|
+
s3: Optional[Dict[str, Any]] = None,
|
|
44
|
+
) -> Union[bytes, Dict[str, Any]]:
|
|
45
|
+
"""Render a template to PDF (synchronous).
|
|
46
|
+
|
|
47
|
+
Returns raw PDF bytes, or a dict with ``{"url": "..."}`` when *s3*
|
|
48
|
+
is provided.
|
|
49
|
+
"""
|
|
50
|
+
body: Dict[str, Any] = dict(data) if data is not None else {}
|
|
51
|
+
if s3 is not None:
|
|
52
|
+
body["s3"] = s3
|
|
53
|
+
|
|
54
|
+
resp_body, content_type = self._request(
|
|
55
|
+
"POST",
|
|
56
|
+
"/v1/render/{}".format(slug),
|
|
57
|
+
body=body,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
if content_type is not None and "application/json" in content_type:
|
|
61
|
+
return json.loads(resp_body) # type: ignore[no-any-return]
|
|
62
|
+
return resp_body
|
|
63
|
+
|
|
64
|
+
def render_async(
|
|
65
|
+
self,
|
|
66
|
+
slug: str,
|
|
67
|
+
data: Any = None,
|
|
68
|
+
*,
|
|
69
|
+
webhook_url: Optional[str] = None,
|
|
70
|
+
) -> Dict[str, Any]:
|
|
71
|
+
"""Start an asynchronous render job.
|
|
72
|
+
|
|
73
|
+
Returns ``{"jobId": "...", "status": "pending"}``.
|
|
74
|
+
"""
|
|
75
|
+
body: Dict[str, Any] = dict(data) if data is not None else {}
|
|
76
|
+
if webhook_url is not None:
|
|
77
|
+
body["webhookUrl"] = webhook_url
|
|
78
|
+
|
|
79
|
+
resp_body, _ = self._request(
|
|
80
|
+
"POST",
|
|
81
|
+
"/v1/render/{}/async".format(slug),
|
|
82
|
+
body=body,
|
|
83
|
+
)
|
|
84
|
+
return json.loads(resp_body) # type: ignore[no-any-return]
|
|
85
|
+
|
|
86
|
+
def get_job(self, job_id: str) -> Dict[str, Any]:
|
|
87
|
+
"""Poll the status of an async render job."""
|
|
88
|
+
resp_body, _ = self._request("GET", "/v1/jobs/{}".format(job_id))
|
|
89
|
+
return json.loads(resp_body) # type: ignore[no-any-return]
|
|
90
|
+
|
|
91
|
+
def extract(self, pdf_bytes: bytes) -> Any:
|
|
92
|
+
"""Extract embedded data from a PDF.
|
|
93
|
+
|
|
94
|
+
Returns the embedded data dict, or ``None`` if the PDF has no
|
|
95
|
+
embedded data.
|
|
96
|
+
"""
|
|
97
|
+
try:
|
|
98
|
+
resp_body, _ = self._request(
|
|
99
|
+
"POST",
|
|
100
|
+
"/v1/extract",
|
|
101
|
+
body=pdf_bytes,
|
|
102
|
+
content_type="application/pdf",
|
|
103
|
+
)
|
|
104
|
+
result = json.loads(resp_body)
|
|
105
|
+
return result.get("data")
|
|
106
|
+
except FormeError as exc:
|
|
107
|
+
if exc.status == 404 and "no embedded data" in exc.message.lower():
|
|
108
|
+
return None
|
|
109
|
+
raise
|
|
110
|
+
|
|
111
|
+
# ------------------------------------------------------------------
|
|
112
|
+
# Internal helpers
|
|
113
|
+
# ------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
def _request(
|
|
116
|
+
self,
|
|
117
|
+
method: str,
|
|
118
|
+
path: str,
|
|
119
|
+
*,
|
|
120
|
+
body: Any = None,
|
|
121
|
+
content_type: str = "application/json",
|
|
122
|
+
) -> tuple:
|
|
123
|
+
"""Send an HTTP request and return ``(response_bytes, content_type)``."""
|
|
124
|
+
url = self._base_url + path
|
|
125
|
+
|
|
126
|
+
if isinstance(body, bytes):
|
|
127
|
+
data = body
|
|
128
|
+
elif body is not None:
|
|
129
|
+
data = json.dumps(body).encode("utf-8")
|
|
130
|
+
else:
|
|
131
|
+
data = None
|
|
132
|
+
|
|
133
|
+
req = urllib.request.Request(url, data=data, method=method)
|
|
134
|
+
req.add_header("Authorization", "Bearer {}".format(self._api_key))
|
|
135
|
+
if data is not None:
|
|
136
|
+
req.add_header("Content-Type", content_type)
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
resp = urllib.request.urlopen(req)
|
|
140
|
+
resp_content_type = resp.headers.get("Content-Type")
|
|
141
|
+
return resp.read(), resp_content_type
|
|
142
|
+
except urllib.error.HTTPError as exc:
|
|
143
|
+
status = exc.code
|
|
144
|
+
message = "Request failed with status {}".format(status)
|
|
145
|
+
try:
|
|
146
|
+
err_body = json.loads(exc.read())
|
|
147
|
+
if isinstance(err_body, dict):
|
|
148
|
+
message = err_body.get("error") or err_body.get("message") or message
|
|
149
|
+
except (ValueError, TypeError):
|
|
150
|
+
pass
|
|
151
|
+
raise FormeError(status, message) from None
|
|
File without changes
|