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.
@@ -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
+ ]
@@ -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}"))