galley-render 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.
- galley_render/__init__.py +57 -0
- galley_render/_client.py +494 -0
- galley_render/_errors.py +167 -0
- galley_render/_models.py +347 -0
- galley_render/_transport.py +251 -0
- galley_render/_trial.py +164 -0
- galley_render/py.typed +0 -0
- galley_render-0.1.0.dist-info/METADATA +252 -0
- galley_render-0.1.0.dist-info/RECORD +11 -0
- galley_render-0.1.0.dist-info/WHEEL +4 -0
- galley_render-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""galley-render — JSON in, PDF out.
|
|
2
|
+
|
|
3
|
+
::
|
|
4
|
+
|
|
5
|
+
from galley_render import Galley
|
|
6
|
+
|
|
7
|
+
galley = Galley(api_key=os.environ["GALLEY_API_KEY"])
|
|
8
|
+
render = galley.render("invoice@1", format="pdf", data={"invoice_number": "INV-1042"})
|
|
9
|
+
galley.download(render, to_file="invoice.pdf")
|
|
10
|
+
|
|
11
|
+
With no key at all, :func:`start_trial` mints a 50-render account through the
|
|
12
|
+
MCP server and hands back a key that works everywhere in this package.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from ._client import DEFAULT_BASE_URL, AsyncGalley, Galley, __version__
|
|
16
|
+
from ._errors import (
|
|
17
|
+
FieldError,
|
|
18
|
+
GalleyConnectionError,
|
|
19
|
+
GalleyError,
|
|
20
|
+
GalleyTimeoutError,
|
|
21
|
+
)
|
|
22
|
+
from ._models import (
|
|
23
|
+
Account,
|
|
24
|
+
Batch,
|
|
25
|
+
DeletedTemplate,
|
|
26
|
+
Render,
|
|
27
|
+
Template,
|
|
28
|
+
TemplateVersion,
|
|
29
|
+
Usage,
|
|
30
|
+
Validation,
|
|
31
|
+
WebhookSecret,
|
|
32
|
+
)
|
|
33
|
+
from ._trial import DEFAULT_MCP_URL, Trial, async_start_trial, start_trial
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"Galley",
|
|
37
|
+
"AsyncGalley",
|
|
38
|
+
"start_trial",
|
|
39
|
+
"async_start_trial",
|
|
40
|
+
"Trial",
|
|
41
|
+
"GalleyError",
|
|
42
|
+
"GalleyConnectionError",
|
|
43
|
+
"GalleyTimeoutError",
|
|
44
|
+
"FieldError",
|
|
45
|
+
"Render",
|
|
46
|
+
"Batch",
|
|
47
|
+
"Template",
|
|
48
|
+
"TemplateVersion",
|
|
49
|
+
"Validation",
|
|
50
|
+
"Usage",
|
|
51
|
+
"Account",
|
|
52
|
+
"WebhookSecret",
|
|
53
|
+
"DeletedTemplate",
|
|
54
|
+
"DEFAULT_BASE_URL",
|
|
55
|
+
"DEFAULT_MCP_URL",
|
|
56
|
+
"__version__",
|
|
57
|
+
]
|
galley_render/_client.py
ADDED
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
"""The clients.
|
|
2
|
+
|
|
3
|
+
``Galley`` and ``AsyncGalley`` expose the same surface, named the same way, so
|
|
4
|
+
moving a script from one to the other is a matter of adding ``await``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import os
|
|
11
|
+
import time
|
|
12
|
+
from typing import Any, Dict, List, Mapping, Optional, Union
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from ._errors import GalleyError, GalleyTimeoutError
|
|
17
|
+
from ._models import (
|
|
18
|
+
Account,
|
|
19
|
+
Batch,
|
|
20
|
+
DeletedTemplate,
|
|
21
|
+
Render,
|
|
22
|
+
Template,
|
|
23
|
+
TemplateVersion,
|
|
24
|
+
Usage,
|
|
25
|
+
Validation,
|
|
26
|
+
WebhookSecret,
|
|
27
|
+
)
|
|
28
|
+
from ._transport import DEFAULT_BASE_URL, AsyncTransport, SyncTransport
|
|
29
|
+
|
|
30
|
+
__version__ = "0.1.0"
|
|
31
|
+
|
|
32
|
+
__all__ = ["Galley", "AsyncGalley", "DEFAULT_BASE_URL", "__version__"]
|
|
33
|
+
|
|
34
|
+
_USER_AGENT = f"galley-render-python/{__version__}"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _render_body(
|
|
38
|
+
template: str,
|
|
39
|
+
*,
|
|
40
|
+
version: Optional[Union[int, str]] = None,
|
|
41
|
+
data: Any = None,
|
|
42
|
+
format: Optional[str] = None,
|
|
43
|
+
options: Optional[Mapping[str, Any]] = None,
|
|
44
|
+
webhook_url: Optional[str] = None,
|
|
45
|
+
async_: bool = False,
|
|
46
|
+
) -> Dict[str, Any]:
|
|
47
|
+
body: Dict[str, Any] = {"template": template}
|
|
48
|
+
if version is not None:
|
|
49
|
+
body["version"] = version
|
|
50
|
+
if data is not None:
|
|
51
|
+
body["data"] = data
|
|
52
|
+
if format is not None:
|
|
53
|
+
body["format"] = format
|
|
54
|
+
if options:
|
|
55
|
+
body["options"] = dict(options)
|
|
56
|
+
if webhook_url:
|
|
57
|
+
body["webhook_url"] = webhook_url
|
|
58
|
+
if async_:
|
|
59
|
+
body["async"] = True
|
|
60
|
+
return body
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _template_body(
|
|
64
|
+
*,
|
|
65
|
+
source: str,
|
|
66
|
+
engine: Optional[str] = None,
|
|
67
|
+
schema: Optional[Mapping[str, Any]] = None,
|
|
68
|
+
options: Optional[Mapping[str, Any]] = None,
|
|
69
|
+
example: Any = None,
|
|
70
|
+
description: Optional[str] = None,
|
|
71
|
+
message: Optional[str] = None,
|
|
72
|
+
) -> Dict[str, Any]:
|
|
73
|
+
body: Dict[str, Any] = {"source": source}
|
|
74
|
+
if engine is not None:
|
|
75
|
+
body["engine"] = engine
|
|
76
|
+
if schema is not None:
|
|
77
|
+
body["schema"] = dict(schema)
|
|
78
|
+
if options is not None:
|
|
79
|
+
body["options"] = dict(options)
|
|
80
|
+
if example is not None:
|
|
81
|
+
body["example"] = example
|
|
82
|
+
if description is not None:
|
|
83
|
+
body["description"] = description
|
|
84
|
+
if message is not None:
|
|
85
|
+
body["message"] = message
|
|
86
|
+
return body
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _require_url(render: Render) -> str:
|
|
90
|
+
if render.url:
|
|
91
|
+
return render.url
|
|
92
|
+
if render.status == "failed":
|
|
93
|
+
raise GalleyError(
|
|
94
|
+
f"Render {render.id} failed, so there is nothing to download. See `render.error`.",
|
|
95
|
+
type="render_failed",
|
|
96
|
+
status=500,
|
|
97
|
+
body=render.raw,
|
|
98
|
+
)
|
|
99
|
+
raise GalleyError(
|
|
100
|
+
f"Render {render.id} is {render.status} and has no file yet. Wait for it with `renders.wait()`.",
|
|
101
|
+
type="not_found",
|
|
102
|
+
status=404,
|
|
103
|
+
body=render.raw,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _failed_render_error(render: Render) -> GalleyError:
|
|
108
|
+
detail = render.error if isinstance(render.error, dict) else {}
|
|
109
|
+
return GalleyError(
|
|
110
|
+
detail.get("message") or f"Render {render.id} failed.",
|
|
111
|
+
type=detail.get("type") or "render_failed",
|
|
112
|
+
status=500,
|
|
113
|
+
body=render.raw,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _settings(
|
|
118
|
+
api_key: Optional[str],
|
|
119
|
+
base_url: Optional[str],
|
|
120
|
+
) -> Dict[str, Any]:
|
|
121
|
+
return {
|
|
122
|
+
"api_key": api_key if api_key is not None else (os.environ.get("GALLEY_API_KEY") or None),
|
|
123
|
+
"base_url": base_url or os.environ.get("GALLEY_BASE_URL") or DEFAULT_BASE_URL,
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# --------------------------------------------------------------------------
|
|
128
|
+
# sync
|
|
129
|
+
# --------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class Galley:
|
|
133
|
+
"""Synchronous client.
|
|
134
|
+
|
|
135
|
+
::
|
|
136
|
+
|
|
137
|
+
galley = Galley(api_key=os.environ["GALLEY_API_KEY"])
|
|
138
|
+
render = galley.render("invoice@1", data={"invoice_number": "INV-1042"}, format="pdf")
|
|
139
|
+
galley.download(render, to_file="invoice.pdf")
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
def __init__(
|
|
143
|
+
self,
|
|
144
|
+
api_key: Optional[str] = None,
|
|
145
|
+
*,
|
|
146
|
+
base_url: Optional[str] = None,
|
|
147
|
+
timeout: float = 60.0,
|
|
148
|
+
max_retries: int = 3,
|
|
149
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
150
|
+
http_client: Optional[httpx.Client] = None,
|
|
151
|
+
) -> None:
|
|
152
|
+
resolved = _settings(api_key, base_url)
|
|
153
|
+
self._t = SyncTransport(
|
|
154
|
+
client=http_client,
|
|
155
|
+
api_key=resolved["api_key"],
|
|
156
|
+
base_url=resolved["base_url"],
|
|
157
|
+
timeout=timeout,
|
|
158
|
+
max_retries=max_retries,
|
|
159
|
+
headers=headers,
|
|
160
|
+
user_agent=_USER_AGENT,
|
|
161
|
+
)
|
|
162
|
+
self.renders = Renders(self._t)
|
|
163
|
+
self.templates = Templates(self._t)
|
|
164
|
+
|
|
165
|
+
@property
|
|
166
|
+
def base_url(self) -> str:
|
|
167
|
+
return self._t.base_url
|
|
168
|
+
|
|
169
|
+
def close(self) -> None:
|
|
170
|
+
self._t.close()
|
|
171
|
+
|
|
172
|
+
def __enter__(self) -> "Galley":
|
|
173
|
+
return self
|
|
174
|
+
|
|
175
|
+
def __exit__(self, *exc: Any) -> None:
|
|
176
|
+
self.close()
|
|
177
|
+
|
|
178
|
+
def render(self, template: str, **kwargs: Any) -> Render:
|
|
179
|
+
"""``POST /v1/render``. The one call most programs make."""
|
|
180
|
+
return self.renders.create(template, **kwargs)
|
|
181
|
+
|
|
182
|
+
def usage(self) -> Usage:
|
|
183
|
+
"""``GET /v1/usage`` — this period's spend, what is left, the trial balance."""
|
|
184
|
+
return Usage.from_api(self._t.request("GET", "/v1/usage"))
|
|
185
|
+
|
|
186
|
+
def account(self) -> Account:
|
|
187
|
+
"""``GET /v1/account`` — the account, and the webhook signing secret.
|
|
188
|
+
|
|
189
|
+
``webhook_secret`` has a value only on the **first** call that has one
|
|
190
|
+
to give; every call after that returns ``None`` and only the prefix.
|
|
191
|
+
Store it when you get it.
|
|
192
|
+
"""
|
|
193
|
+
return Account.from_api(self._t.request("GET", "/v1/account"))
|
|
194
|
+
|
|
195
|
+
def rotate_webhook_secret(self) -> WebhookSecret:
|
|
196
|
+
"""``POST /v1/account/webhook-secret`` — mint a new secret, shown once.
|
|
197
|
+
|
|
198
|
+
The previous secret stops verifying deliveries immediately, so update
|
|
199
|
+
your handler in the same change.
|
|
200
|
+
"""
|
|
201
|
+
return WebhookSecret.from_api(self._t.request("POST", "/v1/account/webhook-secret"))
|
|
202
|
+
|
|
203
|
+
def render_and_wait(
|
|
204
|
+
self,
|
|
205
|
+
template: str,
|
|
206
|
+
*,
|
|
207
|
+
poll_interval: float = 1.0,
|
|
208
|
+
wait_timeout: float = 120.0,
|
|
209
|
+
**kwargs: Any,
|
|
210
|
+
) -> Render:
|
|
211
|
+
"""Render and wait, whichever path the API took."""
|
|
212
|
+
render = self.renders.create(template, **kwargs)
|
|
213
|
+
if render.succeeded:
|
|
214
|
+
return render
|
|
215
|
+
return self.renders.wait(render.id, poll_interval=poll_interval, timeout=wait_timeout)
|
|
216
|
+
|
|
217
|
+
def download(self, target: Union[Render, str], *, to_file: Optional[str] = None) -> bytes:
|
|
218
|
+
"""Fetch the bytes behind a render's signed URL.
|
|
219
|
+
|
|
220
|
+
Accepts a render, a render id or a URL. A signed URL that has expired —
|
|
221
|
+
they are short-lived by design — is re-signed by re-reading the render
|
|
222
|
+
rather than failing.
|
|
223
|
+
"""
|
|
224
|
+
url = self._resolve_url(target)
|
|
225
|
+
try:
|
|
226
|
+
body = self._t.get_bytes(url)
|
|
227
|
+
except GalleyError as err:
|
|
228
|
+
if err.status not in (401, 403) or isinstance(target, str):
|
|
229
|
+
raise
|
|
230
|
+
body = self._t.get_bytes(_require_url(self.renders.get(target.id)))
|
|
231
|
+
if to_file:
|
|
232
|
+
with open(to_file, "wb") as handle:
|
|
233
|
+
handle.write(body)
|
|
234
|
+
return body
|
|
235
|
+
|
|
236
|
+
def _resolve_url(self, target: Union[Render, str]) -> str:
|
|
237
|
+
if isinstance(target, str):
|
|
238
|
+
if target.startswith("http://") or target.startswith("https://"):
|
|
239
|
+
return target
|
|
240
|
+
return _require_url(self.renders.get(target))
|
|
241
|
+
return target.url or _require_url(self.renders.get(target.id))
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
class Renders:
|
|
245
|
+
def __init__(self, transport: SyncTransport) -> None:
|
|
246
|
+
self._t = transport
|
|
247
|
+
|
|
248
|
+
def create(self, template: str, **kwargs: Any) -> Render:
|
|
249
|
+
"""``POST /v1/render``. Returns ``succeeded`` inline, or ``queued`` to poll."""
|
|
250
|
+
return Render.from_api(self._t.request("POST", "/v1/render", json=_render_body(template, **kwargs)))
|
|
251
|
+
|
|
252
|
+
def batch(self, renders: List[Mapping[str, Any]], *, webhook_url: Optional[str] = None) -> Batch:
|
|
253
|
+
"""``POST /v1/render/batch``. Up to 50; a bad item fails alone, inline."""
|
|
254
|
+
body: Dict[str, Any] = {
|
|
255
|
+
"renders": [_render_body(r["template"], **{k: v for k, v in r.items() if k != "template"}) for r in renders]
|
|
256
|
+
}
|
|
257
|
+
if webhook_url:
|
|
258
|
+
body["webhook_url"] = webhook_url
|
|
259
|
+
return Batch.from_api(self._t.request("POST", "/v1/render/batch", json=body))
|
|
260
|
+
|
|
261
|
+
def get(self, render_id: str) -> Render:
|
|
262
|
+
"""``GET /v1/renders/:id``. The ``url`` is signed fresh on every read."""
|
|
263
|
+
return Render.from_api(self._t.request("GET", f"/v1/renders/{render_id}"))
|
|
264
|
+
|
|
265
|
+
def list(self, *, limit: Optional[int] = None) -> List[Render]:
|
|
266
|
+
"""``GET /v1/renders``, newest first."""
|
|
267
|
+
body = self._t.request("GET", "/v1/renders", params={"limit": limit})
|
|
268
|
+
return [Render.from_api(r) for r in body.get("data", [])]
|
|
269
|
+
|
|
270
|
+
def wait(self, render_id: str, *, poll_interval: float = 1.0, timeout: float = 120.0) -> Render:
|
|
271
|
+
"""Poll until the render leaves ``queued``/``processing``.
|
|
272
|
+
|
|
273
|
+
A failed render raises, with the API's own error attached — the same
|
|
274
|
+
thing a synchronous render would have done.
|
|
275
|
+
"""
|
|
276
|
+
started = time.monotonic()
|
|
277
|
+
interval = poll_interval
|
|
278
|
+
while True:
|
|
279
|
+
render = self.get(render_id)
|
|
280
|
+
if render.succeeded:
|
|
281
|
+
return render
|
|
282
|
+
if render.status == "failed":
|
|
283
|
+
raise _failed_render_error(render)
|
|
284
|
+
waited = time.monotonic() - started
|
|
285
|
+
if waited >= timeout:
|
|
286
|
+
raise GalleyTimeoutError(render_id, waited)
|
|
287
|
+
time.sleep(interval)
|
|
288
|
+
interval = min(interval * 2, 5.0)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
class Templates:
|
|
292
|
+
def __init__(self, transport: SyncTransport) -> None:
|
|
293
|
+
self._t = transport
|
|
294
|
+
|
|
295
|
+
def list(self) -> List[Template]:
|
|
296
|
+
"""``GET /v1/templates``."""
|
|
297
|
+
body = self._t.request("GET", "/v1/templates")
|
|
298
|
+
return [Template.from_api(t) for t in body.get("data", [])]
|
|
299
|
+
|
|
300
|
+
def get(self, ref: str) -> Template:
|
|
301
|
+
"""``GET /v1/templates/:ref`` — ``invoice`` for the latest, ``invoice@3`` to pin."""
|
|
302
|
+
return Template.from_api(self._t.request("GET", f"/v1/templates/{ref}"))
|
|
303
|
+
|
|
304
|
+
def create(self, name: str, *, source: str, **kwargs: Any) -> Template:
|
|
305
|
+
"""``POST /v1/templates`` — creates it at version 1."""
|
|
306
|
+
body = {"name": name, **_template_body(source=source, **kwargs)}
|
|
307
|
+
return Template.from_api(self._t.request("POST", "/v1/templates", json=body))
|
|
308
|
+
|
|
309
|
+
def publish(self, name: str, *, source: str, **kwargs: Any) -> Template:
|
|
310
|
+
"""``POST /v1/templates/:name/versions`` — a new immutable version.
|
|
311
|
+
|
|
312
|
+
Earlier versions keep rendering, and anything pinned to one is
|
|
313
|
+
unaffected.
|
|
314
|
+
"""
|
|
315
|
+
return Template.from_api(
|
|
316
|
+
self._t.request("POST", f"/v1/templates/{name}/versions", json=_template_body(source=source, **kwargs))
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
def versions(self, name: str) -> List[TemplateVersion]:
|
|
320
|
+
"""``GET /v1/templates/:name/versions``."""
|
|
321
|
+
body = self._t.request("GET", f"/v1/templates/{name}/versions")
|
|
322
|
+
return [TemplateVersion.from_api(v) for v in body.get("data", [])]
|
|
323
|
+
|
|
324
|
+
def validate(self, ref: str, data: Any) -> Validation:
|
|
325
|
+
"""``POST /v1/templates/:ref/validate`` — free, renders nothing."""
|
|
326
|
+
return Validation.from_api(self._t.request("POST", f"/v1/templates/{ref}/validate", json={"data": data}))
|
|
327
|
+
|
|
328
|
+
def delete(self, name: str) -> DeletedTemplate:
|
|
329
|
+
"""``DELETE /v1/templates/:name`` — soft delete; renders keep working."""
|
|
330
|
+
return DeletedTemplate.from_api(self._t.request("DELETE", f"/v1/templates/{name}"))
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
# --------------------------------------------------------------------------
|
|
334
|
+
# async
|
|
335
|
+
# --------------------------------------------------------------------------
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
class AsyncGalley:
|
|
339
|
+
"""Asynchronous client. Same surface as :class:`Galley`, with ``await``."""
|
|
340
|
+
|
|
341
|
+
def __init__(
|
|
342
|
+
self,
|
|
343
|
+
api_key: Optional[str] = None,
|
|
344
|
+
*,
|
|
345
|
+
base_url: Optional[str] = None,
|
|
346
|
+
timeout: float = 60.0,
|
|
347
|
+
max_retries: int = 3,
|
|
348
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
349
|
+
http_client: Optional[httpx.AsyncClient] = None,
|
|
350
|
+
) -> None:
|
|
351
|
+
resolved = _settings(api_key, base_url)
|
|
352
|
+
self._t = AsyncTransport(
|
|
353
|
+
client=http_client,
|
|
354
|
+
api_key=resolved["api_key"],
|
|
355
|
+
base_url=resolved["base_url"],
|
|
356
|
+
timeout=timeout,
|
|
357
|
+
max_retries=max_retries,
|
|
358
|
+
headers=headers,
|
|
359
|
+
user_agent=_USER_AGENT,
|
|
360
|
+
)
|
|
361
|
+
self.renders = AsyncRenders(self._t)
|
|
362
|
+
self.templates = AsyncTemplates(self._t)
|
|
363
|
+
|
|
364
|
+
@property
|
|
365
|
+
def base_url(self) -> str:
|
|
366
|
+
return self._t.base_url
|
|
367
|
+
|
|
368
|
+
async def aclose(self) -> None:
|
|
369
|
+
await self._t.aclose()
|
|
370
|
+
|
|
371
|
+
async def __aenter__(self) -> "AsyncGalley":
|
|
372
|
+
return self
|
|
373
|
+
|
|
374
|
+
async def __aexit__(self, *exc: Any) -> None:
|
|
375
|
+
await self.aclose()
|
|
376
|
+
|
|
377
|
+
async def render(self, template: str, **kwargs: Any) -> Render:
|
|
378
|
+
return await self.renders.create(template, **kwargs)
|
|
379
|
+
|
|
380
|
+
async def usage(self) -> Usage:
|
|
381
|
+
return Usage.from_api(await self._t.request("GET", "/v1/usage"))
|
|
382
|
+
|
|
383
|
+
async def account(self) -> Account:
|
|
384
|
+
return Account.from_api(await self._t.request("GET", "/v1/account"))
|
|
385
|
+
|
|
386
|
+
async def rotate_webhook_secret(self) -> WebhookSecret:
|
|
387
|
+
return WebhookSecret.from_api(await self._t.request("POST", "/v1/account/webhook-secret"))
|
|
388
|
+
|
|
389
|
+
async def render_and_wait(
|
|
390
|
+
self,
|
|
391
|
+
template: str,
|
|
392
|
+
*,
|
|
393
|
+
poll_interval: float = 1.0,
|
|
394
|
+
wait_timeout: float = 120.0,
|
|
395
|
+
**kwargs: Any,
|
|
396
|
+
) -> Render:
|
|
397
|
+
render = await self.renders.create(template, **kwargs)
|
|
398
|
+
if render.succeeded:
|
|
399
|
+
return render
|
|
400
|
+
return await self.renders.wait(render.id, poll_interval=poll_interval, timeout=wait_timeout)
|
|
401
|
+
|
|
402
|
+
async def download(self, target: Union[Render, str], *, to_file: Optional[str] = None) -> bytes:
|
|
403
|
+
url = await self._resolve_url(target)
|
|
404
|
+
try:
|
|
405
|
+
body = await self._t.get_bytes(url)
|
|
406
|
+
except GalleyError as err:
|
|
407
|
+
if err.status not in (401, 403) or isinstance(target, str):
|
|
408
|
+
raise
|
|
409
|
+
fresh = await self.renders.get(target.id)
|
|
410
|
+
body = await self._t.get_bytes(_require_url(fresh))
|
|
411
|
+
if to_file:
|
|
412
|
+
with open(to_file, "wb") as handle:
|
|
413
|
+
handle.write(body)
|
|
414
|
+
return body
|
|
415
|
+
|
|
416
|
+
async def _resolve_url(self, target: Union[Render, str]) -> str:
|
|
417
|
+
if isinstance(target, str):
|
|
418
|
+
if target.startswith("http://") or target.startswith("https://"):
|
|
419
|
+
return target
|
|
420
|
+
return _require_url(await self.renders.get(target))
|
|
421
|
+
if target.url:
|
|
422
|
+
return target.url
|
|
423
|
+
return _require_url(await self.renders.get(target.id))
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
class AsyncRenders:
|
|
427
|
+
def __init__(self, transport: AsyncTransport) -> None:
|
|
428
|
+
self._t = transport
|
|
429
|
+
|
|
430
|
+
async def create(self, template: str, **kwargs: Any) -> Render:
|
|
431
|
+
return Render.from_api(await self._t.request("POST", "/v1/render", json=_render_body(template, **kwargs)))
|
|
432
|
+
|
|
433
|
+
async def batch(self, renders: List[Mapping[str, Any]], *, webhook_url: Optional[str] = None) -> Batch:
|
|
434
|
+
body: Dict[str, Any] = {
|
|
435
|
+
"renders": [_render_body(r["template"], **{k: v for k, v in r.items() if k != "template"}) for r in renders]
|
|
436
|
+
}
|
|
437
|
+
if webhook_url:
|
|
438
|
+
body["webhook_url"] = webhook_url
|
|
439
|
+
return Batch.from_api(await self._t.request("POST", "/v1/render/batch", json=body))
|
|
440
|
+
|
|
441
|
+
async def get(self, render_id: str) -> Render:
|
|
442
|
+
return Render.from_api(await self._t.request("GET", f"/v1/renders/{render_id}"))
|
|
443
|
+
|
|
444
|
+
async def list(self, *, limit: Optional[int] = None) -> List[Render]:
|
|
445
|
+
body = await self._t.request("GET", "/v1/renders", params={"limit": limit})
|
|
446
|
+
return [Render.from_api(r) for r in body.get("data", [])]
|
|
447
|
+
|
|
448
|
+
async def wait(self, render_id: str, *, poll_interval: float = 1.0, timeout: float = 120.0) -> Render:
|
|
449
|
+
started = time.monotonic()
|
|
450
|
+
interval = poll_interval
|
|
451
|
+
while True:
|
|
452
|
+
render = await self.get(render_id)
|
|
453
|
+
if render.succeeded:
|
|
454
|
+
return render
|
|
455
|
+
if render.status == "failed":
|
|
456
|
+
raise _failed_render_error(render)
|
|
457
|
+
waited = time.monotonic() - started
|
|
458
|
+
if waited >= timeout:
|
|
459
|
+
raise GalleyTimeoutError(render_id, waited)
|
|
460
|
+
await asyncio.sleep(interval)
|
|
461
|
+
interval = min(interval * 2, 5.0)
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
class AsyncTemplates:
|
|
465
|
+
def __init__(self, transport: AsyncTransport) -> None:
|
|
466
|
+
self._t = transport
|
|
467
|
+
|
|
468
|
+
async def list(self) -> List[Template]:
|
|
469
|
+
body = await self._t.request("GET", "/v1/templates")
|
|
470
|
+
return [Template.from_api(t) for t in body.get("data", [])]
|
|
471
|
+
|
|
472
|
+
async def get(self, ref: str) -> Template:
|
|
473
|
+
return Template.from_api(await self._t.request("GET", f"/v1/templates/{ref}"))
|
|
474
|
+
|
|
475
|
+
async def create(self, name: str, *, source: str, **kwargs: Any) -> Template:
|
|
476
|
+
body = {"name": name, **_template_body(source=source, **kwargs)}
|
|
477
|
+
return Template.from_api(await self._t.request("POST", "/v1/templates", json=body))
|
|
478
|
+
|
|
479
|
+
async def publish(self, name: str, *, source: str, **kwargs: Any) -> Template:
|
|
480
|
+
return Template.from_api(
|
|
481
|
+
await self._t.request(
|
|
482
|
+
"POST", f"/v1/templates/{name}/versions", json=_template_body(source=source, **kwargs)
|
|
483
|
+
)
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
async def versions(self, name: str) -> List[TemplateVersion]:
|
|
487
|
+
body = await self._t.request("GET", f"/v1/templates/{name}/versions")
|
|
488
|
+
return [TemplateVersion.from_api(v) for v in body.get("data", [])]
|
|
489
|
+
|
|
490
|
+
async def validate(self, ref: str, data: Any) -> Validation:
|
|
491
|
+
return Validation.from_api(await self._t.request("POST", f"/v1/templates/{ref}/validate", json={"data": data}))
|
|
492
|
+
|
|
493
|
+
async def delete(self, name: str) -> DeletedTemplate:
|
|
494
|
+
return DeletedTemplate.from_api(await self._t.request("DELETE", f"/v1/templates/{name}"))
|