quirepdf 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- quirepdf/__init__.py +38 -0
- quirepdf/client.py +322 -0
- quirepdf/errors.py +63 -0
- quirepdf/py.typed +0 -0
- quirepdf/types.py +698 -0
- quirepdf-0.1.0.dist-info/METADATA +134 -0
- quirepdf-0.1.0.dist-info/RECORD +9 -0
- quirepdf-0.1.0.dist-info/WHEEL +4 -0
- quirepdf-0.1.0.dist-info/licenses/LICENSE +21 -0
quirepdf/__init__.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Quire: send JSON, get back a polished PDF.
|
|
2
|
+
|
|
3
|
+
from quirepdf import Quire
|
|
4
|
+
|
|
5
|
+
client = Quire() # reads QUIRE_API_KEY and QUIRE_API_URL
|
|
6
|
+
client.render(data).save("invoice.pdf")
|
|
7
|
+
|
|
8
|
+
Typed template data lives in ``quirepdf.types`` (``InvoiceData``, ``ReceiptData``, ...).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from .client import (
|
|
12
|
+
CreatedKey,
|
|
13
|
+
KeyInfo,
|
|
14
|
+
Quire,
|
|
15
|
+
Quota,
|
|
16
|
+
RenderResult,
|
|
17
|
+
TemplateDetail,
|
|
18
|
+
TemplateMeta,
|
|
19
|
+
Usage,
|
|
20
|
+
ValidationResult,
|
|
21
|
+
__version__,
|
|
22
|
+
)
|
|
23
|
+
from .errors import FieldError, QuireError
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"Quire",
|
|
27
|
+
"QuireError",
|
|
28
|
+
"RenderResult",
|
|
29
|
+
"ValidationResult",
|
|
30
|
+
"Quota",
|
|
31
|
+
"FieldError",
|
|
32
|
+
"TemplateMeta",
|
|
33
|
+
"TemplateDetail",
|
|
34
|
+
"Usage",
|
|
35
|
+
"KeyInfo",
|
|
36
|
+
"CreatedKey",
|
|
37
|
+
"__version__",
|
|
38
|
+
]
|
quirepdf/client.py
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"""Synchronous Quire API client (standard library only)."""
|
|
2
|
+
|
|
3
|
+
import decimal
|
|
4
|
+
import http.client
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import pathlib
|
|
8
|
+
import socket
|
|
9
|
+
import urllib.error
|
|
10
|
+
import urllib.parse
|
|
11
|
+
import urllib.request
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from email.message import Message
|
|
14
|
+
from typing import Any, Dict, List, Mapping, Optional, TypedDict, Union
|
|
15
|
+
|
|
16
|
+
from .errors import FieldError, QuireError
|
|
17
|
+
|
|
18
|
+
__version__ = "0.1.0"
|
|
19
|
+
|
|
20
|
+
DEFAULT_BASE_URL = "https://api.quirepdf.dev"
|
|
21
|
+
USER_AGENT = f"quirepdf-python/{__version__}"
|
|
22
|
+
|
|
23
|
+
PathLike = Union[str, "os.PathLike[str]"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# ---------- response shapes ----------
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class TemplateMeta(TypedDict):
|
|
30
|
+
"""One entry of ``templates.list()``."""
|
|
31
|
+
|
|
32
|
+
name: str
|
|
33
|
+
title: str
|
|
34
|
+
description: str
|
|
35
|
+
category: str
|
|
36
|
+
tags: List[str]
|
|
37
|
+
version: str
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class TemplateDetail(TypedDict):
|
|
41
|
+
"""``templates.get(name)``: metadata, the JSON Schema for the data, and valid sample data."""
|
|
42
|
+
|
|
43
|
+
template: TemplateMeta
|
|
44
|
+
schema: Dict[str, Any]
|
|
45
|
+
sample: Dict[str, Any]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Usage(TypedDict):
|
|
49
|
+
"""``usage()``: renders used in the current calendar month."""
|
|
50
|
+
|
|
51
|
+
email: str
|
|
52
|
+
plan: str
|
|
53
|
+
period: str
|
|
54
|
+
used: int
|
|
55
|
+
limit: int
|
|
56
|
+
remaining: int
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class KeyInfo(TypedDict):
|
|
60
|
+
"""An API key as listed by ``keys.list()`` (never includes the secret)."""
|
|
61
|
+
|
|
62
|
+
id: str
|
|
63
|
+
prefix: str
|
|
64
|
+
name: Optional[str]
|
|
65
|
+
created_at: int
|
|
66
|
+
last_used_at: Optional[int]
|
|
67
|
+
current: bool
|
|
68
|
+
"""True for the key this client is authenticated with (added by the SDK)."""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class CreatedKey(TypedDict):
|
|
72
|
+
"""``keys.create()``: the new key's info plus its secret, which is shown only once."""
|
|
73
|
+
|
|
74
|
+
key: Dict[str, Any]
|
|
75
|
+
api_key: str
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True)
|
|
79
|
+
class Quota:
|
|
80
|
+
"""Monthly render quota after this render (accounts mode only)."""
|
|
81
|
+
|
|
82
|
+
limit: int
|
|
83
|
+
remaining: int
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass
|
|
87
|
+
class RenderResult:
|
|
88
|
+
"""A rendered document.
|
|
89
|
+
|
|
90
|
+
The file is in ``content`` rather than a field named ``bytes`` (the name the cross-language
|
|
91
|
+
spec uses): ``bytes`` would shadow the built-in type inside the class and read ambiguously at
|
|
92
|
+
call sites (``result.bytes`` next to ``bytes(...)``), and ``content`` matches the convention
|
|
93
|
+
Python HTTP libraries use for a binary response body.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
content: bytes = field(repr=False)
|
|
97
|
+
content_type: str
|
|
98
|
+
pages: int
|
|
99
|
+
template: str
|
|
100
|
+
template_source: str
|
|
101
|
+
"""``explicit`` (you named it), ``detected`` (the data matched its schema) or ``fallback``
|
|
102
|
+
(the generic ``document`` layout)."""
|
|
103
|
+
hint: Optional[str] = None
|
|
104
|
+
"""On fallback: a template that almost matched and why, e.g. ``invoice (seller is required)``."""
|
|
105
|
+
render_ms: float = 0.0
|
|
106
|
+
quota: Optional[Quota] = None
|
|
107
|
+
|
|
108
|
+
def save(self, path: PathLike) -> pathlib.Path:
|
|
109
|
+
"""Write the document to ``path`` and return it as a ``pathlib.Path``."""
|
|
110
|
+
p = pathlib.Path(path)
|
|
111
|
+
p.write_bytes(self.content)
|
|
112
|
+
return p
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass
|
|
116
|
+
class ValidationResult:
|
|
117
|
+
"""Outcome of ``validate()``: which template the data resolves to, and every problem with it."""
|
|
118
|
+
|
|
119
|
+
valid: bool
|
|
120
|
+
template: str
|
|
121
|
+
source: str
|
|
122
|
+
hint: Optional[str] = None
|
|
123
|
+
errors: List[FieldError] = field(default_factory=list)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# ---------- transport ----------
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass
|
|
130
|
+
class _Response:
|
|
131
|
+
status: int
|
|
132
|
+
headers: Message
|
|
133
|
+
body: bytes
|
|
134
|
+
|
|
135
|
+
def json(self) -> Any:
|
|
136
|
+
return json.loads(self.body.decode("utf-8")) if self.body else None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
140
|
+
"""Never follow redirects: urllib would turn a POST into a GET and forward the API key to
|
|
141
|
+
whatever host the redirect names. A 3xx surfaces as a ``QuireError`` instead."""
|
|
142
|
+
|
|
143
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[no-untyped-def]
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _json_default(value: Any) -> Any:
|
|
148
|
+
if isinstance(value, decimal.Decimal):
|
|
149
|
+
return int(value) if value == value.to_integral_value() else float(value)
|
|
150
|
+
raise TypeError(f"Object of type {type(value).__name__} is not JSON serializable")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# ---------- client ----------
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class Quire:
|
|
157
|
+
"""Client for the Quire API.
|
|
158
|
+
|
|
159
|
+
>>> from quirepdf import Quire
|
|
160
|
+
>>> client = Quire() # QUIRE_API_KEY / QUIRE_API_URL from the environment
|
|
161
|
+
>>> client.render({"title": "Hello", "items": [{"name": "Tea", "qty": 2}]}).save("hello.pdf")
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
api_key: API key. Defaults to ``QUIRE_API_KEY``. When neither is set no ``Authorization``
|
|
165
|
+
header is sent (fine for open-mode servers; others reply ``unauthorized``).
|
|
166
|
+
base_url: API root. Defaults to ``QUIRE_API_URL``, else ``https://api.quirepdf.dev``.
|
|
167
|
+
timeout: Seconds to wait for each request.
|
|
168
|
+
"""
|
|
169
|
+
|
|
170
|
+
def __init__(self, api_key: Optional[str] = None, base_url: Optional[str] = None, timeout: float = 30.0) -> None:
|
|
171
|
+
self.api_key: Optional[str] = api_key or os.environ.get("QUIRE_API_KEY") or None
|
|
172
|
+
self.base_url: str = (base_url or os.environ.get("QUIRE_API_URL") or DEFAULT_BASE_URL).rstrip("/")
|
|
173
|
+
self.timeout = timeout
|
|
174
|
+
self.templates = Templates(self)
|
|
175
|
+
self.keys = Keys(self)
|
|
176
|
+
self._opener = urllib.request.build_opener(_NoRedirect)
|
|
177
|
+
|
|
178
|
+
def __repr__(self) -> str:
|
|
179
|
+
key = f"{self.api_key[:8]}..." if self.api_key else None
|
|
180
|
+
return f"Quire(base_url={self.base_url!r}, api_key={key!r}, timeout={self.timeout!r})"
|
|
181
|
+
|
|
182
|
+
# ----- documents -----
|
|
183
|
+
|
|
184
|
+
def render(
|
|
185
|
+
self,
|
|
186
|
+
data: Mapping[str, Any],
|
|
187
|
+
template: Optional[str] = None,
|
|
188
|
+
format: str = "pdf",
|
|
189
|
+
page: Optional[int] = None,
|
|
190
|
+
) -> RenderResult:
|
|
191
|
+
"""Render ``data`` to a PDF (or one page as PNG with ``format="png"``).
|
|
192
|
+
|
|
193
|
+
Without ``template`` the API picks the best-matching gallery template, or the generic
|
|
194
|
+
``document`` layout (see ``RenderResult.template_source`` and ``hint``).
|
|
195
|
+
"""
|
|
196
|
+
path = f"/v1/render/{_segment(template)}" if template else "/v1/render"
|
|
197
|
+
query: Dict[str, Any] = {"format": format if format != "pdf" else None, "page": page}
|
|
198
|
+
res = self._request("POST", path, query=query, body=data)
|
|
199
|
+
h = res.headers
|
|
200
|
+
limit, remaining = _int(h.get("x-quota-limit")), _int(h.get("x-quota-remaining"))
|
|
201
|
+
return RenderResult(
|
|
202
|
+
content=res.body,
|
|
203
|
+
content_type=(h.get("content-type") or "").split(";")[0].strip(),
|
|
204
|
+
pages=_int(h.get("x-pages")) or 0,
|
|
205
|
+
template=h.get("x-template") or (template or ""),
|
|
206
|
+
template_source=h.get("x-template-source") or ("explicit" if template else ""),
|
|
207
|
+
hint=h.get("x-template-hint") or None,
|
|
208
|
+
render_ms=_float(h.get("x-render-ms")) or 0.0,
|
|
209
|
+
quota=Quota(limit, remaining) if limit is not None and remaining is not None else None,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
def validate(self, data: Mapping[str, Any], template: Optional[str] = None) -> ValidationResult:
|
|
213
|
+
"""Check ``data`` without rendering (free, not metered). Invalid data is a result, not an error."""
|
|
214
|
+
body = self._request("POST", "/v1/validate", query={"template": template}, body=data).json()
|
|
215
|
+
return ValidationResult(
|
|
216
|
+
valid=bool(body.get("valid")),
|
|
217
|
+
template=body.get("template") or "",
|
|
218
|
+
source=body.get("source") or "",
|
|
219
|
+
hint=body.get("hint") or None,
|
|
220
|
+
errors=[{"path": str(e.get("path", "")), "message": str(e.get("message", ""))} for e in body.get("errors") or []],
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
def usage(self) -> Usage:
|
|
224
|
+
"""Renders used this month on your plan (accounts mode only)."""
|
|
225
|
+
return self._request("GET", "/v1/usage").json() # type: ignore[no-any-return]
|
|
226
|
+
|
|
227
|
+
# ----- transport -----
|
|
228
|
+
|
|
229
|
+
def _request(
|
|
230
|
+
self,
|
|
231
|
+
method: str,
|
|
232
|
+
path: str,
|
|
233
|
+
query: Optional[Mapping[str, Any]] = None,
|
|
234
|
+
body: Any = None,
|
|
235
|
+
) -> _Response:
|
|
236
|
+
url = self.base_url + path
|
|
237
|
+
params = {k: str(v) for k, v in (query or {}).items() if v is not None}
|
|
238
|
+
if params:
|
|
239
|
+
url += "?" + urllib.parse.urlencode(params)
|
|
240
|
+
headers = {"User-Agent": USER_AGENT}
|
|
241
|
+
if self.api_key:
|
|
242
|
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
243
|
+
data = None
|
|
244
|
+
if body is not None:
|
|
245
|
+
data = json.dumps(body, ensure_ascii=False, default=_json_default).encode("utf-8")
|
|
246
|
+
headers["Content-Type"] = "application/json"
|
|
247
|
+
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
248
|
+
try:
|
|
249
|
+
with self._opener.open(req, timeout=self.timeout) as resp:
|
|
250
|
+
return _Response(resp.status, resp.headers, resp.read())
|
|
251
|
+
except urllib.error.HTTPError as e:
|
|
252
|
+
try:
|
|
253
|
+
raw = e.read()
|
|
254
|
+
except (OSError, http.client.HTTPException):
|
|
255
|
+
raw = b""
|
|
256
|
+
finally:
|
|
257
|
+
e.close()
|
|
258
|
+
raise QuireError.from_response(e.code, raw) from None
|
|
259
|
+
except urllib.error.URLError as e:
|
|
260
|
+
if isinstance(e.reason, (socket.timeout, TimeoutError)):
|
|
261
|
+
raise self._timeout(method, path) from e
|
|
262
|
+
raise QuireError(0, "network", f"can't reach {self.base_url} ({e.reason})") from e
|
|
263
|
+
except (socket.timeout, TimeoutError) as e: # timed out while reading the response
|
|
264
|
+
raise self._timeout(method, path) from e
|
|
265
|
+
except (OSError, http.client.HTTPException) as e:
|
|
266
|
+
raise QuireError(0, "network", f"connection to {self.base_url} failed ({e or type(e).__name__})") from e
|
|
267
|
+
|
|
268
|
+
def _timeout(self, method: str, path: str) -> QuireError:
|
|
269
|
+
return QuireError(0, "timeout", f"{method} {path} timed out after {self.timeout:g}s")
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
class Templates:
|
|
273
|
+
"""``client.templates``: the template catalog."""
|
|
274
|
+
|
|
275
|
+
def __init__(self, client: Quire) -> None:
|
|
276
|
+
self._client = client
|
|
277
|
+
|
|
278
|
+
def list(self) -> List[TemplateMeta]:
|
|
279
|
+
return self._client._request("GET", "/v1/templates").json()["templates"] # type: ignore[no-any-return]
|
|
280
|
+
|
|
281
|
+
def get(self, name: str) -> TemplateDetail:
|
|
282
|
+
return self._client._request("GET", f"/v1/templates/{_segment(name)}").json() # type: ignore[no-any-return]
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
class Keys:
|
|
286
|
+
"""``client.keys``: API keys of the authenticated account (accounts mode only)."""
|
|
287
|
+
|
|
288
|
+
def __init__(self, client: Quire) -> None:
|
|
289
|
+
self._client = client
|
|
290
|
+
|
|
291
|
+
def list(self) -> List[KeyInfo]:
|
|
292
|
+
"""Active keys. ``current`` marks the key this client uses."""
|
|
293
|
+
body = self._client._request("GET", "/v1/keys").json()
|
|
294
|
+
current = body.get("current_key_id")
|
|
295
|
+
return [dict(k, current=k.get("id") == current) for k in body.get("keys") or []] # type: ignore[misc]
|
|
296
|
+
|
|
297
|
+
def create(self, name: Optional[str] = None) -> CreatedKey:
|
|
298
|
+
"""Create a key. The secret is in ``["api_key"]`` and is returned only this once."""
|
|
299
|
+
body = {"name": name} if name is not None else {}
|
|
300
|
+
return self._client._request("POST", "/v1/keys", body=body).json() # type: ignore[no-any-return]
|
|
301
|
+
|
|
302
|
+
def revoke(self, key_id: str) -> None:
|
|
303
|
+
"""Revoke a key by id. The API refuses to revoke your only active key (``last_key``)."""
|
|
304
|
+
self._client._request("DELETE", f"/v1/keys/{_segment(key_id)}")
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _segment(value: str) -> str:
|
|
308
|
+
return urllib.parse.quote(value, safe="")
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _int(value: Optional[str]) -> Optional[int]:
|
|
312
|
+
try:
|
|
313
|
+
return int(value) if value is not None else None
|
|
314
|
+
except ValueError:
|
|
315
|
+
return None
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _float(value: Optional[str]) -> Optional[float]:
|
|
319
|
+
try:
|
|
320
|
+
return float(value) if value is not None else None
|
|
321
|
+
except ValueError:
|
|
322
|
+
return None
|
quirepdf/errors.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""The one error type the SDK raises for API, network and timeout failures."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any, Dict, List, Optional
|
|
5
|
+
|
|
6
|
+
FieldError = Dict[str, str]
|
|
7
|
+
"""One validation problem: ``{"path": "items[0].qty", "message": "items[0].qty must be a number"}``."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class QuireError(Exception):
|
|
11
|
+
"""Raised for every failed request.
|
|
12
|
+
|
|
13
|
+
Attributes:
|
|
14
|
+
status: HTTP status code, or ``0`` when no response arrived (network failure or timeout).
|
|
15
|
+
type: The API error type (``invalid_data``, ``unknown_template``, ``unauthorized``,
|
|
16
|
+
``quota_exceeded``, ...), or ``"network"`` / ``"timeout"`` for client-side failures,
|
|
17
|
+
or ``"http_error"`` when the server sent a non-JSON error body.
|
|
18
|
+
message: Human-readable message from the API, e.g. ``customer.name is required``.
|
|
19
|
+
fields: Every validation problem as ``{"path", "message"}`` dicts (``invalid_data`` only;
|
|
20
|
+
empty otherwise).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, status: int, type: str, message: str, fields: Optional[List[FieldError]] = None) -> None:
|
|
24
|
+
super().__init__(message)
|
|
25
|
+
self.status = status
|
|
26
|
+
self.type = type
|
|
27
|
+
self.message = message
|
|
28
|
+
self.fields: List[FieldError] = list(fields or [])
|
|
29
|
+
|
|
30
|
+
def __str__(self) -> str:
|
|
31
|
+
lines = [self.message]
|
|
32
|
+
for f in self.fields:
|
|
33
|
+
msg = f.get("message", "")
|
|
34
|
+
# A single-problem error repeats that problem as its message; don't print it twice.
|
|
35
|
+
if msg and msg != self.message:
|
|
36
|
+
lines.append(f" - {msg}")
|
|
37
|
+
return "\n".join(lines)
|
|
38
|
+
|
|
39
|
+
def __repr__(self) -> str:
|
|
40
|
+
return f"QuireError(status={self.status!r}, type={self.type!r}, message={self.message!r}, fields={self.fields!r})"
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def from_response(cls, status: int, body: bytes) -> "QuireError":
|
|
44
|
+
"""Build an error from an HTTP error response (``{"error": {"type", "message", "fields"?}}``)."""
|
|
45
|
+
type_, message = "http_error", f"HTTP {status}"
|
|
46
|
+
fields: List[FieldError] = []
|
|
47
|
+
payload: Any = None
|
|
48
|
+
try:
|
|
49
|
+
payload = json.loads(body.decode("utf-8")) if body else None
|
|
50
|
+
except ValueError: # includes JSONDecodeError and UnicodeDecodeError
|
|
51
|
+
pass
|
|
52
|
+
err = payload.get("error") if isinstance(payload, dict) else None
|
|
53
|
+
if isinstance(err, dict):
|
|
54
|
+
type_ = str(err.get("type") or type_)
|
|
55
|
+
message = str(err.get("message") or message)
|
|
56
|
+
raw_fields = err.get("fields")
|
|
57
|
+
if isinstance(raw_fields, list):
|
|
58
|
+
fields = [
|
|
59
|
+
{"path": str(f.get("path", "")), "message": str(f.get("message", ""))}
|
|
60
|
+
for f in raw_fields
|
|
61
|
+
if isinstance(f, dict)
|
|
62
|
+
]
|
|
63
|
+
return cls(status, type_, message, fields)
|
quirepdf/py.typed
ADDED
|
File without changes
|
quirepdf/types.py
ADDED
|
@@ -0,0 +1,698 @@
|
|
|
1
|
+
"""Typed data for Quire's gallery templates.
|
|
2
|
+
|
|
3
|
+
GENERATED by scripts/gen_types.py from templates/*/schema.json. Do not edit by hand;
|
|
4
|
+
re-run ``python3 scripts/gen_types.py`` after changing a schema.
|
|
5
|
+
|
|
6
|
+
Each template has a ``<Name>Data`` TypedDict (``InvoiceData``, ``ReceiptData``, ...).
|
|
7
|
+
Python 3.9 has no ``NotRequired``, so an object with both required and optional keys is a
|
|
8
|
+
private required base class (``total=True``) plus a public ``total=False`` subclass.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from typing import Any, Dict, List, Literal, TypedDict
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"CertificateData",
|
|
15
|
+
"CertificateIssuer",
|
|
16
|
+
"CertificateSignature",
|
|
17
|
+
"CreditNoteCustomer",
|
|
18
|
+
"CreditNoteData",
|
|
19
|
+
"CreditNoteItem",
|
|
20
|
+
"CreditNoteOriginalInvoice",
|
|
21
|
+
"CreditNoteSeller",
|
|
22
|
+
"CreditNoteTax",
|
|
23
|
+
"InvoiceCustomer",
|
|
24
|
+
"InvoiceData",
|
|
25
|
+
"InvoiceDiscount",
|
|
26
|
+
"InvoiceItem",
|
|
27
|
+
"InvoicePayment",
|
|
28
|
+
"InvoiceSeller",
|
|
29
|
+
"InvoiceTax",
|
|
30
|
+
"QuoteCustomer",
|
|
31
|
+
"QuoteData",
|
|
32
|
+
"QuoteDiscount",
|
|
33
|
+
"QuoteSection",
|
|
34
|
+
"QuoteSectionItem",
|
|
35
|
+
"QuoteSeller",
|
|
36
|
+
"QuoteTax",
|
|
37
|
+
"ReceiptCustomer",
|
|
38
|
+
"ReceiptData",
|
|
39
|
+
"ReceiptDiscount",
|
|
40
|
+
"ReceiptItem",
|
|
41
|
+
"ReceiptNote",
|
|
42
|
+
"ReceiptPayment",
|
|
43
|
+
"ReceiptSeller",
|
|
44
|
+
"ReceiptSupport",
|
|
45
|
+
"ReceiptTax",
|
|
46
|
+
"StatementAging",
|
|
47
|
+
"StatementCustomer",
|
|
48
|
+
"StatementData",
|
|
49
|
+
"StatementEntry",
|
|
50
|
+
"StatementPayment",
|
|
51
|
+
"StatementSeller",
|
|
52
|
+
"DocumentData",
|
|
53
|
+
"TemplateName",
|
|
54
|
+
"TEMPLATE_DATA_TYPES",
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class _CertificateIssuerRequired(TypedDict):
|
|
59
|
+
"""Required keys of :class:`CertificateIssuer`."""
|
|
60
|
+
|
|
61
|
+
# [1 to 60 chars]
|
|
62
|
+
name: str
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class CertificateIssuer(_CertificateIssuerRequired, total=False):
|
|
66
|
+
"""The organisation awarding the certificate"""
|
|
67
|
+
|
|
68
|
+
# Muted line under the issuer name, e.g. a website [max 80 chars]
|
|
69
|
+
tagline: str
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class _CertificateSignatureRequired(TypedDict):
|
|
73
|
+
"""Required keys of :class:`CertificateSignature`."""
|
|
74
|
+
|
|
75
|
+
# [1 to 50 chars]
|
|
76
|
+
name: str
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class CertificateSignature(_CertificateSignatureRequired, total=False):
|
|
80
|
+
"""``signatures[]`` in :class:`CertificateData`."""
|
|
81
|
+
|
|
82
|
+
# [max 60 chars]
|
|
83
|
+
title: str
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class _CertificateDataRequired(TypedDict):
|
|
87
|
+
"""Required keys of :class:`CertificateData`."""
|
|
88
|
+
|
|
89
|
+
# The organisation awarding the certificate
|
|
90
|
+
issuer: CertificateIssuer
|
|
91
|
+
# Recipient's full name. Long names are shrunk to fit, then wrapped. [1 to 100 chars]
|
|
92
|
+
recipient: str
|
|
93
|
+
# Course or achievement name [1 to 140 chars]
|
|
94
|
+
achievement: str
|
|
95
|
+
# Date of issue, pre-formatted for display [1 to 40 chars]
|
|
96
|
+
date: str
|
|
97
|
+
# One or two signature lines. With one, the date of issue takes the other slot. [1 to 2 items]
|
|
98
|
+
signatures: List[CertificateSignature]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class CertificateData(_CertificateDataRequired, total=False):
|
|
102
|
+
"""Data for the ``certificate`` template (Certificate)."""
|
|
103
|
+
|
|
104
|
+
# Brand colour for the frame, seal and title, e.g. #0f766e [pattern ^#[0-9a-fA-F]{6}$]
|
|
105
|
+
accent: str
|
|
106
|
+
# e.g. Certificate of achievement [default "Certificate of completion"; 1 to 60 chars]
|
|
107
|
+
title: str
|
|
108
|
+
# [default "This certifies that"; max 60 chars]
|
|
109
|
+
intro: str
|
|
110
|
+
# Text between the name and the achievement [default "has successfully completed"; max 80 chars]
|
|
111
|
+
statement: str
|
|
112
|
+
# Muted line under the achievement, e.g. hours, grade, format [max 220 chars]
|
|
113
|
+
details: str
|
|
114
|
+
# [max 40 chars]
|
|
115
|
+
credential_id: str
|
|
116
|
+
# Verification link, shown in small monospace text [max 100 chars]
|
|
117
|
+
verify_url: str
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class _CreditNoteOriginalInvoiceRequired(TypedDict):
|
|
121
|
+
"""Required keys of :class:`CreditNoteOriginalInvoice`."""
|
|
122
|
+
|
|
123
|
+
# [1 to 40 chars]
|
|
124
|
+
number: str
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class CreditNoteOriginalInvoice(_CreditNoteOriginalInvoiceRequired, total=False):
|
|
128
|
+
"""The invoice this credit is issued against"""
|
|
129
|
+
|
|
130
|
+
# Original invoice date, pre-formatted for display
|
|
131
|
+
date: str
|
|
132
|
+
# Original invoice total
|
|
133
|
+
amount: float
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class _CreditNoteSellerRequired(TypedDict):
|
|
137
|
+
"""Required keys of :class:`CreditNoteSeller`."""
|
|
138
|
+
|
|
139
|
+
# [non-empty]
|
|
140
|
+
name: str
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class CreditNoteSeller(_CreditNoteSellerRequired, total=False):
|
|
144
|
+
"""The business issuing the credit (shown as 'From')"""
|
|
145
|
+
|
|
146
|
+
# Shown under the brand name
|
|
147
|
+
email: str
|
|
148
|
+
address: List[str]
|
|
149
|
+
tax_id: str
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class _CreditNoteCustomerRequired(TypedDict):
|
|
153
|
+
"""Required keys of :class:`CreditNoteCustomer`."""
|
|
154
|
+
|
|
155
|
+
# [non-empty]
|
|
156
|
+
name: str
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class CreditNoteCustomer(_CreditNoteCustomerRequired, total=False):
|
|
160
|
+
"""The customer receiving the credit (shown as 'Billed to')"""
|
|
161
|
+
|
|
162
|
+
company: str
|
|
163
|
+
address: List[str]
|
|
164
|
+
email: str
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class _CreditNoteItemRequired(TypedDict):
|
|
168
|
+
"""Required keys of :class:`CreditNoteItem`."""
|
|
169
|
+
|
|
170
|
+
description: str
|
|
171
|
+
# [>= 0]
|
|
172
|
+
qty: float
|
|
173
|
+
# [>= 0]
|
|
174
|
+
unit_price: float
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class CreditNoteItem(_CreditNoteItemRequired, total=False):
|
|
178
|
+
"""``items[]`` in :class:`CreditNoteData`."""
|
|
179
|
+
|
|
180
|
+
detail: str
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class CreditNoteTax(TypedDict):
|
|
184
|
+
"""Tax credited back, applied to the subtotal"""
|
|
185
|
+
|
|
186
|
+
label: str
|
|
187
|
+
# [0 to 1]
|
|
188
|
+
rate: float
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class _CreditNoteDataRequired(TypedDict):
|
|
192
|
+
"""Required keys of :class:`CreditNoteData`."""
|
|
193
|
+
|
|
194
|
+
# Credit note number, e.g. CN-2026-0019 [1 to 40 chars]
|
|
195
|
+
number: str
|
|
196
|
+
# Issue date, pre-formatted for display [non-empty]
|
|
197
|
+
issued: str
|
|
198
|
+
# The invoice this credit is issued against
|
|
199
|
+
original_invoice: CreditNoteOriginalInvoice
|
|
200
|
+
# The business issuing the credit (shown as 'From')
|
|
201
|
+
seller: CreditNoteSeller
|
|
202
|
+
# The customer receiving the credit (shown as 'Billed to')
|
|
203
|
+
customer: CreditNoteCustomer
|
|
204
|
+
# Credited lines. Use positive quantities and prices; they are shown as credit. [at least 1
|
|
205
|
+
# item]
|
|
206
|
+
items: List[CreditNoteItem]
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class CreditNoteData(_CreditNoteDataRequired, total=False):
|
|
210
|
+
"""Data for the ``credit-note`` template (Credit note)."""
|
|
211
|
+
|
|
212
|
+
# [default "issued"]
|
|
213
|
+
status: Literal["issued", "applied", "refunded"]
|
|
214
|
+
# Currency symbol prefix [default "$"; max 4 chars]
|
|
215
|
+
currency: str
|
|
216
|
+
# Brand colour, e.g. #7048e8 [pattern ^#[0-9a-fA-F]{6}$]
|
|
217
|
+
accent: str
|
|
218
|
+
# Why the credit was issued [max 600 chars]
|
|
219
|
+
reason: str
|
|
220
|
+
# Tax credited back, applied to the subtotal
|
|
221
|
+
tax: CreditNoteTax
|
|
222
|
+
# How this credit will be applied, e.g. refunded to the original card [max 600 chars]
|
|
223
|
+
application: str
|
|
224
|
+
# [max 600 chars]
|
|
225
|
+
notes: str
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class _InvoiceSellerRequired(TypedDict):
|
|
229
|
+
"""Required keys of :class:`InvoiceSeller`."""
|
|
230
|
+
|
|
231
|
+
# [non-empty]
|
|
232
|
+
name: str
|
|
233
|
+
address: List[str]
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class InvoiceSeller(_InvoiceSellerRequired, total=False):
|
|
237
|
+
"""``seller`` in :class:`InvoiceData`."""
|
|
238
|
+
|
|
239
|
+
email: str
|
|
240
|
+
tax_id: str
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class _InvoiceCustomerRequired(TypedDict):
|
|
244
|
+
"""Required keys of :class:`InvoiceCustomer`."""
|
|
245
|
+
|
|
246
|
+
# [non-empty]
|
|
247
|
+
name: str
|
|
248
|
+
address: List[str]
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
class InvoiceCustomer(_InvoiceCustomerRequired, total=False):
|
|
252
|
+
"""``customer`` in :class:`InvoiceData`."""
|
|
253
|
+
|
|
254
|
+
company: str
|
|
255
|
+
email: str
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
class _InvoiceItemRequired(TypedDict):
|
|
259
|
+
"""Required keys of :class:`InvoiceItem`."""
|
|
260
|
+
|
|
261
|
+
description: str
|
|
262
|
+
# [>= 0]
|
|
263
|
+
qty: float
|
|
264
|
+
unit_price: float
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
class InvoiceItem(_InvoiceItemRequired, total=False):
|
|
268
|
+
"""``items[]`` in :class:`InvoiceData`."""
|
|
269
|
+
|
|
270
|
+
detail: str
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
class InvoiceDiscount(TypedDict):
|
|
274
|
+
"""``discount`` in :class:`InvoiceData`."""
|
|
275
|
+
|
|
276
|
+
label: str
|
|
277
|
+
# [0 to 1]
|
|
278
|
+
rate: float
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
class InvoiceTax(TypedDict):
|
|
282
|
+
"""``tax`` in :class:`InvoiceData`."""
|
|
283
|
+
|
|
284
|
+
label: str
|
|
285
|
+
# [0 to 1]
|
|
286
|
+
rate: float
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
class InvoicePayment(TypedDict, total=False):
|
|
290
|
+
"""``payment`` in :class:`InvoiceData`."""
|
|
291
|
+
|
|
292
|
+
method: str
|
|
293
|
+
link: str
|
|
294
|
+
bank: str
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
class _InvoiceDataRequired(TypedDict):
|
|
298
|
+
"""Required keys of :class:`InvoiceData`."""
|
|
299
|
+
|
|
300
|
+
# Invoice number, e.g. INV-2026-0142
|
|
301
|
+
number: str
|
|
302
|
+
# Issue date, pre-formatted for display
|
|
303
|
+
issued: str
|
|
304
|
+
# Due date, pre-formatted for display
|
|
305
|
+
due: str
|
|
306
|
+
seller: InvoiceSeller
|
|
307
|
+
customer: InvoiceCustomer
|
|
308
|
+
# [at least 1 item]
|
|
309
|
+
items: List[InvoiceItem]
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
class InvoiceData(_InvoiceDataRequired, total=False):
|
|
313
|
+
"""Data for the ``invoice`` template (Invoice)."""
|
|
314
|
+
|
|
315
|
+
# [default "due"]
|
|
316
|
+
status: Literal["draft", "due", "paid", "overdue", "void"]
|
|
317
|
+
# Currency symbol prefix [default "$"; max 4 chars]
|
|
318
|
+
currency: str
|
|
319
|
+
# Brand colour, e.g. #3b5bdb [pattern ^#[0-9a-fA-F]{6}$]
|
|
320
|
+
accent: str
|
|
321
|
+
discount: InvoiceDiscount
|
|
322
|
+
tax: InvoiceTax
|
|
323
|
+
payment: InvoicePayment
|
|
324
|
+
notes: str
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
class _QuoteSellerRequired(TypedDict):
|
|
328
|
+
"""Required keys of :class:`QuoteSeller`."""
|
|
329
|
+
|
|
330
|
+
# [non-empty]
|
|
331
|
+
name: str
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
class QuoteSeller(_QuoteSellerRequired, total=False):
|
|
335
|
+
"""The business preparing the quote (shown as 'Prepared by')"""
|
|
336
|
+
|
|
337
|
+
# Person responsible, e.g. 'Jonas Berg, Account Director'
|
|
338
|
+
contact: str
|
|
339
|
+
email: str
|
|
340
|
+
phone: str
|
|
341
|
+
address: List[str]
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
class _QuoteCustomerRequired(TypedDict):
|
|
345
|
+
"""Required keys of :class:`QuoteCustomer`."""
|
|
346
|
+
|
|
347
|
+
# [non-empty]
|
|
348
|
+
name: str
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
class QuoteCustomer(_QuoteCustomerRequired, total=False):
|
|
352
|
+
"""The client receiving the quote (shown as 'Prepared for')"""
|
|
353
|
+
|
|
354
|
+
company: str
|
|
355
|
+
address: List[str]
|
|
356
|
+
email: str
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
class _QuoteSectionItemRequired(TypedDict):
|
|
360
|
+
"""Required keys of :class:`QuoteSectionItem`."""
|
|
361
|
+
|
|
362
|
+
description: str
|
|
363
|
+
# [>= 0]
|
|
364
|
+
qty: float
|
|
365
|
+
unit_price: float
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
class QuoteSectionItem(_QuoteSectionItemRequired, total=False):
|
|
369
|
+
"""``sections[].items[]`` in :class:`QuoteData`."""
|
|
370
|
+
|
|
371
|
+
detail: str
|
|
372
|
+
# Shown after the quantity, e.g. hrs, days [max 12 chars]
|
|
373
|
+
unit: str
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
class _QuoteSectionRequired(TypedDict):
|
|
377
|
+
"""Required keys of :class:`QuoteSection`."""
|
|
378
|
+
|
|
379
|
+
# [at least 1 item]
|
|
380
|
+
items: List[QuoteSectionItem]
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
class QuoteSection(_QuoteSectionRequired, total=False):
|
|
384
|
+
"""``sections[]`` in :class:`QuoteData`."""
|
|
385
|
+
|
|
386
|
+
title: str
|
|
387
|
+
# Short muted text shown next to the section title
|
|
388
|
+
note: str
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
class QuoteDiscount(TypedDict):
|
|
392
|
+
"""``discount`` in :class:`QuoteData`."""
|
|
393
|
+
|
|
394
|
+
label: str
|
|
395
|
+
# [0 to 1]
|
|
396
|
+
rate: float
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
class QuoteTax(TypedDict):
|
|
400
|
+
"""``tax`` in :class:`QuoteData`."""
|
|
401
|
+
|
|
402
|
+
label: str
|
|
403
|
+
# [0 to 1]
|
|
404
|
+
rate: float
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
class _QuoteDataRequired(TypedDict):
|
|
408
|
+
"""Required keys of :class:`QuoteData`."""
|
|
409
|
+
|
|
410
|
+
# Quote number, e.g. Q-2026-0057 [non-empty]
|
|
411
|
+
number: str
|
|
412
|
+
# Issue date, pre-formatted for display [non-empty]
|
|
413
|
+
issued: str
|
|
414
|
+
# Expiry date, pre-formatted for display [non-empty]
|
|
415
|
+
valid_until: str
|
|
416
|
+
# The business preparing the quote (shown as 'Prepared by')
|
|
417
|
+
seller: QuoteSeller
|
|
418
|
+
# The client receiving the quote (shown as 'Prepared for')
|
|
419
|
+
customer: QuoteCustomer
|
|
420
|
+
# Line items grouped into sections. Use a single section without a title for an ungrouped quote.
|
|
421
|
+
# Titles and subtotals are shown when there is more than one section. [at least 1 item]
|
|
422
|
+
sections: List[QuoteSection]
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
class QuoteData(_QuoteDataRequired, total=False):
|
|
426
|
+
"""Data for the ``quote`` template (Quote)."""
|
|
427
|
+
|
|
428
|
+
# [default "sent"]
|
|
429
|
+
status: Literal["draft", "sent", "accepted", "expired"]
|
|
430
|
+
# Currency symbol prefix [default "$"; max 4 chars]
|
|
431
|
+
currency: str
|
|
432
|
+
# Brand colour, e.g. #c2410c [pattern ^#[0-9a-fA-F]{6}$]
|
|
433
|
+
accent: str
|
|
434
|
+
# Short project title
|
|
435
|
+
project: str
|
|
436
|
+
# One-paragraph project summary
|
|
437
|
+
summary: str
|
|
438
|
+
discount: QuoteDiscount
|
|
439
|
+
tax: QuoteTax
|
|
440
|
+
terms: List[str]
|
|
441
|
+
# Text above the signature lines. Defaults to a sign-and-return instruction.
|
|
442
|
+
acceptance_note: str
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
class _ReceiptSellerRequired(TypedDict):
|
|
446
|
+
"""Required keys of :class:`ReceiptSeller`."""
|
|
447
|
+
|
|
448
|
+
# [non-empty]
|
|
449
|
+
name: str
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
class ReceiptSeller(_ReceiptSellerRequired, total=False):
|
|
453
|
+
"""``seller`` in :class:`ReceiptData`."""
|
|
454
|
+
|
|
455
|
+
# Shown under the brand name
|
|
456
|
+
email: str
|
|
457
|
+
address: List[str]
|
|
458
|
+
tax_id: str
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
class _ReceiptCustomerRequired(TypedDict):
|
|
462
|
+
"""Required keys of :class:`ReceiptCustomer`."""
|
|
463
|
+
|
|
464
|
+
# [non-empty]
|
|
465
|
+
name: str
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
class ReceiptCustomer(_ReceiptCustomerRequired, total=False):
|
|
469
|
+
"""``customer`` in :class:`ReceiptData`."""
|
|
470
|
+
|
|
471
|
+
company: str
|
|
472
|
+
address: List[str]
|
|
473
|
+
email: str
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
class _ReceiptPaymentRequired(TypedDict):
|
|
477
|
+
"""Required keys of :class:`ReceiptPayment`."""
|
|
478
|
+
|
|
479
|
+
# e.g. Visa •••• 4242 [non-empty]
|
|
480
|
+
method: str
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
class ReceiptPayment(_ReceiptPaymentRequired, total=False):
|
|
484
|
+
"""``payment`` in :class:`ReceiptData`."""
|
|
485
|
+
|
|
486
|
+
# [max 40 chars]
|
|
487
|
+
transaction_id: str
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
class _ReceiptItemRequired(TypedDict):
|
|
491
|
+
"""Required keys of :class:`ReceiptItem`."""
|
|
492
|
+
|
|
493
|
+
description: str
|
|
494
|
+
# [>= 0]
|
|
495
|
+
qty: float
|
|
496
|
+
unit_price: float
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
class ReceiptItem(_ReceiptItemRequired, total=False):
|
|
500
|
+
"""``items[]`` in :class:`ReceiptData`."""
|
|
501
|
+
|
|
502
|
+
detail: str
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
class ReceiptDiscount(TypedDict):
|
|
506
|
+
"""``discount`` in :class:`ReceiptData`."""
|
|
507
|
+
|
|
508
|
+
label: str
|
|
509
|
+
# [0 to 1]
|
|
510
|
+
rate: float
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
class ReceiptTax(TypedDict):
|
|
514
|
+
"""``tax`` in :class:`ReceiptData`."""
|
|
515
|
+
|
|
516
|
+
label: str
|
|
517
|
+
# [0 to 1]
|
|
518
|
+
rate: float
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
class ReceiptSupport(TypedDict, total=False):
|
|
522
|
+
"""``support`` in :class:`ReceiptData`."""
|
|
523
|
+
|
|
524
|
+
# [default "Questions?"]
|
|
525
|
+
title: str
|
|
526
|
+
email: str
|
|
527
|
+
phone: str
|
|
528
|
+
url: str
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
class _ReceiptNoteRequired(TypedDict):
|
|
532
|
+
"""Required keys of :class:`ReceiptNote`."""
|
|
533
|
+
|
|
534
|
+
text: str
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
class ReceiptNote(_ReceiptNoteRequired, total=False):
|
|
538
|
+
"""``note`` in :class:`ReceiptData`."""
|
|
539
|
+
|
|
540
|
+
# e.g. Refund policy [default "Notes"]
|
|
541
|
+
title: str
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
class _ReceiptDataRequired(TypedDict):
|
|
545
|
+
"""Required keys of :class:`ReceiptData`."""
|
|
546
|
+
|
|
547
|
+
# Receipt number, e.g. RCPT-2026-0388 [non-empty]
|
|
548
|
+
number: str
|
|
549
|
+
# Payment date, pre-formatted for display [non-empty]
|
|
550
|
+
paid_on: str
|
|
551
|
+
seller: ReceiptSeller
|
|
552
|
+
customer: ReceiptCustomer
|
|
553
|
+
payment: ReceiptPayment
|
|
554
|
+
# [at least 1 item]
|
|
555
|
+
items: List[ReceiptItem]
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
class ReceiptData(_ReceiptDataRequired, total=False):
|
|
559
|
+
"""Data for the ``receipt`` template (Receipt)."""
|
|
560
|
+
|
|
561
|
+
# Currency symbol prefix [default "$"; max 4 chars]
|
|
562
|
+
currency: str
|
|
563
|
+
# Brand colour, e.g. #0b7285 [pattern ^#[0-9a-fA-F]{6}$]
|
|
564
|
+
accent: str
|
|
565
|
+
discount: ReceiptDiscount
|
|
566
|
+
tax: ReceiptTax
|
|
567
|
+
support: ReceiptSupport
|
|
568
|
+
note: ReceiptNote
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
class _StatementSellerRequired(TypedDict):
|
|
572
|
+
"""Required keys of :class:`StatementSeller`."""
|
|
573
|
+
|
|
574
|
+
# [non-empty]
|
|
575
|
+
name: str
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
class StatementSeller(_StatementSellerRequired, total=False):
|
|
579
|
+
"""The business issuing the statement. The address is shown under 'Remit to'."""
|
|
580
|
+
|
|
581
|
+
# Shown under the brand name
|
|
582
|
+
email: str
|
|
583
|
+
address: List[str]
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
class _StatementCustomerRequired(TypedDict):
|
|
587
|
+
"""Required keys of :class:`StatementCustomer`."""
|
|
588
|
+
|
|
589
|
+
# [non-empty]
|
|
590
|
+
name: str
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
class StatementCustomer(_StatementCustomerRequired, total=False):
|
|
594
|
+
"""The account holder (shown as 'Statement for')"""
|
|
595
|
+
|
|
596
|
+
company: str
|
|
597
|
+
# Customer account number [max 30 chars]
|
|
598
|
+
account: str
|
|
599
|
+
address: List[str]
|
|
600
|
+
email: str
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
class _StatementEntryRequired(TypedDict):
|
|
604
|
+
"""Required keys of :class:`StatementEntry`."""
|
|
605
|
+
|
|
606
|
+
# Pre-formatted, e.g. 4 Sep [1 to 16 chars]
|
|
607
|
+
date: str
|
|
608
|
+
# [non-empty]
|
|
609
|
+
description: str
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
class StatementEntry(_StatementEntryRequired, total=False):
|
|
613
|
+
"""``entries[]`` in :class:`StatementData`."""
|
|
614
|
+
|
|
615
|
+
# e.g. INV-2026-0431 [max 18 chars]
|
|
616
|
+
reference: str
|
|
617
|
+
# [>= 0]
|
|
618
|
+
charge: float
|
|
619
|
+
# [>= 0]
|
|
620
|
+
payment: float
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
class StatementAging(TypedDict):
|
|
624
|
+
"""Outstanding balance by age"""
|
|
625
|
+
|
|
626
|
+
current: float
|
|
627
|
+
days_1_30: float
|
|
628
|
+
days_31_60: float
|
|
629
|
+
days_61_90: float
|
|
630
|
+
days_90_plus: float
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
class StatementPayment(TypedDict, total=False):
|
|
634
|
+
"""Payment instructions"""
|
|
635
|
+
|
|
636
|
+
method: str
|
|
637
|
+
# [max 80 chars]
|
|
638
|
+
link: str
|
|
639
|
+
bank: str
|
|
640
|
+
# Reference the customer should quote when paying [max 30 chars]
|
|
641
|
+
reference: str
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
class _StatementDataRequired(TypedDict):
|
|
645
|
+
"""Required keys of :class:`StatementData`."""
|
|
646
|
+
|
|
647
|
+
# Statement date, pre-formatted for display, e.g. 30 Sep 2026 [non-empty]
|
|
648
|
+
date: str
|
|
649
|
+
# Statement period, pre-formatted, e.g. 1 Sep – 30 Sep 2026 [1 to 60 chars]
|
|
650
|
+
period: str
|
|
651
|
+
# Balance at the start of the period. Negative means the customer is in credit.
|
|
652
|
+
opening_balance: float
|
|
653
|
+
# The business issuing the statement. The address is shown under 'Remit to'.
|
|
654
|
+
seller: StatementSeller
|
|
655
|
+
# The account holder (shown as 'Statement for')
|
|
656
|
+
customer: StatementCustomer
|
|
657
|
+
# Activity in the period, oldest first. The running balance is computed from opening_balance.
|
|
658
|
+
# Put invoices and debits in 'charge', payments and credits in 'payment'.
|
|
659
|
+
entries: List[StatementEntry]
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
class StatementData(_StatementDataRequired, total=False):
|
|
663
|
+
"""Data for the ``statement`` template (Statement of account)."""
|
|
664
|
+
|
|
665
|
+
# When the balance is due, pre-formatted for display
|
|
666
|
+
due_date: str
|
|
667
|
+
# Badge next to the balance. Defaults to 'due' when the closing balance is positive, 'paid' when
|
|
668
|
+
# it is zero and 'credit' when it is negative.
|
|
669
|
+
status: Literal["due", "overdue", "paid", "credit"]
|
|
670
|
+
# Currency symbol prefix [default "$"; max 4 chars]
|
|
671
|
+
currency: str
|
|
672
|
+
# Brand colour, e.g. #b45309 [pattern ^#[0-9a-fA-F]{6}$]
|
|
673
|
+
accent: str
|
|
674
|
+
# Outstanding balance by age
|
|
675
|
+
aging: StatementAging
|
|
676
|
+
# Payment instructions
|
|
677
|
+
payment: StatementPayment
|
|
678
|
+
# [max 600 chars]
|
|
679
|
+
notes: str
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
DocumentData = Dict[str, Any]
|
|
683
|
+
"""The ``document`` template accepts any JSON object: reserved keys shape the header, every other
|
|
684
|
+
key becomes a section."""
|
|
685
|
+
|
|
686
|
+
TemplateName = Literal["certificate", "credit-note", "document", "invoice", "quote", "receipt", "statement"]
|
|
687
|
+
"""Names of the templates these types were generated from."""
|
|
688
|
+
|
|
689
|
+
TEMPLATE_DATA_TYPES: Dict[str, Any] = {
|
|
690
|
+
"certificate": CertificateData,
|
|
691
|
+
"credit-note": CreditNoteData,
|
|
692
|
+
"document": DocumentData,
|
|
693
|
+
"invoice": InvoiceData,
|
|
694
|
+
"quote": QuoteData,
|
|
695
|
+
"receipt": ReceiptData,
|
|
696
|
+
"statement": StatementData,
|
|
697
|
+
}
|
|
698
|
+
"""Template name -> its data type."""
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: quirepdf
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for the Quire API: send JSON, get back a polished PDF.
|
|
5
|
+
Project-URL: Homepage, https://quirepdf.dev
|
|
6
|
+
Project-URL: Documentation, https://quirepdf.dev/docs/sdk-python
|
|
7
|
+
Project-URL: Pricing, https://quirepdf.dev/pricing
|
|
8
|
+
Author-email: Quire PDF <support@quirepdf.dev>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: invoice,json-to-pdf,pdf,pdf-generation,quire,receipt,typst
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Classifier: Topic :: Office/Business
|
|
17
|
+
Classifier: Topic :: Printing
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Python: >=3.9
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# quirepdf (Python)
|
|
23
|
+
|
|
24
|
+
Send JSON, get back a polished PDF. Python 3.9+, standard library only, synchronous.
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
pip install quirepdf
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Quickstart
|
|
33
|
+
|
|
34
|
+
Any JSON object renders. Quire picks the best-matching template, or a clean generic layout:
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from quirepdf import Quire
|
|
38
|
+
|
|
39
|
+
client = Quire() # reads QUIRE_API_KEY (and QUIRE_API_URL)
|
|
40
|
+
|
|
41
|
+
order = {
|
|
42
|
+
"order_id": "ORD-88213",
|
|
43
|
+
"customer": {"name": "Lena Fischer", "email": "lena@example.de"},
|
|
44
|
+
"lines": [{"sku": "TS-BLK-M", "name": "Organic tee", "qty": 2, "price": 29.0}],
|
|
45
|
+
"currency": "€",
|
|
46
|
+
}
|
|
47
|
+
result = client.render(order)
|
|
48
|
+
result.save("order.pdf")
|
|
49
|
+
print(result.template, result.template_source, result.pages) # document fallback 1
|
|
50
|
+
if result.hint:
|
|
51
|
+
print("almost matched:", result.hint) # e.g. "invoice (seller is required)"
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Name a template and use the generated types so your editor checks the fields:
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
from quirepdf.types import InvoiceData
|
|
58
|
+
|
|
59
|
+
invoice: InvoiceData = {
|
|
60
|
+
"number": "INV-2026-0142",
|
|
61
|
+
"issued": "1 Oct 2026",
|
|
62
|
+
"due": "15 Oct 2026",
|
|
63
|
+
"seller": {"name": "Northwind Studio", "address": ["221 Market Street", "San Francisco, CA 94105"]},
|
|
64
|
+
"customer": {"name": "Ravi Kumar", "address": ["14 Residency Road", "Bengaluru 560025"]},
|
|
65
|
+
"items": [{"description": "Pro plan", "qty": 1, "unit_price": 49}],
|
|
66
|
+
"status": "due", # Literal["draft", "due", "paid", "overdue", "void"]
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
pdf = client.render(invoice, template="invoice")
|
|
70
|
+
png = client.render(invoice, template="invoice", format="png", page=1) # preview one page
|
|
71
|
+
print(pdf.content[:5], len(png.content), pdf.render_ms, pdf.quota) # Quota(limit=100, remaining=97)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
`RenderResult` fields: `content` (the file as `bytes`), `content_type`, `pages`, `template`,
|
|
75
|
+
`template_source` (`explicit` | `detected` | `fallback`), `hint`, `render_ms`, `quota`, and `save(path)`.
|
|
76
|
+
|
|
77
|
+
Other calls:
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
check = client.validate(invoice, template="invoice") # free, nothing rendered
|
|
81
|
+
check.valid, check.errors # False, [{"path": "...", "message": "..."}]
|
|
82
|
+
|
|
83
|
+
client.templates.list() # [{"name": "invoice", "title": "Invoice", ...}, ...]
|
|
84
|
+
client.templates.get("invoice") # {"template": {...}, "schema": {...}, "sample": {...}}
|
|
85
|
+
client.usage() # {"plan": "free", "used": 3, "limit": 100, "remaining": 97, ...}
|
|
86
|
+
|
|
87
|
+
new = client.keys.create(name="ci") # new["api_key"] is shown only once
|
|
88
|
+
client.keys.list() # [{"id", "prefix", "name", "current": bool, ...}]
|
|
89
|
+
client.keys.revoke(new["key"]["id"])
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Errors
|
|
93
|
+
|
|
94
|
+
Every failure raises `QuireError` with `status`, `type`, `message` and `fields`:
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
from quirepdf import QuireError
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
client.render({"number": "INV-1"}, template="invoice")
|
|
101
|
+
except QuireError as err:
|
|
102
|
+
print(err.status, err.type) # 422 invalid_data
|
|
103
|
+
print(err) # data has 5 problems
|
|
104
|
+
# - issued is required
|
|
105
|
+
# - due is required ...
|
|
106
|
+
for f in err.fields:
|
|
107
|
+
print(f["path"], f["message"])
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Common types: `invalid_data`, `unknown_template`, `unauthorized`, `quota_exceeded`, `bad_request`,
|
|
111
|
+
`payload_too_large`, `timeout`. Client-side failures have `status == 0`: `type == "network"`
|
|
112
|
+
(can't connect) or `type == "timeout"` (no reply within `timeout` seconds). The SDK never retries.
|
|
113
|
+
|
|
114
|
+
## Configuration
|
|
115
|
+
|
|
116
|
+
| Argument | Environment | Default |
|
|
117
|
+
|------------|-----------------|-------------------------|
|
|
118
|
+
| `api_key` | `QUIRE_API_KEY` | none (no `Authorization` header is sent) |
|
|
119
|
+
| `base_url` | `QUIRE_API_URL` | `https://api.quirepdf.dev` |
|
|
120
|
+
| `timeout` | | `30.0` seconds |
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
client = Quire(api_key="qk_...", base_url="http://127.0.0.1:8787", timeout=10)
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
A local engine in open mode (`make serve`) needs no key.
|
|
127
|
+
|
|
128
|
+
## Development
|
|
129
|
+
|
|
130
|
+
```sh
|
|
131
|
+
python3 scripts/gen_types.py # regenerate src/quirepdf/types.py after a schema change
|
|
132
|
+
python3 scripts/gen_types.py --check # fail if it is stale
|
|
133
|
+
PYTHONPATH=src python3 -m unittest discover -s tests -v # runs against engine/target/release/quire-engine (make build)
|
|
134
|
+
```
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
quirepdf/__init__.py,sha256=I1ZxHwLsv_gaDc3Eav5jveiVUcAwEaYTyQ7dN44HCDA,732
|
|
2
|
+
quirepdf/client.py,sha256=MtgKs_WXeU-9a-6vWAEHyWXphqha0RBE7h_LkV2-RPA,11494
|
|
3
|
+
quirepdf/errors.py,sha256=FqI6wgOW_3WuS21jVe315m4_r_bu8N_CZuCh3j9Csq8,2810
|
|
4
|
+
quirepdf/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
quirepdf/types.py,sha256=1YbV4k9AgfU9j39R8Q-BeflbT7jokceBUgxZAus-8f8,17679
|
|
6
|
+
quirepdf-0.1.0.dist-info/METADATA,sha256=nBQb3s742354v09kV2PQUw2544RvffEgVeXeJFxwRa0,4948
|
|
7
|
+
quirepdf-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
8
|
+
quirepdf-0.1.0.dist-info/licenses/LICENSE,sha256=ZW-qhkKnBmVhrfiWVu5aSfxUhhwaL-LHGLcCaJNYzwo,1066
|
|
9
|
+
quirepdf-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Quire PDF
|
|
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.
|