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.
- quirepdf-0.1.0/.gitignore +14 -0
- quirepdf-0.1.0/LICENSE +21 -0
- quirepdf-0.1.0/PKG-INFO +134 -0
- quirepdf-0.1.0/README.md +113 -0
- quirepdf-0.1.0/pyproject.toml +35 -0
- quirepdf-0.1.0/src/quirepdf/__init__.py +38 -0
- quirepdf-0.1.0/src/quirepdf/client.py +322 -0
- quirepdf-0.1.0/src/quirepdf/errors.py +63 -0
- quirepdf-0.1.0/src/quirepdf/py.typed +0 -0
- quirepdf-0.1.0/src/quirepdf/types.py +698 -0
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.
|
quirepdf-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
+
```
|
quirepdf-0.1.0/README.md
ADDED
|
@@ -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
|