quirepdf 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.
@@ -0,0 +1,14 @@
1
+ engine/target/
2
+ node_modules/
3
+ site/dist/
4
+ site/.astro/
5
+ sdk-js/dist/
6
+ mcp/dist/
7
+ __pycache__/
8
+ *.egg-info/
9
+ out/
10
+ .DS_Store
11
+ *.log
12
+ # Local secrets (billing keys etc.); never commit.
13
+ .env.local
14
+ *.env.local
quirepdf-0.1.0/LICENSE ADDED
@@ -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.
@@ -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,113 @@
1
+ # quirepdf (Python)
2
+
3
+ Send JSON, get back a polished PDF. Python 3.9+, standard library only, synchronous.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ pip install quirepdf
9
+ ```
10
+
11
+ ## Quickstart
12
+
13
+ Any JSON object renders. Quire picks the best-matching template, or a clean generic layout:
14
+
15
+ ```python
16
+ from quirepdf import Quire
17
+
18
+ client = Quire() # reads QUIRE_API_KEY (and QUIRE_API_URL)
19
+
20
+ order = {
21
+ "order_id": "ORD-88213",
22
+ "customer": {"name": "Lena Fischer", "email": "lena@example.de"},
23
+ "lines": [{"sku": "TS-BLK-M", "name": "Organic tee", "qty": 2, "price": 29.0}],
24
+ "currency": "€",
25
+ }
26
+ result = client.render(order)
27
+ result.save("order.pdf")
28
+ print(result.template, result.template_source, result.pages) # document fallback 1
29
+ if result.hint:
30
+ print("almost matched:", result.hint) # e.g. "invoice (seller is required)"
31
+ ```
32
+
33
+ Name a template and use the generated types so your editor checks the fields:
34
+
35
+ ```python
36
+ from quirepdf.types import InvoiceData
37
+
38
+ invoice: InvoiceData = {
39
+ "number": "INV-2026-0142",
40
+ "issued": "1 Oct 2026",
41
+ "due": "15 Oct 2026",
42
+ "seller": {"name": "Northwind Studio", "address": ["221 Market Street", "San Francisco, CA 94105"]},
43
+ "customer": {"name": "Ravi Kumar", "address": ["14 Residency Road", "Bengaluru 560025"]},
44
+ "items": [{"description": "Pro plan", "qty": 1, "unit_price": 49}],
45
+ "status": "due", # Literal["draft", "due", "paid", "overdue", "void"]
46
+ }
47
+
48
+ pdf = client.render(invoice, template="invoice")
49
+ png = client.render(invoice, template="invoice", format="png", page=1) # preview one page
50
+ print(pdf.content[:5], len(png.content), pdf.render_ms, pdf.quota) # Quota(limit=100, remaining=97)
51
+ ```
52
+
53
+ `RenderResult` fields: `content` (the file as `bytes`), `content_type`, `pages`, `template`,
54
+ `template_source` (`explicit` | `detected` | `fallback`), `hint`, `render_ms`, `quota`, and `save(path)`.
55
+
56
+ Other calls:
57
+
58
+ ```python
59
+ check = client.validate(invoice, template="invoice") # free, nothing rendered
60
+ check.valid, check.errors # False, [{"path": "...", "message": "..."}]
61
+
62
+ client.templates.list() # [{"name": "invoice", "title": "Invoice", ...}, ...]
63
+ client.templates.get("invoice") # {"template": {...}, "schema": {...}, "sample": {...}}
64
+ client.usage() # {"plan": "free", "used": 3, "limit": 100, "remaining": 97, ...}
65
+
66
+ new = client.keys.create(name="ci") # new["api_key"] is shown only once
67
+ client.keys.list() # [{"id", "prefix", "name", "current": bool, ...}]
68
+ client.keys.revoke(new["key"]["id"])
69
+ ```
70
+
71
+ ## Errors
72
+
73
+ Every failure raises `QuireError` with `status`, `type`, `message` and `fields`:
74
+
75
+ ```python
76
+ from quirepdf import QuireError
77
+
78
+ try:
79
+ client.render({"number": "INV-1"}, template="invoice")
80
+ except QuireError as err:
81
+ print(err.status, err.type) # 422 invalid_data
82
+ print(err) # data has 5 problems
83
+ # - issued is required
84
+ # - due is required ...
85
+ for f in err.fields:
86
+ print(f["path"], f["message"])
87
+ ```
88
+
89
+ Common types: `invalid_data`, `unknown_template`, `unauthorized`, `quota_exceeded`, `bad_request`,
90
+ `payload_too_large`, `timeout`. Client-side failures have `status == 0`: `type == "network"`
91
+ (can't connect) or `type == "timeout"` (no reply within `timeout` seconds). The SDK never retries.
92
+
93
+ ## Configuration
94
+
95
+ | Argument | Environment | Default |
96
+ |------------|-----------------|-------------------------|
97
+ | `api_key` | `QUIRE_API_KEY` | none (no `Authorization` header is sent) |
98
+ | `base_url` | `QUIRE_API_URL` | `https://api.quirepdf.dev` |
99
+ | `timeout` | | `30.0` seconds |
100
+
101
+ ```python
102
+ client = Quire(api_key="qk_...", base_url="http://127.0.0.1:8787", timeout=10)
103
+ ```
104
+
105
+ A local engine in open mode (`make serve`) needs no key.
106
+
107
+ ## Development
108
+
109
+ ```sh
110
+ python3 scripts/gen_types.py # regenerate src/quirepdf/types.py after a schema change
111
+ python3 scripts/gen_types.py --check # fail if it is stale
112
+ PYTHONPATH=src python3 -m unittest discover -s tests -v # runs against engine/target/release/quire-engine (make build)
113
+ ```
@@ -0,0 +1,35 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.18"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "quirepdf"
7
+ version = "0.1.0"
8
+ description = "Python SDK for the Quire API: send JSON, get back a polished PDF."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "Quire PDF", email = "support@quirepdf.dev" }]
14
+ keywords = ["pdf", "pdf-generation", "invoice", "receipt", "json-to-pdf", "typst", "quire"]
15
+ dependencies = []
16
+ classifiers = [
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3 :: Only",
19
+ "Operating System :: OS Independent",
20
+ "Intended Audience :: Developers",
21
+ "Topic :: Office/Business",
22
+ "Topic :: Printing",
23
+ "Typing :: Typed",
24
+ ]
25
+
26
+ [project.urls]
27
+ Homepage = "https://quirepdf.dev"
28
+ Documentation = "https://quirepdf.dev/docs/sdk-python"
29
+ Pricing = "https://quirepdf.dev/pricing"
30
+
31
+ [tool.hatch.build.targets.wheel]
32
+ packages = ["src/quirepdf"]
33
+
34
+ [tool.hatch.build.targets.sdist]
35
+ include = ["src/quirepdf", "README.md", "LICENSE"]
@@ -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
+ ]
@@ -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