formfeed 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.
- formfeed-0.1.0/.gitignore +19 -0
- formfeed-0.1.0/LICENSE +21 -0
- formfeed-0.1.0/PKG-INFO +70 -0
- formfeed-0.1.0/README.md +40 -0
- formfeed-0.1.0/pyproject.toml +48 -0
- formfeed-0.1.0/src/formfeed/__init__.py +33 -0
- formfeed-0.1.0/src/formfeed/client.py +552 -0
- formfeed-0.1.0/src/formfeed/errors.py +30 -0
- formfeed-0.1.0/src/formfeed/models.py +150 -0
- formfeed-0.1.0/src/formfeed/py.typed +0 -0
- formfeed-0.1.0/src/formfeed/webhooks.py +59 -0
- formfeed-0.1.0/tests/test_client.py +155 -0
formfeed-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Philipp Staudt IT-Dienstleistungen
|
|
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.
|
formfeed-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: formfeed
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Formfeed API client: template-based PDF and image generation (sync and async, httpx).
|
|
5
|
+
Project-URL: Homepage, https://formfeed.dev
|
|
6
|
+
Project-URL: Documentation, https://docs.formfeed.dev/api/sdks/python
|
|
7
|
+
Project-URL: Source, https://github.com/formfeed-dev/formfeed/tree/main/packages/sdk-python
|
|
8
|
+
Project-URL: Issues, https://github.com/formfeed-dev/formfeed/issues
|
|
9
|
+
Project-URL: Changelog, https://docs.formfeed.dev/changelog
|
|
10
|
+
Author-email: Formfeed <support@formfeed.dev>
|
|
11
|
+
License-Expression: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: api,formfeed,html-to-pdf,pdf,pdf-generation,sdk,templates
|
|
14
|
+
Classifier: Development Status :: 4 - Beta
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Topic :: Office/Business
|
|
24
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
25
|
+
Classifier: Typing :: Typed
|
|
26
|
+
Requires-Python: >=3.10
|
|
27
|
+
Requires-Dist: httpx>=0.27
|
|
28
|
+
Requires-Dist: pydantic>=2.5
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# formfeed (Python)
|
|
32
|
+
|
|
33
|
+
Client for the Formfeed API: template-based PDF and image generation. Sync and async, built on
|
|
34
|
+
httpx, pydantic models, retries on 429 and 503 with the server's `Retry-After`, an
|
|
35
|
+
`Idempotency-Key` on every render, typed errors and webhook verification.
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install formfeed
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from formfeed import Formfeed
|
|
43
|
+
|
|
44
|
+
client = Formfeed("ff_test_…") # region="us" or base_url="…" for other hosts
|
|
45
|
+
render = client.renders.create(template="invoice-de", data={"invoice": {"number": "2026-001"}})
|
|
46
|
+
open("invoice.pdf", "wb").write(client.renders.download(render))
|
|
47
|
+
|
|
48
|
+
job = client.renders.batch([{"data": d} for d in rows], template="invoice-de", zip=True)
|
|
49
|
+
job = client.jobs.wait_for(job.id)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from formfeed import AsyncFormfeed
|
|
54
|
+
|
|
55
|
+
async with AsyncFormfeed("ff_test_…") as client:
|
|
56
|
+
render = await client.renders.create(html="<h1>Hello</h1>", output="png")
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Webhooks:
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
from formfeed import parse_webhook_event
|
|
63
|
+
|
|
64
|
+
event = parse_webhook_event(secret, request.headers["Webhook-Signature"], request.body)
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Errors raise `FormfeedError` with `code`, `status`, `problem` and `request_id`. Templates:
|
|
68
|
+
`client.templates.all()`, `.version("invoice-de", "latest")`, `.create_version(...)`, `.publish(...)`.
|
|
69
|
+
|
|
70
|
+
Documentation: <https://docs.formfeed.dev/api/sdks/python>
|
formfeed-0.1.0/README.md
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# formfeed (Python)
|
|
2
|
+
|
|
3
|
+
Client for the Formfeed API: template-based PDF and image generation. Sync and async, built on
|
|
4
|
+
httpx, pydantic models, retries on 429 and 503 with the server's `Retry-After`, an
|
|
5
|
+
`Idempotency-Key` on every render, typed errors and webhook verification.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install formfeed
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
from formfeed import Formfeed
|
|
13
|
+
|
|
14
|
+
client = Formfeed("ff_test_…") # region="us" or base_url="…" for other hosts
|
|
15
|
+
render = client.renders.create(template="invoice-de", data={"invoice": {"number": "2026-001"}})
|
|
16
|
+
open("invoice.pdf", "wb").write(client.renders.download(render))
|
|
17
|
+
|
|
18
|
+
job = client.renders.batch([{"data": d} for d in rows], template="invoice-de", zip=True)
|
|
19
|
+
job = client.jobs.wait_for(job.id)
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
from formfeed import AsyncFormfeed
|
|
24
|
+
|
|
25
|
+
async with AsyncFormfeed("ff_test_…") as client:
|
|
26
|
+
render = await client.renders.create(html="<h1>Hello</h1>", output="png")
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Webhooks:
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from formfeed import parse_webhook_event
|
|
33
|
+
|
|
34
|
+
event = parse_webhook_event(secret, request.headers["Webhook-Signature"], request.body)
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Errors raise `FormfeedError` with `code`, `status`, `problem` and `request_id`. Templates:
|
|
38
|
+
`client.templates.all()`, `.version("invoice-de", "latest")`, `.create_version(...)`, `.publish(...)`.
|
|
39
|
+
|
|
40
|
+
Documentation: <https://docs.formfeed.dev/api/sdks/python>
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.25"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "formfeed"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Formfeed API client: template-based PDF and image generation (sync and async, httpx)."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
# PEP 639: the SPDX expression replaces the licence classifier, the two must not be combined
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
requires-python = ">=3.10"
|
|
14
|
+
authors = [{ name = "Formfeed", email = "support@formfeed.dev" }]
|
|
15
|
+
keywords = ["formfeed", "pdf", "pdf-generation", "html-to-pdf", "templates", "api", "sdk"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 4 - Beta",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
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 = ["httpx>=0.27", "pydantic>=2.5"]
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://formfeed.dev"
|
|
34
|
+
Documentation = "https://docs.formfeed.dev/api/sdks/python"
|
|
35
|
+
Source = "https://github.com/formfeed-dev/formfeed/tree/main/packages/sdk-python"
|
|
36
|
+
Issues = "https://github.com/formfeed-dev/formfeed/issues"
|
|
37
|
+
Changelog = "https://docs.formfeed.dev/changelog"
|
|
38
|
+
|
|
39
|
+
[tool.hatch.build.targets.wheel]
|
|
40
|
+
packages = ["src/formfeed"]
|
|
41
|
+
|
|
42
|
+
# the source archive carries what a rebuild and the tests need, not the workspace's project.json
|
|
43
|
+
[tool.hatch.build.targets.sdist]
|
|
44
|
+
include = ["src/formfeed", "tests", "README.md", "LICENSE", "pyproject.toml"]
|
|
45
|
+
|
|
46
|
+
[tool.pytest.ini_options]
|
|
47
|
+
testpaths = ["tests"]
|
|
48
|
+
pythonpath = ["src"]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Formfeed API client (spec 10 §1): sync and async, retries on 429/503, idempotency keys, typed errors."""
|
|
2
|
+
|
|
3
|
+
from .client import AsyncFormfeed, Formfeed
|
|
4
|
+
from .errors import FormfeedError
|
|
5
|
+
from .models import (
|
|
6
|
+
Job,
|
|
7
|
+
Render,
|
|
8
|
+
RenderPage,
|
|
9
|
+
Template,
|
|
10
|
+
Usage,
|
|
11
|
+
TemplateVersion,
|
|
12
|
+
WebhookEndpoint,
|
|
13
|
+
WebhookEvent,
|
|
14
|
+
)
|
|
15
|
+
from .webhooks import parse_webhook_event, verify_webhook_signature
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"AsyncFormfeed",
|
|
19
|
+
"Formfeed",
|
|
20
|
+
"FormfeedError",
|
|
21
|
+
"Job",
|
|
22
|
+
"Render",
|
|
23
|
+
"RenderPage",
|
|
24
|
+
"Template",
|
|
25
|
+
"Usage",
|
|
26
|
+
"TemplateVersion",
|
|
27
|
+
"WebhookEndpoint",
|
|
28
|
+
"WebhookEvent",
|
|
29
|
+
"parse_webhook_event",
|
|
30
|
+
"verify_webhook_signature",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
"""Sync and async clients (httpx). Every behaviour mirrors ``@formfeed/sdk`` so the two SDKs stay in step."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import random
|
|
7
|
+
import time
|
|
8
|
+
import uuid
|
|
9
|
+
from typing import Any, Generic, TypeVar
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from .errors import FormfeedError
|
|
14
|
+
from .models import Job, Region, Render, RenderPage, Template, TemplatePage, TemplateVersion, Usage, WebhookEndpoint
|
|
15
|
+
|
|
16
|
+
HOSTS: dict[str, str] = {"eu": "https://api-eu.formfeed.dev/v1", "us": "https://api-us.formfeed.dev/v1"}
|
|
17
|
+
USER_AGENT = "formfeed-sdk-python/0.1"
|
|
18
|
+
RETRY_STATUSES = (429, 503)
|
|
19
|
+
|
|
20
|
+
T = TypeVar("T")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _backoff(attempt: int, retry_after: str | None) -> float:
|
|
24
|
+
"""Seconds to wait: the server's Retry-After when present, else 0.5 s doubling, plus jitter, capped at 30 s."""
|
|
25
|
+
base = 0.5 * 2 ** (attempt - 1)
|
|
26
|
+
if retry_after:
|
|
27
|
+
try:
|
|
28
|
+
value = float(retry_after)
|
|
29
|
+
if value > 0:
|
|
30
|
+
base = value
|
|
31
|
+
except ValueError:
|
|
32
|
+
pass
|
|
33
|
+
return min(30.0, base + random.random() * 0.25)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _problem_error(res: httpx.Response) -> FormfeedError:
|
|
37
|
+
try:
|
|
38
|
+
problem = res.json() if res.content else {}
|
|
39
|
+
except ValueError:
|
|
40
|
+
problem = {}
|
|
41
|
+
if not isinstance(problem, dict):
|
|
42
|
+
problem = {}
|
|
43
|
+
return FormfeedError(
|
|
44
|
+
str(problem.get("code") or f"http_{res.status_code}"),
|
|
45
|
+
str(problem.get("detail") or problem.get("title") or f"HTTP {res.status_code}"),
|
|
46
|
+
res.status_code,
|
|
47
|
+
problem,
|
|
48
|
+
res.headers.get("x-request-id"),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _query(**params: Any) -> dict[str, Any]:
|
|
53
|
+
return {k: v for k, v in params.items() if v is not None and v != ""}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class _Base(Generic[T]):
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
api_key: str,
|
|
60
|
+
*,
|
|
61
|
+
region: Region = "eu",
|
|
62
|
+
base_url: str | None = None,
|
|
63
|
+
max_retries: int = 3,
|
|
64
|
+
timeout: float = 120.0,
|
|
65
|
+
transport: httpx.BaseTransport | httpx.AsyncBaseTransport | None = None,
|
|
66
|
+
) -> None:
|
|
67
|
+
if not api_key:
|
|
68
|
+
raise FormfeedError("invalid_request", "api_key is required")
|
|
69
|
+
self.api_key = api_key
|
|
70
|
+
self.base_url = (base_url or HOSTS[region]).rstrip("/")
|
|
71
|
+
self.max_retries = max_retries
|
|
72
|
+
self.timeout = timeout
|
|
73
|
+
self._transport = transport
|
|
74
|
+
|
|
75
|
+
def _headers(self, body: Any, idempotency_key: str | None) -> dict[str, str]:
|
|
76
|
+
headers = {"authorization": f"Bearer {self.api_key}", "accept": "application/json", "user-agent": USER_AGENT}
|
|
77
|
+
if body is not None:
|
|
78
|
+
headers["content-type"] = "application/json"
|
|
79
|
+
if idempotency_key:
|
|
80
|
+
headers["idempotency-key"] = idempotency_key
|
|
81
|
+
return headers
|
|
82
|
+
|
|
83
|
+
@staticmethod
|
|
84
|
+
def _decode(res: httpx.Response) -> Any:
|
|
85
|
+
if res.status_code == 204 or not res.content:
|
|
86
|
+
return None
|
|
87
|
+
return res.json()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class Formfeed(_Base[Any]):
|
|
91
|
+
"""Synchronous client.
|
|
92
|
+
|
|
93
|
+
>>> client = Formfeed("ff_test_…")
|
|
94
|
+
>>> render = client.renders.create(template="invoice-de", data={"invoice": {...}})
|
|
95
|
+
>>> pdf = client.renders.download(render)
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
def __init__(self, api_key: str, **options: Any) -> None:
|
|
99
|
+
super().__init__(api_key, **options)
|
|
100
|
+
self._http = httpx.Client(timeout=self.timeout, transport=self._transport) # type: ignore[arg-type]
|
|
101
|
+
self.renders = _Renders(self)
|
|
102
|
+
self.jobs = _Jobs(self)
|
|
103
|
+
self.webhooks = _Webhooks(self)
|
|
104
|
+
self.templates = _Templates(self)
|
|
105
|
+
self.account = _Account(self)
|
|
106
|
+
|
|
107
|
+
def close(self) -> None:
|
|
108
|
+
self._http.close()
|
|
109
|
+
|
|
110
|
+
def __enter__(self) -> "Formfeed":
|
|
111
|
+
return self
|
|
112
|
+
|
|
113
|
+
def __exit__(self, *exc: object) -> None:
|
|
114
|
+
self.close()
|
|
115
|
+
|
|
116
|
+
def request(self, method: str, path: str, body: Any = None, *, idempotency_key: str | None = None) -> Any:
|
|
117
|
+
"""Raw request with auth, retries and problem mapping; for endpoints without a helper."""
|
|
118
|
+
url = f"{self.base_url}{path}"
|
|
119
|
+
headers = self._headers(body, idempotency_key)
|
|
120
|
+
attempt = 0
|
|
121
|
+
while True:
|
|
122
|
+
try:
|
|
123
|
+
res = self._http.request(method, url, json=body, headers=headers)
|
|
124
|
+
except httpx.HTTPError as e:
|
|
125
|
+
if attempt < self.max_retries:
|
|
126
|
+
attempt += 1
|
|
127
|
+
time.sleep(_backoff(attempt, None))
|
|
128
|
+
continue
|
|
129
|
+
raise FormfeedError("network_error", str(e)) from e
|
|
130
|
+
if res.status_code in RETRY_STATUSES and attempt < self.max_retries:
|
|
131
|
+
attempt += 1
|
|
132
|
+
time.sleep(_backoff(attempt, res.headers.get("retry-after")))
|
|
133
|
+
continue
|
|
134
|
+
if res.is_error:
|
|
135
|
+
raise _problem_error(res)
|
|
136
|
+
return self._decode(res)
|
|
137
|
+
|
|
138
|
+
def download_url(self, url: str) -> bytes:
|
|
139
|
+
res = self._http.get(url)
|
|
140
|
+
if res.is_error:
|
|
141
|
+
raise FormfeedError("download_failed", f"download answered HTTP {res.status_code}", res.status_code)
|
|
142
|
+
return res.content
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class AsyncFormfeed(_Base[Any]):
|
|
146
|
+
"""Asynchronous client with the same surface as :class:`Formfeed`; every method is awaitable."""
|
|
147
|
+
|
|
148
|
+
def __init__(self, api_key: str, **options: Any) -> None:
|
|
149
|
+
super().__init__(api_key, **options)
|
|
150
|
+
self._http = httpx.AsyncClient(timeout=self.timeout, transport=self._transport) # type: ignore[arg-type]
|
|
151
|
+
self.renders = _AsyncRenders(self)
|
|
152
|
+
self.jobs = _AsyncJobs(self)
|
|
153
|
+
self.webhooks = _AsyncWebhooks(self)
|
|
154
|
+
self.templates = _AsyncTemplates(self)
|
|
155
|
+
self.account = _AsyncAccount(self)
|
|
156
|
+
|
|
157
|
+
async def aclose(self) -> None:
|
|
158
|
+
await self._http.aclose()
|
|
159
|
+
|
|
160
|
+
async def __aenter__(self) -> "AsyncFormfeed":
|
|
161
|
+
return self
|
|
162
|
+
|
|
163
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
164
|
+
await self.aclose()
|
|
165
|
+
|
|
166
|
+
async def request(self, method: str, path: str, body: Any = None, *, idempotency_key: str | None = None) -> Any:
|
|
167
|
+
url = f"{self.base_url}{path}"
|
|
168
|
+
headers = self._headers(body, idempotency_key)
|
|
169
|
+
attempt = 0
|
|
170
|
+
while True:
|
|
171
|
+
try:
|
|
172
|
+
res = await self._http.request(method, url, json=body, headers=headers)
|
|
173
|
+
except httpx.HTTPError as e:
|
|
174
|
+
if attempt < self.max_retries:
|
|
175
|
+
attempt += 1
|
|
176
|
+
await asyncio.sleep(_backoff(attempt, None))
|
|
177
|
+
continue
|
|
178
|
+
raise FormfeedError("network_error", str(e)) from e
|
|
179
|
+
if res.status_code in RETRY_STATUSES and attempt < self.max_retries:
|
|
180
|
+
attempt += 1
|
|
181
|
+
await asyncio.sleep(_backoff(attempt, res.headers.get("retry-after")))
|
|
182
|
+
continue
|
|
183
|
+
if res.is_error:
|
|
184
|
+
raise _problem_error(res)
|
|
185
|
+
return self._decode(res)
|
|
186
|
+
|
|
187
|
+
async def download_url(self, url: str) -> bytes:
|
|
188
|
+
res = await self._http.get(url)
|
|
189
|
+
if res.is_error:
|
|
190
|
+
raise FormfeedError("download_failed", f"download answered HTTP {res.status_code}", res.status_code)
|
|
191
|
+
return res.content
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# --- sync namespaces --------------------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _render_body(template: str | None, html: str | None, url: str | None, data: dict[str, Any] | None, options: dict[str, Any]) -> dict[str, Any]:
|
|
198
|
+
body: dict[str, Any] = {k: v for k, v in options.items() if v is not None}
|
|
199
|
+
if template is not None:
|
|
200
|
+
body["template"] = template
|
|
201
|
+
if html is not None:
|
|
202
|
+
body["html"] = html
|
|
203
|
+
if url is not None:
|
|
204
|
+
body["url"] = url
|
|
205
|
+
if data is not None:
|
|
206
|
+
body["data"] = data
|
|
207
|
+
return body
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class _Renders:
|
|
211
|
+
def __init__(self, client: Formfeed) -> None:
|
|
212
|
+
self._c = client
|
|
213
|
+
|
|
214
|
+
def create(
|
|
215
|
+
self,
|
|
216
|
+
*,
|
|
217
|
+
template: str | None = None,
|
|
218
|
+
html: str | None = None,
|
|
219
|
+
url: str | None = None,
|
|
220
|
+
data: dict[str, Any] | None = None,
|
|
221
|
+
idempotency_key: str | None = None,
|
|
222
|
+
**options: Any,
|
|
223
|
+
) -> Render:
|
|
224
|
+
"""Renders a template, HTML or URL. Sync by default; ``mode="async"`` returns a queued render."""
|
|
225
|
+
body = _render_body(template, html, url, data, options)
|
|
226
|
+
return Render.model_validate(self._c.request("POST", "/renders", body, idempotency_key=idempotency_key or str(uuid.uuid4())))
|
|
227
|
+
|
|
228
|
+
def get(self, render_id: str) -> Render:
|
|
229
|
+
return Render.model_validate(self._c.request("GET", f"/renders/{render_id}"))
|
|
230
|
+
|
|
231
|
+
def wait_for(self, render_id: str, *, timeout: float = 120.0, interval: float = 1.0) -> Render:
|
|
232
|
+
"""Polls until the render succeeded or failed."""
|
|
233
|
+
deadline = time.monotonic() + timeout
|
|
234
|
+
while True:
|
|
235
|
+
render = self.get(render_id)
|
|
236
|
+
if render.finished:
|
|
237
|
+
return render
|
|
238
|
+
if time.monotonic() + interval > deadline:
|
|
239
|
+
raise FormfeedError("timeout", f"render {render_id} did not finish within the wait time")
|
|
240
|
+
time.sleep(interval)
|
|
241
|
+
|
|
242
|
+
def download(self, render: Render | str) -> bytes:
|
|
243
|
+
target = self.get(render) if isinstance(render, str) else render
|
|
244
|
+
if not target.download_url:
|
|
245
|
+
raise FormfeedError("not_ready", f"render {target.id} has no output ({target.status})")
|
|
246
|
+
return self._c.download_url(target.download_url)
|
|
247
|
+
|
|
248
|
+
def list(
|
|
249
|
+
self,
|
|
250
|
+
*,
|
|
251
|
+
template: str | None = None,
|
|
252
|
+
status: str | None = None,
|
|
253
|
+
environment: str | None = None,
|
|
254
|
+
since: str | None = None,
|
|
255
|
+
until: str | None = None,
|
|
256
|
+
limit: int | None = None,
|
|
257
|
+
cursor: str | None = None,
|
|
258
|
+
) -> RenderPage:
|
|
259
|
+
"""Page of renders, newest first."""
|
|
260
|
+
params = httpx.QueryParams(_query(template=template, status=status, environment=environment, since=since, until=until, limit=limit, cursor=cursor))
|
|
261
|
+
return RenderPage.model_validate(self._c.request("GET", f"/renders{'?' + str(params) if params else ''}"))
|
|
262
|
+
|
|
263
|
+
def all(self, **filters: Any) -> list[Render]:
|
|
264
|
+
"""Every render matching the filters, following the cursor."""
|
|
265
|
+
out: list[Render] = []
|
|
266
|
+
cursor: str | None = None
|
|
267
|
+
while True:
|
|
268
|
+
page = self.list(cursor=cursor, **filters)
|
|
269
|
+
out.extend(page.data)
|
|
270
|
+
cursor = page.next_cursor
|
|
271
|
+
if not cursor:
|
|
272
|
+
return out
|
|
273
|
+
|
|
274
|
+
def delete_outputs(self, render_id: str) -> None:
|
|
275
|
+
"""Removes the stored files of a render before they expire (needs the file:delete scope)."""
|
|
276
|
+
self._c.request("DELETE", f"/renders/{render_id}/outputs")
|
|
277
|
+
|
|
278
|
+
def batch(self, items: list[dict[str, Any]], *, template: str | None = None, idempotency_key: str | None = None, **options: Any) -> Job:
|
|
279
|
+
body: dict[str, Any] = {"items": items, **{k: v for k, v in options.items() if v is not None}}
|
|
280
|
+
if template is not None:
|
|
281
|
+
body["template"] = template
|
|
282
|
+
return Job.model_validate(self._c.request("POST", "/renders/batch", body, idempotency_key=idempotency_key or str(uuid.uuid4())))
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
class _Jobs:
|
|
286
|
+
def __init__(self, client: Formfeed) -> None:
|
|
287
|
+
self._c = client
|
|
288
|
+
|
|
289
|
+
def get(self, job_id: str) -> Job:
|
|
290
|
+
return Job.model_validate(self._c.request("GET", f"/jobs/{job_id}"))
|
|
291
|
+
|
|
292
|
+
def wait_for(self, job_id: str, *, timeout: float = 600.0, interval: float = 2.0) -> Job:
|
|
293
|
+
deadline = time.monotonic() + timeout
|
|
294
|
+
while True:
|
|
295
|
+
job = self.get(job_id)
|
|
296
|
+
if job.finished:
|
|
297
|
+
return job
|
|
298
|
+
if time.monotonic() + interval > deadline:
|
|
299
|
+
raise FormfeedError("timeout", f"job {job_id} did not finish within the wait time")
|
|
300
|
+
time.sleep(interval)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
class _Webhooks:
|
|
304
|
+
def __init__(self, client: Formfeed) -> None:
|
|
305
|
+
self._c = client
|
|
306
|
+
|
|
307
|
+
def list(self) -> list[WebhookEndpoint]:
|
|
308
|
+
return [WebhookEndpoint.model_validate(e) for e in self._c.request("GET", "/webhooks")["data"]]
|
|
309
|
+
|
|
310
|
+
def create(self, url: str, *, events: list[str] | None = None, description: str | None = None) -> WebhookEndpoint:
|
|
311
|
+
return WebhookEndpoint.model_validate(self._c.request("POST", "/webhooks", _query(url=url, events=events, description=description)))
|
|
312
|
+
|
|
313
|
+
def update(self, endpoint_id: str, **patch: Any) -> WebhookEndpoint:
|
|
314
|
+
return WebhookEndpoint.model_validate(self._c.request("PUT", f"/webhooks/{endpoint_id}", patch))
|
|
315
|
+
|
|
316
|
+
def delete(self, endpoint_id: str) -> None:
|
|
317
|
+
self._c.request("DELETE", f"/webhooks/{endpoint_id}")
|
|
318
|
+
|
|
319
|
+
def test(self, endpoint_id: str) -> dict[str, Any]:
|
|
320
|
+
return self._c.request("POST", f"/webhooks/{endpoint_id}/test")
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
class _Templates:
|
|
324
|
+
def __init__(self, client: Formfeed) -> None:
|
|
325
|
+
self._c = client
|
|
326
|
+
|
|
327
|
+
def list(self, *, kind: str | None = None, engine: str | None = None, tag: str | None = None, q: str | None = None, limit: int | None = None, cursor: str | None = None) -> TemplatePage:
|
|
328
|
+
params = httpx.QueryParams(_query(kind=kind, engine=engine, tag=tag, q=q, limit=limit, cursor=cursor))
|
|
329
|
+
return TemplatePage.model_validate(self._c.request("GET", f"/templates{'?' + str(params) if params else ''}"))
|
|
330
|
+
|
|
331
|
+
def all(self, **filters: Any) -> list[Template]:
|
|
332
|
+
"""Every template of the workspace, following the cursor."""
|
|
333
|
+
out: list[Template] = []
|
|
334
|
+
cursor: str | None = None
|
|
335
|
+
while True:
|
|
336
|
+
page = self.list(cursor=cursor, **filters)
|
|
337
|
+
out.extend(page.data)
|
|
338
|
+
cursor = page.next_cursor
|
|
339
|
+
if not cursor:
|
|
340
|
+
return out
|
|
341
|
+
|
|
342
|
+
def get(self, id_or_slug: str) -> Template:
|
|
343
|
+
return Template.model_validate(self._c.request("GET", f"/templates/{id_or_slug}"))
|
|
344
|
+
|
|
345
|
+
def create(self, **template: Any) -> Template:
|
|
346
|
+
return Template.model_validate(self._c.request("POST", "/templates", template))
|
|
347
|
+
|
|
348
|
+
def update(self, id_or_slug: str, **patch: Any) -> Template:
|
|
349
|
+
return Template.model_validate(self._c.request("PUT", f"/templates/{id_or_slug}", patch))
|
|
350
|
+
|
|
351
|
+
def archive(self, id_or_slug: str) -> None:
|
|
352
|
+
self._c.request("DELETE", f"/templates/{id_or_slug}")
|
|
353
|
+
|
|
354
|
+
def version(self, id_or_slug: str, which: str | int = "published") -> TemplateVersion:
|
|
355
|
+
"""``which``: ``published``, ``latest`` or a version number; carries the files."""
|
|
356
|
+
return TemplateVersion.model_validate(self._c.request("GET", f"/templates/{id_or_slug}/versions/{which}"))
|
|
357
|
+
|
|
358
|
+
def versions(self, id_or_slug: str) -> list[TemplateVersion]:
|
|
359
|
+
return [TemplateVersion.model_validate(v) for v in self._c.request("GET", f"/templates/{id_or_slug}/versions")["data"]]
|
|
360
|
+
|
|
361
|
+
def create_version(self, id_or_slug: str, **version: Any) -> TemplateVersion:
|
|
362
|
+
return TemplateVersion.model_validate(self._c.request("POST", f"/templates/{id_or_slug}/versions", version))
|
|
363
|
+
|
|
364
|
+
def publish(self, id_or_slug: str, number: int) -> TemplateVersion:
|
|
365
|
+
return TemplateVersion.model_validate(self._c.request("POST", f"/templates/{id_or_slug}/versions/{number}/publish"))
|
|
366
|
+
|
|
367
|
+
def schema(self, id_or_slug: str) -> dict[str, Any]:
|
|
368
|
+
return self._c.request("GET", f"/templates/{id_or_slug}/schema")
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
class _Account:
|
|
372
|
+
def __init__(self, client: Formfeed) -> None:
|
|
373
|
+
self._c = client
|
|
374
|
+
|
|
375
|
+
def get(self) -> dict[str, Any]:
|
|
376
|
+
return self._c.request("GET", "/account")
|
|
377
|
+
|
|
378
|
+
def usage(self, period: str = "current") -> Usage:
|
|
379
|
+
"""Units of the current period, or of ``YYYY-MM``, with a daily series and per template."""
|
|
380
|
+
return Usage.model_validate(self._c.request("GET", f"/usage?period={period}"))
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
# --- async namespaces -------------------------------------------------------------------------
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
class _AsyncRenders:
|
|
387
|
+
def __init__(self, client: AsyncFormfeed) -> None:
|
|
388
|
+
self._c = client
|
|
389
|
+
|
|
390
|
+
async def create(
|
|
391
|
+
self,
|
|
392
|
+
*,
|
|
393
|
+
template: str | None = None,
|
|
394
|
+
html: str | None = None,
|
|
395
|
+
url: str | None = None,
|
|
396
|
+
data: dict[str, Any] | None = None,
|
|
397
|
+
idempotency_key: str | None = None,
|
|
398
|
+
**options: Any,
|
|
399
|
+
) -> Render:
|
|
400
|
+
body = _render_body(template, html, url, data, options)
|
|
401
|
+
return Render.model_validate(await self._c.request("POST", "/renders", body, idempotency_key=idempotency_key or str(uuid.uuid4())))
|
|
402
|
+
|
|
403
|
+
async def get(self, render_id: str) -> Render:
|
|
404
|
+
return Render.model_validate(await self._c.request("GET", f"/renders/{render_id}"))
|
|
405
|
+
|
|
406
|
+
async def wait_for(self, render_id: str, *, timeout: float = 120.0, interval: float = 1.0) -> Render:
|
|
407
|
+
deadline = time.monotonic() + timeout
|
|
408
|
+
while True:
|
|
409
|
+
render = await self.get(render_id)
|
|
410
|
+
if render.finished:
|
|
411
|
+
return render
|
|
412
|
+
if time.monotonic() + interval > deadline:
|
|
413
|
+
raise FormfeedError("timeout", f"render {render_id} did not finish within the wait time")
|
|
414
|
+
await asyncio.sleep(interval)
|
|
415
|
+
|
|
416
|
+
async def download(self, render: Render | str) -> bytes:
|
|
417
|
+
target = await self.get(render) if isinstance(render, str) else render
|
|
418
|
+
if not target.download_url:
|
|
419
|
+
raise FormfeedError("not_ready", f"render {target.id} has no output ({target.status})")
|
|
420
|
+
return await self._c.download_url(target.download_url)
|
|
421
|
+
|
|
422
|
+
async def list(
|
|
423
|
+
self,
|
|
424
|
+
*,
|
|
425
|
+
template: str | None = None,
|
|
426
|
+
status: str | None = None,
|
|
427
|
+
environment: str | None = None,
|
|
428
|
+
since: str | None = None,
|
|
429
|
+
until: str | None = None,
|
|
430
|
+
limit: int | None = None,
|
|
431
|
+
cursor: str | None = None,
|
|
432
|
+
) -> RenderPage:
|
|
433
|
+
"""Page of renders, newest first."""
|
|
434
|
+
params = httpx.QueryParams(_query(template=template, status=status, environment=environment, since=since, until=until, limit=limit, cursor=cursor))
|
|
435
|
+
return RenderPage.model_validate(await self._c.request("GET", f"/renders{'?' + str(params) if params else ''}"))
|
|
436
|
+
|
|
437
|
+
async def all(self, **filters: Any) -> list[Render]:
|
|
438
|
+
"""Every render matching the filters, following the cursor."""
|
|
439
|
+
out: list[Render] = []
|
|
440
|
+
cursor: str | None = None
|
|
441
|
+
while True:
|
|
442
|
+
page = await self.list(cursor=cursor, **filters)
|
|
443
|
+
out.extend(page.data)
|
|
444
|
+
cursor = page.next_cursor
|
|
445
|
+
if not cursor:
|
|
446
|
+
return out
|
|
447
|
+
|
|
448
|
+
async def delete_outputs(self, render_id: str) -> None:
|
|
449
|
+
"""Removes the stored files of a render before they expire (needs the file:delete scope)."""
|
|
450
|
+
await self._c.request("DELETE", f"/renders/{render_id}/outputs")
|
|
451
|
+
|
|
452
|
+
async def batch(self, items: list[dict[str, Any]], *, template: str | None = None, idempotency_key: str | None = None, **options: Any) -> Job:
|
|
453
|
+
body: dict[str, Any] = {"items": items, **{k: v for k, v in options.items() if v is not None}}
|
|
454
|
+
if template is not None:
|
|
455
|
+
body["template"] = template
|
|
456
|
+
return Job.model_validate(await self._c.request("POST", "/renders/batch", body, idempotency_key=idempotency_key or str(uuid.uuid4())))
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
class _AsyncJobs:
|
|
460
|
+
def __init__(self, client: AsyncFormfeed) -> None:
|
|
461
|
+
self._c = client
|
|
462
|
+
|
|
463
|
+
async def get(self, job_id: str) -> Job:
|
|
464
|
+
return Job.model_validate(await self._c.request("GET", f"/jobs/{job_id}"))
|
|
465
|
+
|
|
466
|
+
async def wait_for(self, job_id: str, *, timeout: float = 600.0, interval: float = 2.0) -> Job:
|
|
467
|
+
deadline = time.monotonic() + timeout
|
|
468
|
+
while True:
|
|
469
|
+
job = await self.get(job_id)
|
|
470
|
+
if job.finished:
|
|
471
|
+
return job
|
|
472
|
+
if time.monotonic() + interval > deadline:
|
|
473
|
+
raise FormfeedError("timeout", f"job {job_id} did not finish within the wait time")
|
|
474
|
+
await asyncio.sleep(interval)
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
class _AsyncWebhooks:
|
|
478
|
+
def __init__(self, client: AsyncFormfeed) -> None:
|
|
479
|
+
self._c = client
|
|
480
|
+
|
|
481
|
+
async def list(self) -> list[WebhookEndpoint]:
|
|
482
|
+
return [WebhookEndpoint.model_validate(e) for e in (await self._c.request("GET", "/webhooks"))["data"]]
|
|
483
|
+
|
|
484
|
+
async def create(self, url: str, *, events: list[str] | None = None, description: str | None = None) -> WebhookEndpoint:
|
|
485
|
+
return WebhookEndpoint.model_validate(await self._c.request("POST", "/webhooks", _query(url=url, events=events, description=description)))
|
|
486
|
+
|
|
487
|
+
async def update(self, endpoint_id: str, **patch: Any) -> WebhookEndpoint:
|
|
488
|
+
return WebhookEndpoint.model_validate(await self._c.request("PUT", f"/webhooks/{endpoint_id}", patch))
|
|
489
|
+
|
|
490
|
+
async def delete(self, endpoint_id: str) -> None:
|
|
491
|
+
await self._c.request("DELETE", f"/webhooks/{endpoint_id}")
|
|
492
|
+
|
|
493
|
+
async def test(self, endpoint_id: str) -> dict[str, Any]:
|
|
494
|
+
return await self._c.request("POST", f"/webhooks/{endpoint_id}/test")
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
class _AsyncTemplates:
|
|
498
|
+
def __init__(self, client: AsyncFormfeed) -> None:
|
|
499
|
+
self._c = client
|
|
500
|
+
|
|
501
|
+
async def list(self, *, kind: str | None = None, engine: str | None = None, tag: str | None = None, q: str | None = None, limit: int | None = None, cursor: str | None = None) -> TemplatePage:
|
|
502
|
+
params = httpx.QueryParams(_query(kind=kind, engine=engine, tag=tag, q=q, limit=limit, cursor=cursor))
|
|
503
|
+
return TemplatePage.model_validate(await self._c.request("GET", f"/templates{'?' + str(params) if params else ''}"))
|
|
504
|
+
|
|
505
|
+
async def all(self, **filters: Any) -> list[Template]:
|
|
506
|
+
out: list[Template] = []
|
|
507
|
+
cursor: str | None = None
|
|
508
|
+
while True:
|
|
509
|
+
page = await self.list(cursor=cursor, **filters)
|
|
510
|
+
out.extend(page.data)
|
|
511
|
+
cursor = page.next_cursor
|
|
512
|
+
if not cursor:
|
|
513
|
+
return out
|
|
514
|
+
|
|
515
|
+
async def get(self, id_or_slug: str) -> Template:
|
|
516
|
+
return Template.model_validate(await self._c.request("GET", f"/templates/{id_or_slug}"))
|
|
517
|
+
|
|
518
|
+
async def create(self, **template: Any) -> Template:
|
|
519
|
+
return Template.model_validate(await self._c.request("POST", "/templates", template))
|
|
520
|
+
|
|
521
|
+
async def update(self, id_or_slug: str, **patch: Any) -> Template:
|
|
522
|
+
return Template.model_validate(await self._c.request("PUT", f"/templates/{id_or_slug}", patch))
|
|
523
|
+
|
|
524
|
+
async def archive(self, id_or_slug: str) -> None:
|
|
525
|
+
await self._c.request("DELETE", f"/templates/{id_or_slug}")
|
|
526
|
+
|
|
527
|
+
async def version(self, id_or_slug: str, which: str | int = "published") -> TemplateVersion:
|
|
528
|
+
return TemplateVersion.model_validate(await self._c.request("GET", f"/templates/{id_or_slug}/versions/{which}"))
|
|
529
|
+
|
|
530
|
+
async def versions(self, id_or_slug: str) -> list[TemplateVersion]:
|
|
531
|
+
return [TemplateVersion.model_validate(v) for v in (await self._c.request("GET", f"/templates/{id_or_slug}/versions"))["data"]]
|
|
532
|
+
|
|
533
|
+
async def create_version(self, id_or_slug: str, **version: Any) -> TemplateVersion:
|
|
534
|
+
return TemplateVersion.model_validate(await self._c.request("POST", f"/templates/{id_or_slug}/versions", version))
|
|
535
|
+
|
|
536
|
+
async def publish(self, id_or_slug: str, number: int) -> TemplateVersion:
|
|
537
|
+
return TemplateVersion.model_validate(await self._c.request("POST", f"/templates/{id_or_slug}/versions/{number}/publish"))
|
|
538
|
+
|
|
539
|
+
async def schema(self, id_or_slug: str) -> dict[str, Any]:
|
|
540
|
+
return await self._c.request("GET", f"/templates/{id_or_slug}/schema")
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
class _AsyncAccount:
|
|
544
|
+
def __init__(self, client: AsyncFormfeed) -> None:
|
|
545
|
+
self._c = client
|
|
546
|
+
|
|
547
|
+
async def get(self) -> dict[str, Any]:
|
|
548
|
+
return await self._c.request("GET", "/account")
|
|
549
|
+
|
|
550
|
+
async def usage(self, period: str = "current") -> Usage:
|
|
551
|
+
"""Units of the current period, or of ``YYYY-MM``, with a daily series and per template."""
|
|
552
|
+
return Usage.model_validate(await self._c.request("GET", f"/usage?period={period}"))
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class FormfeedError(Exception):
|
|
7
|
+
"""An API problem (RFC 9457) or a transport failure.
|
|
8
|
+
|
|
9
|
+
``code`` is the problem code (``quota_exceeded``, ``template_not_found`` …) or ``network_error`` /
|
|
10
|
+
``timeout`` / ``not_ready`` for client-side failures; ``status`` is the HTTP status, 0 when none.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
code: str,
|
|
16
|
+
message: str,
|
|
17
|
+
status: int = 0,
|
|
18
|
+
problem: dict[str, Any] | None = None,
|
|
19
|
+
request_id: str | None = None,
|
|
20
|
+
) -> None:
|
|
21
|
+
super().__init__(message)
|
|
22
|
+
self.code = code
|
|
23
|
+
self.message = message
|
|
24
|
+
self.status = status
|
|
25
|
+
self.problem = problem
|
|
26
|
+
self.request_id = request_id
|
|
27
|
+
|
|
28
|
+
def __str__(self) -> str: # pragma: no cover - formatting
|
|
29
|
+
suffix = f" (request {self.request_id})" if self.request_id else ""
|
|
30
|
+
return f"{self.code}: {self.message}{suffix}"
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Response models. Extra fields the API adds later are kept (``extra="allow"``) so clients never break on additions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Literal
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict
|
|
8
|
+
|
|
9
|
+
Region = Literal["eu", "us"]
|
|
10
|
+
Engine = Literal["jinja2", "liquid", "handlebars"]
|
|
11
|
+
OutputFormat = Literal["pdf", "png", "jpg", "webp"]
|
|
12
|
+
RenderStatus = Literal["queued", "rendering", "succeeded", "failed"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class _Model(BaseModel):
|
|
16
|
+
model_config = ConfigDict(extra="allow", populate_by_name=True)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class RenderTemplateRef(_Model):
|
|
20
|
+
id: str
|
|
21
|
+
slug: str
|
|
22
|
+
version: int
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Render(_Model):
|
|
26
|
+
id: str
|
|
27
|
+
status: RenderStatus
|
|
28
|
+
output: str | None = None
|
|
29
|
+
download_url: str | None = None
|
|
30
|
+
expires_at: str | None = None
|
|
31
|
+
bytes: int | None = None
|
|
32
|
+
page_count: int | None = None
|
|
33
|
+
units: float = 0
|
|
34
|
+
region: str | None = None
|
|
35
|
+
environment: str | None = None
|
|
36
|
+
template: RenderTemplateRef | None = None
|
|
37
|
+
engine_version: str | None = None
|
|
38
|
+
template_checksum: str | None = None
|
|
39
|
+
output_sha256: str | None = None
|
|
40
|
+
deduplicated: bool = False
|
|
41
|
+
timings: dict[str, float] | None = None
|
|
42
|
+
error: dict[str, Any] | None = None
|
|
43
|
+
meta: dict[str, Any] = {}
|
|
44
|
+
created_at: str | None = None
|
|
45
|
+
completed_at: str | None = None
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def finished(self) -> bool:
|
|
49
|
+
return self.status in ("succeeded", "failed")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class Job(_Model):
|
|
53
|
+
id: str
|
|
54
|
+
type: str = "batch"
|
|
55
|
+
status: str
|
|
56
|
+
total: int = 0
|
|
57
|
+
succeeded: int = 0
|
|
58
|
+
failed: int = 0
|
|
59
|
+
zip_url: str | None = None
|
|
60
|
+
zip_expires_at: str | None = None
|
|
61
|
+
items: list[Render] = []
|
|
62
|
+
meta: dict[str, Any] = {}
|
|
63
|
+
created_at: str | None = None
|
|
64
|
+
completed_at: str | None = None
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def finished(self) -> bool:
|
|
68
|
+
return self.status not in ("queued", "processing")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class WebhookEndpoint(_Model):
|
|
72
|
+
id: str
|
|
73
|
+
url: str
|
|
74
|
+
events: list[str] = []
|
|
75
|
+
enabled: bool = True
|
|
76
|
+
description: str | None = None
|
|
77
|
+
consecutive_failures: int = 0
|
|
78
|
+
secret: str | None = None
|
|
79
|
+
created_at: str | None = None
|
|
80
|
+
updated_at: str | None = None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class Template(_Model):
|
|
84
|
+
id: str
|
|
85
|
+
slug: str
|
|
86
|
+
name: str
|
|
87
|
+
description: str | None = None
|
|
88
|
+
kind: str
|
|
89
|
+
engine: str
|
|
90
|
+
tags: list[str] = []
|
|
91
|
+
published_version: int | None = None
|
|
92
|
+
latest_version: int = 0
|
|
93
|
+
created_at: str | None = None
|
|
94
|
+
updated_at: str | None = None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class TemplateVersion(_Model):
|
|
98
|
+
id: str
|
|
99
|
+
number: int
|
|
100
|
+
status: str
|
|
101
|
+
checksum: str
|
|
102
|
+
change_note: str | None = None
|
|
103
|
+
created_at: str | None = None
|
|
104
|
+
published_at: str | None = None
|
|
105
|
+
html: str | None = None
|
|
106
|
+
css: str | None = None
|
|
107
|
+
head: str | None = None
|
|
108
|
+
settings: dict[str, Any] | None = None
|
|
109
|
+
sample_data: dict[str, Any] | None = None
|
|
110
|
+
data_schema: dict[str, Any] | None = None
|
|
111
|
+
i18n: dict[str, Any] | None = None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class UsageDay(_Model):
|
|
115
|
+
date: str
|
|
116
|
+
units: float = 0
|
|
117
|
+
renders: int = 0
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class UsageTemplate(_Model):
|
|
121
|
+
template: str
|
|
122
|
+
units: float = 0
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class Usage(_Model):
|
|
126
|
+
period: str
|
|
127
|
+
included: float = 0
|
|
128
|
+
used: float = 0
|
|
129
|
+
overage_used: float = 0
|
|
130
|
+
overage_balance: float = 0
|
|
131
|
+
daily: list[UsageDay] = []
|
|
132
|
+
by_template: list[UsageTemplate] = []
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class RenderPage(_Model):
|
|
136
|
+
data: list[Render]
|
|
137
|
+
next_cursor: str | None = None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class TemplatePage(_Model):
|
|
141
|
+
data: list[Template]
|
|
142
|
+
next_cursor: str | None = None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class WebhookEvent(_Model):
|
|
146
|
+
id: str
|
|
147
|
+
type: str
|
|
148
|
+
created_at: str
|
|
149
|
+
workspace_id: str | None = None
|
|
150
|
+
data: Any = None
|
|
File without changes
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Webhook signature verification (spec 04 §2.4): ``Webhook-Signature: t=<unix>,v1=<hex hmac-sha256(secret, t + "." + body)>``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import hmac
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
import time
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from .models import WebhookEvent
|
|
13
|
+
|
|
14
|
+
_HEX64 = re.compile(r"^[0-9a-f]{64}$")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def verify_webhook_signature(
|
|
18
|
+
secret: str,
|
|
19
|
+
signature_header: str | None,
|
|
20
|
+
raw_body: bytes | str,
|
|
21
|
+
*,
|
|
22
|
+
tolerance_seconds: int = 300,
|
|
23
|
+
now: int | None = None,
|
|
24
|
+
) -> bool:
|
|
25
|
+
"""True when the header matches the raw body. Pass the body exactly as received: the signature covers the bytes on the wire."""
|
|
26
|
+
if not signature_header or not secret:
|
|
27
|
+
return False
|
|
28
|
+
parts: dict[str, str] = {}
|
|
29
|
+
for kv in signature_header.split(","):
|
|
30
|
+
key, _, value = kv.strip().partition("=")
|
|
31
|
+
parts[key] = value
|
|
32
|
+
try:
|
|
33
|
+
t = int(parts.get("t", ""))
|
|
34
|
+
except ValueError:
|
|
35
|
+
return False
|
|
36
|
+
v1 = parts.get("v1", "").lower()
|
|
37
|
+
if not _HEX64.match(v1):
|
|
38
|
+
return False
|
|
39
|
+
current = now if now is not None else int(time.time())
|
|
40
|
+
if abs(current - t) > tolerance_seconds:
|
|
41
|
+
return False
|
|
42
|
+
body = raw_body.encode("utf-8") if isinstance(raw_body, str) else raw_body
|
|
43
|
+
expected = hmac.new(secret.encode("utf-8"), f"{t}.".encode("utf-8") + body, hashlib.sha256).hexdigest()
|
|
44
|
+
return hmac.compare_digest(expected, v1)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def parse_webhook_event(
|
|
48
|
+
secret: str,
|
|
49
|
+
signature_header: str | None,
|
|
50
|
+
raw_body: bytes | str,
|
|
51
|
+
*,
|
|
52
|
+
tolerance_seconds: int = 300,
|
|
53
|
+
now: int | None = None,
|
|
54
|
+
) -> WebhookEvent:
|
|
55
|
+
"""Parses a verified event body; raises ``ValueError`` when the signature does not match."""
|
|
56
|
+
if not verify_webhook_signature(secret, signature_header, raw_body, tolerance_seconds=tolerance_seconds, now=now):
|
|
57
|
+
raise ValueError("invalid webhook signature")
|
|
58
|
+
payload: dict[str, Any] = json.loads(raw_body)
|
|
59
|
+
return WebhookEvent.model_validate(payload)
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import hmac
|
|
3
|
+
import json
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from formfeed import AsyncFormfeed, Formfeed, FormfeedError, parse_webhook_event, verify_webhook_signature
|
|
10
|
+
|
|
11
|
+
RENDER = {"id": "rnd_1", "status": "succeeded", "download_url": "https://cdn.test/o/x.pdf?exp=1&sig=2", "page_count": 1, "units": 1}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _json(body, status=200, headers=None):
|
|
15
|
+
return httpx.Response(status, json=body, headers=headers or {})
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Recorder:
|
|
19
|
+
def __init__(self, responder):
|
|
20
|
+
self.calls: list[httpx.Request] = []
|
|
21
|
+
self._responder = responder
|
|
22
|
+
|
|
23
|
+
def handler(self, request: httpx.Request) -> httpx.Response:
|
|
24
|
+
self.calls.append(request)
|
|
25
|
+
return self._responder(request, len(self.calls))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def sync_client(responder, **options) -> tuple[Formfeed, Recorder]:
|
|
29
|
+
rec = Recorder(responder)
|
|
30
|
+
return Formfeed("ff_test_k", transport=httpx.MockTransport(rec.handler), **options), rec
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_render_sends_auth_idempotency_and_maps_the_response():
|
|
34
|
+
client, rec = sync_client(lambda req, n: _json(RENDER, 201))
|
|
35
|
+
render = client.renders.create(template="invoice", data={"a": 1}, output="pdf")
|
|
36
|
+
assert render.id == "rnd_1" and render.finished
|
|
37
|
+
req = rec.calls[0]
|
|
38
|
+
assert req.url == "https://api-eu.formfeed.dev/v1/renders"
|
|
39
|
+
assert req.headers["authorization"] == "Bearer ff_test_k"
|
|
40
|
+
assert len(req.headers["idempotency-key"]) > 10
|
|
41
|
+
assert json.loads(req.content) == {"template": "invoice", "data": {"a": 1}, "output": "pdf"}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_retries_429_with_retry_after_then_succeeds(monkeypatch):
|
|
45
|
+
monkeypatch.setattr("formfeed.client.time.sleep", lambda s: None)
|
|
46
|
+
client, rec = sync_client(lambda req, n: _json({"code": "rate_limited"}, 429, {"retry-after": "1"}) if n < 3 else _json(RENDER))
|
|
47
|
+
assert client.renders.get("rnd_1").status == "succeeded"
|
|
48
|
+
assert len(rec.calls) == 3
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_problems_become_typed_errors():
|
|
52
|
+
client, _ = sync_client(lambda req, n: _json({"code": "quota_exceeded", "detail": "Monthly units used up", "status": 402}, 402, {"x-request-id": "req_9"}))
|
|
53
|
+
with pytest.raises(FormfeedError) as e:
|
|
54
|
+
client.renders.create(html="<p>x</p>")
|
|
55
|
+
assert e.value.code == "quota_exceeded"
|
|
56
|
+
assert e.value.status == 402
|
|
57
|
+
assert e.value.request_id == "req_9"
|
|
58
|
+
assert "Monthly units" in str(e.value)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_wait_for_and_download(monkeypatch):
|
|
62
|
+
monkeypatch.setattr("formfeed.client.time.sleep", lambda s: None)
|
|
63
|
+
states = iter(["queued", "rendering", "succeeded"])
|
|
64
|
+
|
|
65
|
+
def responder(req, n):
|
|
66
|
+
if req.url.host == "cdn.test":
|
|
67
|
+
return httpx.Response(200, content=b"%PDF")
|
|
68
|
+
return _json({**RENDER, "status": next(states)})
|
|
69
|
+
|
|
70
|
+
client, _ = sync_client(responder)
|
|
71
|
+
render = client.renders.wait_for("rnd_1", interval=0)
|
|
72
|
+
assert render.status == "succeeded"
|
|
73
|
+
assert client.renders.download(render) == b"%PDF"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_usage_reads_a_period():
|
|
77
|
+
client, rec = sync_client(
|
|
78
|
+
lambda req, n: _json(
|
|
79
|
+
{"period": "2026-09", "included": 15000, "used": 120, "overage_used": 0, "overage_balance": 0,
|
|
80
|
+
"daily": [{"date": "2026-09-07", "units": 3, "renders": 2}], "by_template": [{"template": "invoice", "units": 3}]}
|
|
81
|
+
)
|
|
82
|
+
)
|
|
83
|
+
usage = client.account.usage("2026-08")
|
|
84
|
+
assert usage.period == "2026-09"
|
|
85
|
+
assert usage.daily[0].renders == 2
|
|
86
|
+
assert usage.by_template[0].template == "invoice"
|
|
87
|
+
assert rec.calls[0].url.params.get("period") == "2026-08"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_renders_list_follows_the_cursor_and_deletes_outputs():
|
|
91
|
+
def responder(req, n):
|
|
92
|
+
if req.method == "DELETE":
|
|
93
|
+
return httpx.Response(204)
|
|
94
|
+
cursor = req.url.params.get("cursor")
|
|
95
|
+
return _json({"data": [RENDER], "next_cursor": None if cursor else "c1"})
|
|
96
|
+
|
|
97
|
+
client, rec = sync_client(responder)
|
|
98
|
+
page = client.renders.list(status="succeeded", limit=1)
|
|
99
|
+
assert len(page.data) == 1 and page.next_cursor == "c1"
|
|
100
|
+
assert rec.calls[0].url.params.get("status") == "succeeded"
|
|
101
|
+
|
|
102
|
+
every = client.renders.all(template="invoice")
|
|
103
|
+
assert len(every) == 2
|
|
104
|
+
assert rec.calls[-1].url.params.get("cursor") == "c1"
|
|
105
|
+
|
|
106
|
+
client.renders.delete_outputs("rnd_1")
|
|
107
|
+
assert rec.calls[-1].method == "DELETE"
|
|
108
|
+
assert rec.calls[-1].url.path == "/v1/renders/rnd_1/outputs"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_templates_follow_the_cursor_and_address_versions():
|
|
112
|
+
def responder(req, n):
|
|
113
|
+
if req.url.path == "/v1/templates":
|
|
114
|
+
cursor = req.url.params.get("cursor")
|
|
115
|
+
return _json({"data": [{"id": "tpl_1", "slug": "a", "name": "A", "kind": "pdf", "engine": "jinja2"}], "next_cursor": None if cursor else "c1"})
|
|
116
|
+
if req.url.path.endswith("/versions/latest"):
|
|
117
|
+
return _json({"id": "v", "number": 3, "status": "draft", "checksum": "x", "html": "<p>"})
|
|
118
|
+
return _json({"id": "v", "number": 4, "status": "published", "checksum": "y"})
|
|
119
|
+
|
|
120
|
+
client, rec = sync_client(responder)
|
|
121
|
+
assert len(client.templates.all(kind="pdf")) == 2
|
|
122
|
+
assert rec.calls[0].url.params["kind"] == "pdf"
|
|
123
|
+
assert rec.calls[1].url.params["cursor"] == "c1"
|
|
124
|
+
assert client.templates.version("a", "latest").html == "<p>"
|
|
125
|
+
assert client.templates.publish("a", 4).status == "published"
|
|
126
|
+
assert rec.calls[-1].url.path == "/v1/templates/a/versions/4/publish"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_async_client_has_the_same_surface():
|
|
130
|
+
import asyncio
|
|
131
|
+
|
|
132
|
+
rec = Recorder(lambda req, n: _json(RENDER, 201))
|
|
133
|
+
|
|
134
|
+
async def scenario():
|
|
135
|
+
async with AsyncFormfeed("ff_test_k", transport=httpx.MockTransport(rec.handler), region="us") as client:
|
|
136
|
+
return await client.renders.create(html="<p>x</p>")
|
|
137
|
+
|
|
138
|
+
render = asyncio.run(scenario())
|
|
139
|
+
assert render.id == "rnd_1"
|
|
140
|
+
assert rec.calls[0].url.host == "api-us.formfeed.dev"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def test_webhook_signature_round_trip():
|
|
144
|
+
secret = "whsec_test"
|
|
145
|
+
body = b'{"id":"evt_1","type":"render.completed","created_at":"2026-09-09T00:00:00Z","workspace_id":null,"data":{"id":"rnd_1"}}'
|
|
146
|
+
t = int(time.time())
|
|
147
|
+
sig = hmac.new(secret.encode(), f"{t}.".encode() + body, hashlib.sha256).hexdigest()
|
|
148
|
+
header = f"t={t},v1={sig}"
|
|
149
|
+
assert verify_webhook_signature(secret, header, body)
|
|
150
|
+
assert not verify_webhook_signature(secret, header, body + b" ")
|
|
151
|
+
assert not verify_webhook_signature(secret, header, body, now=t + 1000)
|
|
152
|
+
event = parse_webhook_event(secret, header, body)
|
|
153
|
+
assert event.type == "render.completed" and event.data == {"id": "rnd_1"}
|
|
154
|
+
with pytest.raises(ValueError):
|
|
155
|
+
parse_webhook_event("other", header, body)
|