typefold-client 0.2.1__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.
- typefold/__init__.py +90 -0
- typefold/_client.py +679 -0
- typefold/_errors.py +23 -0
- typefold/models.py +609 -0
- typefold/py.typed +0 -0
- typefold_client-0.2.1.dist-info/METADATA +188 -0
- typefold_client-0.2.1.dist-info/RECORD +8 -0
- typefold_client-0.2.1.dist-info/WHEEL +4 -0
typefold/_client.py
ADDED
|
@@ -0,0 +1,679 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import dataclasses
|
|
5
|
+
import itertools
|
|
6
|
+
import json
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Generator, Sequence
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from datetime import date, datetime, timedelta, timezone
|
|
11
|
+
from decimal import Decimal
|
|
12
|
+
from email.utils import parsedate_to_datetime
|
|
13
|
+
from enum import Enum
|
|
14
|
+
from typing import Any, TypeVar
|
|
15
|
+
from urllib.parse import quote
|
|
16
|
+
from uuid import UUID
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
|
|
20
|
+
from ._errors import TypefoldError
|
|
21
|
+
from .models import (
|
|
22
|
+
ApiError,
|
|
23
|
+
DirectRenderRequest,
|
|
24
|
+
Render,
|
|
25
|
+
RenderBatch,
|
|
26
|
+
RenderReportRequest,
|
|
27
|
+
RenderRequest,
|
|
28
|
+
RenderState,
|
|
29
|
+
RenderStringRequest,
|
|
30
|
+
ReportSummary,
|
|
31
|
+
TemplateRef,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
MAX_WAIT_MS = 10_000
|
|
35
|
+
FIRST_POLL_INTERVAL = 1.0
|
|
36
|
+
MAX_POLL_INTERVAL = 5.0
|
|
37
|
+
DEFAULT_WAIT_TIMEOUT = 120.0
|
|
38
|
+
DEFAULT_BATCH_WAIT_TIMEOUT = 600.0
|
|
39
|
+
DEFAULT_HTTP_TIMEOUT = 120.0
|
|
40
|
+
FIRST_RETRY_DELAY = 1.0
|
|
41
|
+
MAX_RETRY_DELAY = 30.0
|
|
42
|
+
_UNFINISHED = (RenderState.QUEUED, RenderState.PROCESSING)
|
|
43
|
+
|
|
44
|
+
T = TypeVar("T")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class _Http:
|
|
49
|
+
"""Asks the driver to send one HTTP request with these ``httpx`` arguments."""
|
|
50
|
+
|
|
51
|
+
request: dict[str, Any]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class _Sleep:
|
|
56
|
+
"""Asks the driver to wait."""
|
|
57
|
+
|
|
58
|
+
seconds: float
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# The client's logic is written once as generators that yield what to do; the blocking and the
|
|
62
|
+
# asyncio client each run them with their own I/O.
|
|
63
|
+
_Plan = Generator[_Http | _Sleep, Any, T]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class _Base:
|
|
67
|
+
_http: httpx.Client | httpx.AsyncClient
|
|
68
|
+
|
|
69
|
+
def __init__(self, base_url: str, api_key: str, max_rate_limit_retries: int) -> None:
|
|
70
|
+
if not base_url:
|
|
71
|
+
raise ValueError("base_url is required.")
|
|
72
|
+
|
|
73
|
+
if not api_key:
|
|
74
|
+
raise ValueError("api_key is required.")
|
|
75
|
+
|
|
76
|
+
# A trailing slash keeps a base path such as /pdf when paths are appended.
|
|
77
|
+
self._base_url = base_url.rstrip("/") + "/"
|
|
78
|
+
self._api_key = api_key
|
|
79
|
+
self.max_rate_limit_retries = max_rate_limit_retries
|
|
80
|
+
"""
|
|
81
|
+
How many times a request the API refused with ``429 RATE_LIMITED`` is sent again, after its
|
|
82
|
+
``Retry-After`` (else 1, 2, 4… seconds, at most 30). A refused request did no work, so any
|
|
83
|
+
request can be retried. Defaults to 3; 0 raises the first refusal.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
def _render_report(self, request: RenderReportRequest, idempotency_key: str | None) -> _Plan[bytes]:
|
|
87
|
+
response = yield from self._send("POST", "v1/reports/render", _dump(request), idempotency_key)
|
|
88
|
+
|
|
89
|
+
return response.content
|
|
90
|
+
|
|
91
|
+
def _render_string(self, request: RenderStringRequest) -> _Plan[str]:
|
|
92
|
+
response = yield from self._send("POST", "v1/renders/string", _dump(request))
|
|
93
|
+
|
|
94
|
+
return str(response.json()["content"])
|
|
95
|
+
|
|
96
|
+
def _list_reports(self) -> _Plan[list[ReportSummary]]:
|
|
97
|
+
response = yield from self._send("GET", "v1/reports")
|
|
98
|
+
|
|
99
|
+
return [ReportSummary.from_json(item) for item in response.json()["items"]]
|
|
100
|
+
|
|
101
|
+
def _get_report_data_schema(self, format: str, template: TemplateRef) -> _Plan[dict[str, Any]]:
|
|
102
|
+
response = yield from self._send("GET", _data_schema_path(format, template))
|
|
103
|
+
schema: dict[str, Any] = response.json()
|
|
104
|
+
|
|
105
|
+
return schema
|
|
106
|
+
|
|
107
|
+
def _create_render(self, request: RenderRequest, idempotency_key: str | None) -> _Plan[Render]:
|
|
108
|
+
response = yield from self._send("POST", "v1/renders", _dump(_with_margins(request)), idempotency_key)
|
|
109
|
+
|
|
110
|
+
return Render.from_json(response.json())
|
|
111
|
+
|
|
112
|
+
def _get_render(self, id: str, deadline: float | None = None) -> _Plan[Render]:
|
|
113
|
+
response = yield from self._send("GET", "v1/renders/" + quote(id, safe=""), deadline=deadline)
|
|
114
|
+
|
|
115
|
+
return Render.from_json(response.json())
|
|
116
|
+
|
|
117
|
+
def _cancel_render(self, id: str) -> _Plan[None]:
|
|
118
|
+
yield from self._send("POST", f"v1/renders/{quote(id, safe='')}/cancel")
|
|
119
|
+
|
|
120
|
+
def _wait_for_render(self, render: Render, timeout: float | None) -> _Plan[Render]:
|
|
121
|
+
deadline = time.monotonic() + (DEFAULT_WAIT_TIMEOUT if timeout is None else timeout)
|
|
122
|
+
interval = FIRST_POLL_INTERVAL
|
|
123
|
+
|
|
124
|
+
while render.state in _UNFINISHED:
|
|
125
|
+
remaining = deadline - time.monotonic()
|
|
126
|
+
|
|
127
|
+
if remaining <= 0:
|
|
128
|
+
raise _timeout_error(render)
|
|
129
|
+
|
|
130
|
+
yield _Sleep(min(remaining, interval))
|
|
131
|
+
interval = min(interval * 2, MAX_POLL_INTERVAL)
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
# Each poll ends by the deadline, rate-limit retries included.
|
|
135
|
+
render = yield from self._get_render(render.id, deadline)
|
|
136
|
+
except TypefoldError as error:
|
|
137
|
+
# Such as RATE_LIMITED; poll again.
|
|
138
|
+
if not error.retryable:
|
|
139
|
+
raise
|
|
140
|
+
except httpx.TransportError:
|
|
141
|
+
# A dropped connection, or a poll cut off at the deadline; poll again.
|
|
142
|
+
pass
|
|
143
|
+
|
|
144
|
+
return render
|
|
145
|
+
|
|
146
|
+
def _create_batch(self, items: Sequence[RenderRequest], idempotency_key: str | None) -> _Plan[RenderBatch]:
|
|
147
|
+
# Batches never wait, so wait_ms is not sent; set on copies so the caller's requests are unchanged.
|
|
148
|
+
body = {"items": [_dump(_with_margins(dataclasses.replace(item, wait_ms=None))) for item in items]}
|
|
149
|
+
response = yield from self._send("POST", "v1/renders/batch", body, idempotency_key)
|
|
150
|
+
|
|
151
|
+
return RenderBatch.from_json(response.json())
|
|
152
|
+
|
|
153
|
+
def _get_batch(self, id: str, deadline: float | None = None) -> _Plan[RenderBatch]:
|
|
154
|
+
response = yield from self._send("GET", "v1/renders/batch/" + quote(id, safe=""), deadline=deadline)
|
|
155
|
+
|
|
156
|
+
return RenderBatch.from_json(response.json())
|
|
157
|
+
|
|
158
|
+
def _wait_for_batch(self, batch: RenderBatch, timeout: float | None) -> _Plan[RenderBatch]:
|
|
159
|
+
deadline = time.monotonic() + (DEFAULT_BATCH_WAIT_TIMEOUT if timeout is None else timeout)
|
|
160
|
+
interval = FIRST_POLL_INTERVAL
|
|
161
|
+
|
|
162
|
+
while batch.counts.queued + batch.counts.processing > 0:
|
|
163
|
+
remaining = deadline - time.monotonic()
|
|
164
|
+
|
|
165
|
+
if remaining <= 0:
|
|
166
|
+
raise _batch_timeout_error(batch)
|
|
167
|
+
|
|
168
|
+
yield _Sleep(min(remaining, interval))
|
|
169
|
+
interval = min(interval * 2, MAX_POLL_INTERVAL)
|
|
170
|
+
|
|
171
|
+
try:
|
|
172
|
+
# Each poll ends by the deadline, rate-limit retries included.
|
|
173
|
+
batch = yield from self._get_batch(batch.id, deadline)
|
|
174
|
+
except TypefoldError as error:
|
|
175
|
+
# Such as RATE_LIMITED; poll again.
|
|
176
|
+
if not error.retryable:
|
|
177
|
+
raise
|
|
178
|
+
except httpx.TransportError:
|
|
179
|
+
# A dropped connection, or a poll cut off at the deadline; poll again.
|
|
180
|
+
pass
|
|
181
|
+
|
|
182
|
+
return batch
|
|
183
|
+
|
|
184
|
+
def _download_batch(self, id: str) -> _Plan[bytes]:
|
|
185
|
+
response = yield from self._send("GET", f"v1/renders/batch/{quote(id, safe='')}/pdf")
|
|
186
|
+
|
|
187
|
+
return response.content
|
|
188
|
+
|
|
189
|
+
def _download(self, render: Render) -> _Plan[bytes]:
|
|
190
|
+
if _url_expiring(render):
|
|
191
|
+
render = yield from self._get_render(render.id)
|
|
192
|
+
|
|
193
|
+
if render.result is None:
|
|
194
|
+
raise ValueError(f"Render {render.id} is {render.state} and has no PDF.")
|
|
195
|
+
|
|
196
|
+
# The signed URL may point at object storage, so the API key is not sent.
|
|
197
|
+
response = yield _Http({"method": "GET", "url": render.result.download_url})
|
|
198
|
+
|
|
199
|
+
return _ensure_success(response).content
|
|
200
|
+
|
|
201
|
+
def _render(self, request: RenderRequest, idempotency_key: str | None, timeout: float | None) -> _Plan[bytes]:
|
|
202
|
+
# The API holds creation up to 10 s; set on a copy so the caller's request is unchanged.
|
|
203
|
+
if request.wait_ms is None:
|
|
204
|
+
request = dataclasses.replace(request, wait_ms=MAX_WAIT_MS)
|
|
205
|
+
|
|
206
|
+
created = yield from self._create_render(request, idempotency_key)
|
|
207
|
+
render = yield from self._wait_for_render(created, timeout)
|
|
208
|
+
|
|
209
|
+
if render.state != RenderState.SUCCEEDED:
|
|
210
|
+
raise _failed_error(render)
|
|
211
|
+
|
|
212
|
+
return (yield from self._download(render))
|
|
213
|
+
|
|
214
|
+
def _send(
|
|
215
|
+
self,
|
|
216
|
+
method: str,
|
|
217
|
+
path: str,
|
|
218
|
+
body: Any = None,
|
|
219
|
+
idempotency_key: str | None = None,
|
|
220
|
+
deadline: float | None = None,
|
|
221
|
+
) -> _Plan[httpx.Response]:
|
|
222
|
+
"""Sends a request, retrying rate limits; with a ``deadline``, it and its retries end by then."""
|
|
223
|
+
request = self._request(method, path, body, idempotency_key)
|
|
224
|
+
delay = FIRST_RETRY_DELAY
|
|
225
|
+
|
|
226
|
+
for attempt in itertools.count():
|
|
227
|
+
if deadline is not None:
|
|
228
|
+
request["timeout"] = self._timeout_until(deadline)
|
|
229
|
+
|
|
230
|
+
response = yield _Http(request)
|
|
231
|
+
wait = self._retry_delay(response, attempt, delay)
|
|
232
|
+
|
|
233
|
+
if wait is None or (deadline is not None and time.monotonic() + wait >= deadline):
|
|
234
|
+
return _ensure_success(response)
|
|
235
|
+
|
|
236
|
+
yield _Sleep(wait)
|
|
237
|
+
delay = min(delay * 2, MAX_RETRY_DELAY)
|
|
238
|
+
|
|
239
|
+
raise AssertionError("unreachable")
|
|
240
|
+
|
|
241
|
+
def _request(self, method: str, path: str, body: Any, idempotency_key: str | None) -> dict[str, Any]:
|
|
242
|
+
# The key goes on each request rather than the httpx client, which callers may share.
|
|
243
|
+
headers = {"x-api-key": self._api_key}
|
|
244
|
+
|
|
245
|
+
if idempotency_key is not None:
|
|
246
|
+
headers["Idempotency-Key"] = idempotency_key
|
|
247
|
+
|
|
248
|
+
request: dict[str, Any] = {"method": method, "url": self._base_url + path, "headers": headers}
|
|
249
|
+
|
|
250
|
+
if body is not None:
|
|
251
|
+
headers["Content-Type"] = "application/json"
|
|
252
|
+
request["content"] = _json(body)
|
|
253
|
+
|
|
254
|
+
return request
|
|
255
|
+
|
|
256
|
+
def _timeout_until(self, deadline: float) -> httpx.Timeout:
|
|
257
|
+
"""The httpx client's timeout, shortened to end at ``deadline``."""
|
|
258
|
+
remaining = max(deadline - time.monotonic(), 0.001)
|
|
259
|
+
own = self._http.timeout
|
|
260
|
+
|
|
261
|
+
def shorter(value: float | None) -> float:
|
|
262
|
+
return remaining if value is None else min(value, remaining)
|
|
263
|
+
|
|
264
|
+
return httpx.Timeout(
|
|
265
|
+
connect=shorter(own.connect), read=shorter(own.read), write=shorter(own.write), pool=shorter(own.pool)
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
def _retry_delay(self, response: httpx.Response, attempt: int, delay: float) -> float | None:
|
|
269
|
+
"""Seconds to wait before sending a refused request again, or None to return the response."""
|
|
270
|
+
if response.status_code != 429 or attempt >= self.max_rate_limit_retries:
|
|
271
|
+
return None
|
|
272
|
+
|
|
273
|
+
wait = _retry_after(response)
|
|
274
|
+
|
|
275
|
+
return min(delay if wait is None else wait, MAX_RETRY_DELAY)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
class TypefoldClient(_Base):
|
|
279
|
+
"""Client for the Typefold API, authenticated with an organization API key.
|
|
280
|
+
|
|
281
|
+
Create one per API key and reuse it; close it, or use it as a context manager, when done.
|
|
282
|
+
"""
|
|
283
|
+
|
|
284
|
+
_http: httpx.Client
|
|
285
|
+
|
|
286
|
+
def __init__(
|
|
287
|
+
self,
|
|
288
|
+
base_url: str,
|
|
289
|
+
api_key: str,
|
|
290
|
+
*,
|
|
291
|
+
http_client: httpx.Client | None = None,
|
|
292
|
+
max_rate_limit_retries: int = 3,
|
|
293
|
+
) -> None:
|
|
294
|
+
"""
|
|
295
|
+
:param base_url: The API's address, such as ``https://pdf.example.com``.
|
|
296
|
+
:param api_key: An organization API key; report renders need ``render:create``,
|
|
297
|
+
``render:read`` and ``template:read``.
|
|
298
|
+
:param http_client: A shared ``httpx.Client``. Report PDFs can take up to 95 seconds, so give
|
|
299
|
+
it a timeout above that; the default client waits 120 seconds. It is not closed with this
|
|
300
|
+
client.
|
|
301
|
+
"""
|
|
302
|
+
super().__init__(base_url, api_key, max_rate_limit_retries)
|
|
303
|
+
self._owns_http = http_client is None
|
|
304
|
+
self._http = http_client or httpx.Client(timeout=DEFAULT_HTTP_TIMEOUT)
|
|
305
|
+
|
|
306
|
+
def close(self) -> None:
|
|
307
|
+
if self._owns_http:
|
|
308
|
+
self._http.close()
|
|
309
|
+
|
|
310
|
+
def __enter__(self) -> TypefoldClient:
|
|
311
|
+
return self
|
|
312
|
+
|
|
313
|
+
def __exit__(self, *args: object) -> None:
|
|
314
|
+
self.close()
|
|
315
|
+
|
|
316
|
+
def render_report(self, request: RenderReportRequest, *, idempotency_key: str | None = None) -> bytes:
|
|
317
|
+
"""Renders a published report or template and returns the PDF or XLSX bytes.
|
|
318
|
+
|
|
319
|
+
:param idempotency_key: Retrying a PDF render with the same key returns the render the key
|
|
320
|
+
first created.
|
|
321
|
+
"""
|
|
322
|
+
return self._run(self._render_report(request, idempotency_key))
|
|
323
|
+
|
|
324
|
+
def render_string(self, request: RenderStringRequest) -> str:
|
|
325
|
+
"""Renders a template string with data, synchronously and without a PDF, and returns the text
|
|
326
|
+
or HTML; for email bodies, subjects, SMS and push messages."""
|
|
327
|
+
return self._run(self._render_string(request))
|
|
328
|
+
|
|
329
|
+
def list_reports(self) -> list[ReportSummary]:
|
|
330
|
+
"""Lists templates and the formats each renders; render them by name, ID or jsreport shortid."""
|
|
331
|
+
return self._run(self._list_reports())
|
|
332
|
+
|
|
333
|
+
def get_report_data_schema(self, format: str, template: TemplateRef) -> dict[str, Any]:
|
|
334
|
+
"""Returns the JSON Schema of the data a published report renders.
|
|
335
|
+
|
|
336
|
+
:param format: ``ReportFormat.PDF`` or ``ReportFormat.XLSX``.
|
|
337
|
+
"""
|
|
338
|
+
return self._run(self._get_report_data_schema(format, template))
|
|
339
|
+
|
|
340
|
+
def create_render(self, request: RenderRequest, *, idempotency_key: str | None = None) -> Render:
|
|
341
|
+
"""Creates a render; it may still be queued or processing when this returns."""
|
|
342
|
+
return self._run(self._create_render(request, idempotency_key))
|
|
343
|
+
|
|
344
|
+
def get_render(self, id: str) -> Render:
|
|
345
|
+
return self._run(self._get_render(id))
|
|
346
|
+
|
|
347
|
+
def cancel_render(self, id: str) -> None:
|
|
348
|
+
self._run(self._cancel_render(id))
|
|
349
|
+
|
|
350
|
+
def wait_for_render(self, render: Render, *, timeout: float | None = None) -> Render:
|
|
351
|
+
"""Polls until the render finishes, backing off from 1 to 5 seconds; raises
|
|
352
|
+
``RENDER_TIMEOUT`` after ``timeout`` seconds (2 minutes). Dropped connections and retryable
|
|
353
|
+
API errors are polled through."""
|
|
354
|
+
return self._run(self._wait_for_render(render, timeout))
|
|
355
|
+
|
|
356
|
+
def download(self, render: Render) -> bytes:
|
|
357
|
+
"""Downloads a succeeded render's PDF, re-reading the render first if its signed URL has
|
|
358
|
+
expired."""
|
|
359
|
+
return self._run(self._download(render))
|
|
360
|
+
|
|
361
|
+
def render(
|
|
362
|
+
self,
|
|
363
|
+
request: RenderRequest,
|
|
364
|
+
*,
|
|
365
|
+
idempotency_key: str | None = None,
|
|
366
|
+
timeout: float | None = None,
|
|
367
|
+
) -> bytes:
|
|
368
|
+
"""Creates a render, waits for it and returns the PDF; a failed or cancelled render raises."""
|
|
369
|
+
return self._run(self._render(request, idempotency_key, timeout))
|
|
370
|
+
|
|
371
|
+
def create_batch(self, items: Sequence[RenderRequest], *, idempotency_key: str | None = None) -> RenderBatch:
|
|
372
|
+
"""Creates up to 100 renders in one request and returns the batch; if any item is invalid,
|
|
373
|
+
none is created. Items are ``DirectRenderRequest`` or ``TemplateVersionRenderRequest``;
|
|
374
|
+
their ``wait_ms`` is not sent, as batches never wait."""
|
|
375
|
+
return self._run(self._create_batch(items, idempotency_key))
|
|
376
|
+
|
|
377
|
+
def get_batch(self, id: str) -> RenderBatch:
|
|
378
|
+
return self._run(self._get_batch(id))
|
|
379
|
+
|
|
380
|
+
def wait_for_batch(self, batch: RenderBatch, *, timeout: float | None = None) -> RenderBatch:
|
|
381
|
+
"""Polls until no item is queued or processing, backing off from 1 to 5 seconds; raises
|
|
382
|
+
``RENDER_TIMEOUT`` after ``timeout`` seconds (10 minutes). Failed items do not stop the
|
|
383
|
+
others."""
|
|
384
|
+
return self._run(self._wait_for_batch(batch, timeout))
|
|
385
|
+
|
|
386
|
+
def download_batch(self, id: str) -> bytes:
|
|
387
|
+
"""Downloads one PDF of every item in order; every item must have succeeded."""
|
|
388
|
+
return self._run(self._download_batch(id))
|
|
389
|
+
|
|
390
|
+
def _run(self, plan: _Plan[T]) -> T:
|
|
391
|
+
sent: Any = None
|
|
392
|
+
error: Exception | None = None
|
|
393
|
+
|
|
394
|
+
while True:
|
|
395
|
+
try:
|
|
396
|
+
step = plan.throw(error) if error is not None else plan.send(sent)
|
|
397
|
+
except StopIteration as stop:
|
|
398
|
+
result: T = stop.value
|
|
399
|
+
|
|
400
|
+
return result
|
|
401
|
+
|
|
402
|
+
sent, error = None, None
|
|
403
|
+
|
|
404
|
+
if isinstance(step, _Sleep):
|
|
405
|
+
time.sleep(step.seconds)
|
|
406
|
+
continue
|
|
407
|
+
|
|
408
|
+
try:
|
|
409
|
+
sent = self._http.request(**step.request)
|
|
410
|
+
except httpx.TransportError as failure:
|
|
411
|
+
error = failure
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
class AsyncTypefoldClient(_Base):
|
|
415
|
+
"""The asyncio counterpart of ``TypefoldClient``, with the same methods as coroutines."""
|
|
416
|
+
|
|
417
|
+
_http: httpx.AsyncClient
|
|
418
|
+
|
|
419
|
+
def __init__(
|
|
420
|
+
self,
|
|
421
|
+
base_url: str,
|
|
422
|
+
api_key: str,
|
|
423
|
+
*,
|
|
424
|
+
http_client: httpx.AsyncClient | None = None,
|
|
425
|
+
max_rate_limit_retries: int = 3,
|
|
426
|
+
) -> None:
|
|
427
|
+
super().__init__(base_url, api_key, max_rate_limit_retries)
|
|
428
|
+
self._owns_http = http_client is None
|
|
429
|
+
self._http = http_client or httpx.AsyncClient(timeout=DEFAULT_HTTP_TIMEOUT)
|
|
430
|
+
|
|
431
|
+
async def aclose(self) -> None:
|
|
432
|
+
if self._owns_http:
|
|
433
|
+
await self._http.aclose()
|
|
434
|
+
|
|
435
|
+
async def __aenter__(self) -> AsyncTypefoldClient:
|
|
436
|
+
return self
|
|
437
|
+
|
|
438
|
+
async def __aexit__(self, *args: object) -> None:
|
|
439
|
+
await self.aclose()
|
|
440
|
+
|
|
441
|
+
async def render_report(self, request: RenderReportRequest, *, idempotency_key: str | None = None) -> bytes:
|
|
442
|
+
return await self._run(self._render_report(request, idempotency_key))
|
|
443
|
+
|
|
444
|
+
async def render_string(self, request: RenderStringRequest) -> str:
|
|
445
|
+
return await self._run(self._render_string(request))
|
|
446
|
+
|
|
447
|
+
async def list_reports(self) -> list[ReportSummary]:
|
|
448
|
+
return await self._run(self._list_reports())
|
|
449
|
+
|
|
450
|
+
async def get_report_data_schema(self, format: str, template: TemplateRef) -> dict[str, Any]:
|
|
451
|
+
return await self._run(self._get_report_data_schema(format, template))
|
|
452
|
+
|
|
453
|
+
async def create_render(self, request: RenderRequest, *, idempotency_key: str | None = None) -> Render:
|
|
454
|
+
return await self._run(self._create_render(request, idempotency_key))
|
|
455
|
+
|
|
456
|
+
async def get_render(self, id: str) -> Render:
|
|
457
|
+
return await self._run(self._get_render(id))
|
|
458
|
+
|
|
459
|
+
async def cancel_render(self, id: str) -> None:
|
|
460
|
+
await self._run(self._cancel_render(id))
|
|
461
|
+
|
|
462
|
+
async def wait_for_render(self, render: Render, *, timeout: float | None = None) -> Render:
|
|
463
|
+
return await self._run(self._wait_for_render(render, timeout))
|
|
464
|
+
|
|
465
|
+
async def download(self, render: Render) -> bytes:
|
|
466
|
+
return await self._run(self._download(render))
|
|
467
|
+
|
|
468
|
+
async def render(
|
|
469
|
+
self,
|
|
470
|
+
request: RenderRequest,
|
|
471
|
+
*,
|
|
472
|
+
idempotency_key: str | None = None,
|
|
473
|
+
timeout: float | None = None,
|
|
474
|
+
) -> bytes:
|
|
475
|
+
return await self._run(self._render(request, idempotency_key, timeout))
|
|
476
|
+
|
|
477
|
+
async def create_batch(self, items: Sequence[RenderRequest], *, idempotency_key: str | None = None) -> RenderBatch:
|
|
478
|
+
return await self._run(self._create_batch(items, idempotency_key))
|
|
479
|
+
|
|
480
|
+
async def get_batch(self, id: str) -> RenderBatch:
|
|
481
|
+
return await self._run(self._get_batch(id))
|
|
482
|
+
|
|
483
|
+
async def wait_for_batch(self, batch: RenderBatch, *, timeout: float | None = None) -> RenderBatch:
|
|
484
|
+
return await self._run(self._wait_for_batch(batch, timeout))
|
|
485
|
+
|
|
486
|
+
async def download_batch(self, id: str) -> bytes:
|
|
487
|
+
return await self._run(self._download_batch(id))
|
|
488
|
+
|
|
489
|
+
async def _run(self, plan: _Plan[T]) -> T:
|
|
490
|
+
sent: Any = None
|
|
491
|
+
error: Exception | None = None
|
|
492
|
+
|
|
493
|
+
while True:
|
|
494
|
+
try:
|
|
495
|
+
step = plan.throw(error) if error is not None else plan.send(sent)
|
|
496
|
+
except StopIteration as stop:
|
|
497
|
+
result: T = stop.value
|
|
498
|
+
|
|
499
|
+
return result
|
|
500
|
+
|
|
501
|
+
sent, error = None, None
|
|
502
|
+
|
|
503
|
+
if isinstance(step, _Sleep):
|
|
504
|
+
await asyncio.sleep(step.seconds)
|
|
505
|
+
continue
|
|
506
|
+
|
|
507
|
+
try:
|
|
508
|
+
sent = await self._http.request(**step.request)
|
|
509
|
+
except httpx.TransportError as failure:
|
|
510
|
+
error = failure
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def _dump(value: Any) -> Any:
|
|
514
|
+
"""A request model as the API's JSON: camelCase names, None fields left out, data as written."""
|
|
515
|
+
if dataclasses.is_dataclass(value) and not isinstance(value, type):
|
|
516
|
+
body: dict[str, Any] = {}
|
|
517
|
+
|
|
518
|
+
for item in dataclasses.fields(value):
|
|
519
|
+
field_value = getattr(value, item.name)
|
|
520
|
+
data = item.metadata.get("data")
|
|
521
|
+
|
|
522
|
+
if data is not None:
|
|
523
|
+
if field_value is not None:
|
|
524
|
+
body[item.name] = field_value
|
|
525
|
+
elif data == "empty":
|
|
526
|
+
body[item.name] = {}
|
|
527
|
+
|
|
528
|
+
continue
|
|
529
|
+
|
|
530
|
+
if field_value is not None:
|
|
531
|
+
body[_camel(item.name)] = _dump(field_value)
|
|
532
|
+
|
|
533
|
+
return body
|
|
534
|
+
|
|
535
|
+
if isinstance(value, (list, tuple)):
|
|
536
|
+
return [_dump(item) for item in value]
|
|
537
|
+
|
|
538
|
+
return value
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def _with_margins(request: RenderRequest) -> RenderRequest:
|
|
542
|
+
"""A copy of a direct render with the margins it left out at 10 mm, as the API needs all four."""
|
|
543
|
+
if not isinstance(request, DirectRenderRequest):
|
|
544
|
+
return request
|
|
545
|
+
|
|
546
|
+
margins = request.settings.margins
|
|
547
|
+
sides = {name: 10.0 for name in ("top_mm", "right_mm", "bottom_mm", "left_mm") if getattr(margins, name) is None}
|
|
548
|
+
|
|
549
|
+
if not sides:
|
|
550
|
+
return request
|
|
551
|
+
|
|
552
|
+
settings = dataclasses.replace(request.settings, margins=dataclasses.replace(margins, **sides))
|
|
553
|
+
|
|
554
|
+
return dataclasses.replace(request, settings=settings)
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def _json(body: Any) -> bytes:
|
|
558
|
+
# NaN and Infinity are not JSON; json would write them anyway.
|
|
559
|
+
return json.dumps(body, default=_json_default, separators=(",", ":"), allow_nan=False).encode()
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def _camel(name: str) -> str:
|
|
563
|
+
head, *rest = name.split("_")
|
|
564
|
+
|
|
565
|
+
return head + "".join(part.capitalize() for part in rest)
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def _json_default(value: Any) -> Any:
|
|
569
|
+
"""Writes the values json cannot, as they commonly appear in template data."""
|
|
570
|
+
if isinstance(value, (datetime, date)):
|
|
571
|
+
return value.isoformat()
|
|
572
|
+
|
|
573
|
+
if isinstance(value, Decimal):
|
|
574
|
+
if not value.is_finite():
|
|
575
|
+
raise ValueError(f"JSON has no {value}; send it as a string or null.")
|
|
576
|
+
|
|
577
|
+
return int(value) if value == value.to_integral_value() else float(value)
|
|
578
|
+
|
|
579
|
+
if isinstance(value, UUID):
|
|
580
|
+
return str(value)
|
|
581
|
+
|
|
582
|
+
if isinstance(value, Enum):
|
|
583
|
+
return value.value
|
|
584
|
+
|
|
585
|
+
if isinstance(value, (set, frozenset)):
|
|
586
|
+
return list(value)
|
|
587
|
+
|
|
588
|
+
if dataclasses.is_dataclass(value) and not isinstance(value, type):
|
|
589
|
+
return dataclasses.asdict(value)
|
|
590
|
+
|
|
591
|
+
raise TypeError(f"Object of type {type(value).__name__} is not JSON serializable")
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def _data_schema_path(format: str, template: TemplateRef) -> str:
|
|
595
|
+
if template.id is not None:
|
|
596
|
+
selector = "id=" + quote(template.id, safe="")
|
|
597
|
+
elif template.shortid is not None:
|
|
598
|
+
selector = "shortid=" + quote(template.shortid, safe="")
|
|
599
|
+
else:
|
|
600
|
+
selector = "name=" + quote(template.name or "", safe="")
|
|
601
|
+
|
|
602
|
+
return f"v1/reports/data-schema?format={quote(format, safe='')}&{selector}"
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def _url_expiring(render: Render) -> bool:
|
|
606
|
+
return render.result is not None and render.result.expires_at <= datetime.now(timezone.utc) + timedelta(seconds=30)
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def _timeout_error(render: Render) -> TypefoldError:
|
|
610
|
+
return TypefoldError(
|
|
611
|
+
None,
|
|
612
|
+
ApiError(
|
|
613
|
+
code="RENDER_TIMEOUT",
|
|
614
|
+
message=f"Render {render.id} is still {render.state}.",
|
|
615
|
+
retryable=True,
|
|
616
|
+
render_id=render.id,
|
|
617
|
+
),
|
|
618
|
+
)
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def _batch_timeout_error(batch: RenderBatch) -> TypefoldError:
|
|
622
|
+
return TypefoldError(
|
|
623
|
+
None,
|
|
624
|
+
ApiError(code="RENDER_TIMEOUT", message=f"Batch {batch.id} still has unfinished items.", retryable=True),
|
|
625
|
+
)
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def _failed_error(render: Render) -> TypefoldError:
|
|
629
|
+
error = render.error or ApiError(code="RENDER_FAILED", message=f"Render {render.id} was {render.state}.")
|
|
630
|
+
|
|
631
|
+
return TypefoldError(None, dataclasses.replace(error, render_id=render.id))
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def _retry_after(response: httpx.Response) -> float | None:
|
|
635
|
+
header = response.headers.get("retry-after", "").strip()
|
|
636
|
+
|
|
637
|
+
if not header:
|
|
638
|
+
return None
|
|
639
|
+
|
|
640
|
+
if header.isdigit():
|
|
641
|
+
return float(header)
|
|
642
|
+
|
|
643
|
+
try:
|
|
644
|
+
when = parsedate_to_datetime(header)
|
|
645
|
+
except (TypeError, ValueError):
|
|
646
|
+
return None
|
|
647
|
+
|
|
648
|
+
if when.tzinfo is None:
|
|
649
|
+
when = when.replace(tzinfo=timezone.utc)
|
|
650
|
+
|
|
651
|
+
return max(0.0, (when - datetime.now(timezone.utc)).total_seconds())
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def _ensure_success(response: httpx.Response) -> httpx.Response:
|
|
655
|
+
if response.is_success:
|
|
656
|
+
return response
|
|
657
|
+
|
|
658
|
+
status = response.status_code
|
|
659
|
+
text = response.text
|
|
660
|
+
error: ApiError | None = None
|
|
661
|
+
|
|
662
|
+
try:
|
|
663
|
+
envelope = json.loads(text)
|
|
664
|
+
|
|
665
|
+
if isinstance(envelope, dict) and isinstance(envelope.get("error"), dict):
|
|
666
|
+
error = ApiError.from_json(envelope["error"])
|
|
667
|
+
except ValueError:
|
|
668
|
+
# Not the API's envelope, e.g. a proxy's error page.
|
|
669
|
+
pass
|
|
670
|
+
|
|
671
|
+
raise TypefoldError(
|
|
672
|
+
status,
|
|
673
|
+
error
|
|
674
|
+
or ApiError(
|
|
675
|
+
code="HTTP_ERROR",
|
|
676
|
+
message=f"HTTP {status}: {text[:200]}",
|
|
677
|
+
retryable=status == 429 or status >= 500,
|
|
678
|
+
),
|
|
679
|
+
)
|
typefold/_errors.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from .models import ApiError
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TypefoldError(Exception):
|
|
7
|
+
"""An API error response, or a render that failed or was cancelled."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, status_code: int | None, error: ApiError) -> None:
|
|
10
|
+
super().__init__(f"{error.code}: {error.message}")
|
|
11
|
+
self.status_code = status_code
|
|
12
|
+
"""The HTTP status, or ``None`` when a render failed after it was accepted."""
|
|
13
|
+
self.error = error
|
|
14
|
+
|
|
15
|
+
@property
|
|
16
|
+
def code(self) -> str:
|
|
17
|
+
"""The API's code, such as ``NOT_FOUND``, ``RATE_LIMITED`` or ``RENDER_TIMEOUT``."""
|
|
18
|
+
return self.error.code
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def retryable(self) -> bool:
|
|
22
|
+
"""Whether a retry can help."""
|
|
23
|
+
return self.error.retryable
|