Metadata-Version: 2.5
Name: galley-render
Version: 0.2.0
Summary: Galley Render — JSON in, PDF out. Official Python client for the document API agents can sign themselves up for.
Project-URL: Homepage, https://galleyrender.com/?utm_source=pypi
Project-URL: Documentation, https://galleyrender.com/docs/sdks/?utm_source=pypi
Project-URL: Changelog, https://galleyrender.com/docs/changelog/?utm_source=pypi
Project-URL: Issues, https://galleyrender.com/support/?utm_source=pypi
Author-email: Galley Render <hello@galleyrender.com>
Maintainer-email: Galley Render <support@galleyrender.com>
License-Expression: MIT
License-File: LICENSE
Keywords: agents,certificate,document-api,html-to-pdf,invoice,mcp,og-image,pdf,pdf-generation,png
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Multimedia :: Graphics :: Graphics Conversion
Classifier: Topic :: Printing
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.9
Requires-Dist: httpx>=0.27
Provides-Extra: dev
Requires-Dist: build>=1.2; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
Requires-Dist: pytest>=8; extra == 'dev'
Requires-Dist: twine>=5; extra == 'dev'
Description-Content-Type: text/markdown

# galley-render

**JSON in, PDF out.** The official Python client for [Galley Render](https://galleyrender.com/?utm_source=pypi) — a
document API for agents and the programs they write. A template plus a JSON payload becomes a PDF,
PNG or JPG behind a signed URL, deterministically and cached, so the same input always returns the
same file and an identical repeat call is free.

- Sync and async clients with the same surface.
- One dependency: `httpx`.
- Typed responses from the API's own [OpenAPI spec](https://api.galleyrender.com/openapi.json),
  with `.raw` kept intact and `py.typed` shipped.
- Retries 429 and 5xx with exponential backoff and full jitter.
- Downloads signed URLs, and re-signs them when they expire.
- Starts a **keyless trial of 10 PDF pages or 10 images** with no signup and no card.

```bash
pip install galley-render
```

Python 3.9 or newer.

## Quickstart

```python
import os
from galley_render import Galley

galley = Galley(api_key=os.environ["GALLEY_API_KEY"])

render = galley.render(
    "invoice@1",
    format="pdf",
    data={
        "invoice_number": "INV-1042",
        "customer": {"name": "Acme Corp"},
        "line_items": [{"description": "Consulting", "quantity": 12, "unit_price": 150}],
    },
)

print(render.url)                       # signed, short-lived
galley.download(render, to_file="invoice.pdf")
```

Async is the same thing with `await`:

```python
from galley_render import AsyncGalley

async with AsyncGalley() as galley:                 # reads GALLEY_API_KEY
    render = await galley.render("invoice@1", format="pdf", data=payload)
    pdf = await galley.download(render)
```

### No key yet

`start_trial()` mints a real trial account — 10 PDF pages or 10 images — through Galley's MCP server and hands back a key that
works everywhere in this package and against the REST API.

```python
import os
from galley_render import Galley, start_trial

trial = start_trial(client_id=os.environ["GALLEY_CLIENT_ID"])  # made once: openssl rand -hex 16
print(trial.renders_remaining)              # 10

galley = Galley(api_key=trial.api_key)
render = galley.render("og-card", format="png", data={"title": "Hello"})
```

`async_start_trial()` is the awaitable form. To lift the limit, call the `create_account` tool on
[the MCP server](https://mcp.galleyrender.com/mcp) with an email, or sign up at
[galleyrender.com](https://galleyrender.com/?utm_source=pypi). The email links to a page that shows who asked and a
short request code; the address's owner confirms it there, and the key comes back (or is shown on
that page, for a caller with no client id of its own). A trial started with a random `clientId`
upgrades in place when its request is confirmed, so nothing it made is lost.

## Configuration

```python
Galley(
    api_key=None,                              # default: $GALLEY_API_KEY
    base_url=None,                             # default: $GALLEY_BASE_URL, then the public API
    timeout=60.0,                              # seconds, per attempt
    max_retries=3,                             # extra attempts on 429/5xx and connection failures
    headers=None,                              # merged into every request
    http_client=None,                          # bring your own httpx.Client
)
```

Both clients are context managers, and close the `httpx` client they created:

```python
with Galley() as galley:
    ...
```

## Rendering

```python
# Small jobs finish inside the call.
render = galley.render(
    "certificate@2",                  # pin the version in anything you ship
    format="pdf",                      # pdf | png | jpg
    data={"recipient": "Dana Lee", "course": "Rope Access L1"},
    options={"page_size": "Letter", "margin": "18mm", "landscape": True},
)

# A webhook, async_=True or a large payload queues the job instead.
queued = galley.render("report", data=data, async_=True)
done = galley.renders.wait(queued.id)                     # polls with backoff

# Or do both in one call, whichever path the API takes.
finished = galley.render_and_wait("report", data=data)

# Up to 50 at a time. A bad item fails alone; the rest still run.
batch = galley.renders.batch(
    [{"template": "statement@4", "data": c} for c in customers],
    webhook_url="https://example.com/hooks/galley",
)
batch.succeeded     # the Render objects
batch.failed        # the error envelopes, each with its index
```

`render.cached` is `True` when the deterministic cache answered: the same template version, data,
options and format were rendered before, and this call cost nothing.

### Language and `warnings`

`data["lang"]` (a BCP 47 tag) sets the language of the template's fixed text, the formats of its
dates and money, and, for a PDF, the paper size; `data["labels"]` rewords single strings. Neither
is required, and both are honoured only on a template whose schema declares them as root `properties`
(the starter library's do for new accounts and after a library refresh; check `get_template` for a
`lang` property first). Elsewhere they are unknown fields.

```python
render = galley.render(
    "invoice@1",
    data={
        "invoice_number": "INV-1042",
        "issued_on": "2026-09-16",
        "seller": {"name": "Galley Render", "logo": "https://example.com/logo.png"},
        "buyer": {"name": "Acme Robotics"},
        "line_items": [{"description": "September", "quantity": 1, "unit_price": 19}],
        "lang": "en-GB",                        # British dates, A4
        "labels": {"invoice": "Tax invoice"},   # your own wording for one string
    },
)

# What the template did not use. Never an error, and empty when there is nothing to say.
for warning in render.warnings or []:
    if warning.type == "unknown_field":
        print(warning.path, "->", warning.did_you_mean)
    else:
        print(warning.type, warning.path, warning.message)
```

`warnings` is on `render()`, on each batch item and on `templates.validate()`, and it is `None` on
`renders.get()`: it describes the request, so it is not stored. A warning is a dict with
attribute access, whose `type` is `unknown_field` (with an optional `did_you_mean`),
`unsupported_language` (with `supported`), `i18n_disabled` or `font_coverage`. See
[the languages page](https://galleyrender.com/docs/templates/languages).

## Downloading

Signed URLs are short-lived; the stored file lasts until the render's `expires_at` (your plan's
retention), and is then deleted. `download()` takes a render, a render id or a URL, and quietly
re-signs an expired one — until `expires_at`, after which it raises `retention_expired` (410).

```python
data = galley.download(render)                            # bytes
galley.download(render, to_file="out/invoice.pdf")
galley.download("rnd_7hq2m4x8k1bv", to_file="a.png")
```

## Templates

Templates are code: one self-contained HTML document with inline CSS and Liquid expressions, plus a
JSON Schema that is the contract for `data`. Versions are immutable and content-addressed.

```python
galley.templates.list()
invoice = galley.templates.get("invoice@3")
invoice.schema       # JSON Schema for `data`
invoice.example      # a payload that renders

galley.templates.create(
    "welcome-card",
    engine="satori",                                     # fast PNG path for simple flexbox cards
    source="<div style='display:flex'>{{ name }}</div>",
    schema={"type": "object", "required": ["name"], "properties": {"name": {"type": "string"}}},
    example={"name": "Dana"},
)

galley.templates.publish("welcome-card", source="…", message="tighter kerning")
galley.templates.versions("welcome-card")

# Free, renders nothing, and returns the same field errors a render would.
check = galley.templates.validate("welcome-card", {"name": 42})
if not check:
    for field in check.errors:
        print(field)          # name: must be string (expected string, got number)
```

## Usage

The free tier is 5 PDF pages or 5 images a month and stops there with a `quota_exceeded` error rather than
billing you; Solo is $5 a month for 200 PDF pages or 500 images. `usage()` reports where the account stands.

```python
usage = galley.usage()
usage.billable_units              # 1 per PNG/JPG, 1 per PDF page; cache hits are free
usage.free_renders_remaining      # counts down from 5 on the free tier
usage.spend_remaining_usd
usage.trial                       # not None only on a keyless trial
```

### The spend cap

On a paid plan the cap bounds what you're billed *past* the allowance, over the Stripe billing
period. The allowance itself never counts toward it, so `0` means "never bill me past my plan".

```python
cap = galley.set_spend_cap(200)   # dollars a month, 0 to 1000
cap.period_spend_cap_usd          # 200 monthly, 2400 on an annual plan
cap.applies_now                   # False on the free tier: it can't be billed
```

Above $1,000 a month, write to support@galleyrender.com. Raising it emails the address on the
account.

## Errors

Every failure is a `GalleyError` carrying the API's own envelope: a stable `type`, a `docs_url` and,
for validation, the field path, the expected type, what arrived and a value that would be accepted.

```python
from galley_render import GalleyError, GalleyConnectionError, GalleyTimeoutError

try:
    galley.render("invoice", data={})
except GalleyError as err:
    err.type          # "validation_error"
    err.status        # 422
    err.retryable     # False
    err.request_id    # quote this in a support mail
    for field in err.errors:
        print(field.path, field.message, field.expected, field.received, field.example)
```

| Type | Status | What to do |
|---|---|---|
| `validation_error` | 422 | Fix the named fields. `err.errors` says exactly which. |
| `invalid_request` | 400 | The request shape is wrong, not the data. |
| `authentication_error` | 401 | Missing or bad key. |
| `not_found` | 404 | No such template, version or render on this account. |
| `quota_exceeded` | 402 | Trial or free tier spent. |
| `spend_cap_exceeded` | 402 | A paid plan's overage reached the account's spend cap. Raise it (up to $1,000 a month) or wait for `details.resets_on`. |
| `rate_limited` | 429 | Retried for you. |
| `asset_blocked` | 400 | An image or font URL failed the SSRF policy; use a public https URL. |
| `render_failed` | 422 | The template, run on this data, could not produce a document. `err.details["cause"]` is `"template"`. Not retried: fix the template or the data. |
| `render_failed` | 500 | The render failed on our side (`err.details["cause"]` is `"server"` or `"storage"`). Retried like any 5xx. |

`GalleyConnectionError` means no HTTP response at all — DNS, TLS, timeout. `GalleyTimeoutError`
means `wait()` gave up while the render was still queued; the render is not lost, so poll again or
take the webhook.

## Also

- [Node SDK](https://www.npmjs.com/package/galley-render) — same surface, zero dependencies.
- [MCP server](https://mcp.galleyrender.com/mcp) — the same operations as agent tools, no key needed
  to start.
- [Docs](https://galleyrender.com/docs/?utm_source=pypi) ·
  [API reference](https://galleyrender.com/docs/api/?utm_source=pypi) ·
  [Template language](https://galleyrender.com/docs/templates/?utm_source=pypi)

MIT licensed. Support: [support@galleyrender.com](mailto:support@galleyrender.com).
