Metadata-Version: 2.4
Name: rendergrid
Version: 0.1.0
Summary: Python client for the Rendergrid image API: typed methods, a wait helper, idempotency keys and retries.
License-Expression: MIT
Project-URL: Homepage, https://rendergrid.io
Project-URL: Documentation, https://rendergrid.io/docs/sdk
Keywords: rendergrid,image generation,api client
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Typing :: Typed
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: httpx<1,>=0.28.1
Requires-Dist: pydantic<3,>=2.12.5
Provides-Extra: test
Requires-Dist: pytest>=8.0.1; extra == "test"
Requires-Dist: pyyaml>=6.0.2; extra == "test"
Requires-Dist: mypy>=1.8.0; extra == "test"
Requires-Dist: flake8>=7.0.0; extra == "test"
Requires-Dist: black>=24.2.0; extra == "test"
Requires-Dist: isort>=5.13.2; extra == "test"
Dynamic: license-file

# rendergrid (Python)

The Python client for the [Rendergrid](https://rendergrid.io) image API: typed methods for the five
public calls, a `wait` helper, idempotency keys, retries, errors with the API's codes, and webhook
signature verification. Money comes back as `Decimal`, never as a float.

- Python 3.11 or newer.
- Two dependencies: `httpx` and `pydantic` (v2).
- Fully typed (`py.typed`).

```console
pip install rendergrid
```

The key is read from the `RENDERGRID_API_KEY` environment variable. Pass another source explicitly:
`Rendergrid(api_key=...)`.

## Quickstart

```python
from rendergrid import Rendergrid

client = Rendergrid()  # the key comes from RENDERGRID_API_KEY

creation = client.images.generate(
    model="nano-banana-pro",
    prompt="A ceramic mug on marble, soft window light",
    resolution="1K",
    aspect_ratio="4:5",
)
print(creation.id, creation.status, creation.cost)

result = creation.wait(timeout=180)  # polls no more often than every 5 seconds
print(result.status, result.result_urls)
```

`generate` submits the request and returns the accepted creation. `wait` polls it until it is
`completed` or `failed` and returns that state. `failed` is a result, not an exception: read
`result.error` and `result.failure` (`type`, `code`, `retryable`). If the time runs out you get a
`WaitTimeout` that carries the creation id, so you can wait again later.

## The five methods

| Call | Endpoint |
| --- | --- |
| `client.images.generate(model=..., prompt=..., ...)` | `POST /api/public/v1/images/generate` |
| `client.creations.get(id)` / `client.creations.wait(id)` | `GET /api/public/v1/creations/{id}` |
| `client.uploads.create(file)` | `POST /api/public/v1/uploads` |
| `client.models.list(category=None)` | `GET /api/public/v1/models` |
| `client.balance.get()` | `GET /api/public/v1/balance` |

A reference image goes through `uploads.create` (PNG, JPEG or WebP; a path, bytes or a binary
file); pass the returned id as `image_file_id`:

```python
upload = client.uploads.create("reference.png")
creation = client.images.generate(
    model="nano-banana-2",
    prompt="The same mug, on a wooden table",
    image_file_id=upload.file_id,
)
```

## What the library does for you

- **Idempotency.** Every `generate` call carries an `X-Idempotency-Key` (yours via
  `idempotency_key=...`, otherwise a fresh random one) and keeps it across retries, so a repeated
  send is never charged twice. Every error `generate` raises carries that key as
  `e.idempotency_key`; to repeat the call yourself, pass it back with
  `idempotency_key=e.idempotency_key`. A bare second `generate()` is a new request with a new key:
  if the first one had been accepted and charged, you pay twice.
- **Retries.** A lost connection, a 429 and a 5xx are repeated up to `max_retries` times (3 by
  default; `0` turns them off) with exponential back-off, and the wait is never shorter than
  `Retry-After`. An answer that says it is not retryable is not repeated. The one exception is
  `uploads.create`: it carries no idempotency key, so it is repeated only when it provably did not
  reach the API (a 429, a 503, or a connection that was refused or never made); a lost answer, a
  timeout while waiting or a 500 is raised at once, because a second POST could store a second
  object. `APIConnectionError.request_may_have_been_sent` says which kind of loss it was.
- **Waiting.** `wait` polls no more often than every 5 seconds and honours `Retry-After` and
  `min_interval_seconds`.
- **Errors.** Every non-success answer becomes an exception with `status`, `code`, `message`,
  `details`, `retryable` and `retry_after`.
- **Money.** Prices, balances and costs are `decimal.Decimal`, read from the exact text the API
  sent.

## Errors

```python
import time

import rendergrid

request = {"model": "nano-banana-2", "prompt": "A ceramic mug on marble"}
try:
    creation = client.images.generate(**request)
except rendergrid.InsufficientBalance as e:
    need, have = e.details["required"], e.details["available"]
    print("Needed:", need, "available:", have)
except rendergrid.RateLimited as e:
    time.sleep(e.retry_after)
    # repeat with the SAME key, never a bare generate(): see "Idempotency" above
    creation = client.images.generate(**request, idempotency_key=e.idempotency_key)
except rendergrid.APIConnectionError as e:
    # no answer came back, but the request may have been accepted and charged: the same key
    # makes the API answer with the first request's result instead of charging again
    creation = client.images.generate(**request, idempotency_key=e.idempotency_key)
except rendergrid.APIError as e:
    print(e.status, e.code, e.retryable)
```

The classes are named by the API's code: `ERR_INSUFFICIENT_BALANCE` is `InsufficientBalance`,
`ERR_RATE_LIMIT` is `RateLimited`, everything else is a plain `APIError`. A connection that never
came back is `APIConnectionError`; all of them derive from `RendergridError`. Whatever the class,
an error raised by `generate` has `idempotency_key` (also when the 202 could not be read).

## Webhooks

Verify a delivery against the raw bytes of the body, never against re-serialised JSON:

```python
import os

from flask import Flask, request
from rendergrid.webhooks import verify

app = Flask(__name__)


@app.post("/webhooks/rendergrid")
def hook():
    event = verify(
        secret=os.environ["RENDERGRID_WEBHOOK_SECRET"],
        headers=request.headers,
        body=request.get_data(),  # the raw bytes, not request.json
    )
    if not event.get("test"):
        save_result(event["id"], event["status"], event.get("result_urls"))
    return "", 204
```

`verify` checks the HMAC-SHA256 signature (`X-Webhook-Signature-256`) and the freshness of the
timestamp (`X-Timestamp`, 300 seconds by default) and raises `WebhookVerificationError` for
anything that does not verify. `body` is the raw bytes, or their exact text.

**Secret rotation.** After a rotation in the portal the previous secret stays valid for 24 hours.
Pass both and a delivery verifies when its signature matches either one, while the timestamp is
still enforced:

```python
event = verify(
    secret=[os.environ["RENDERGRID_WEBHOOK_SECRET"], os.environ["RENDERGRID_WEBHOOK_SECRET_PREVIOUS"]],
    headers=request.headers,
    body=request.get_data(),
)
```

Take the previous secret out of the list when the 24 hours are over. Every entry must have
something in it: an empty list or a blank entry (an unset variable, or only whitespace) is
refused, never skipped. `tolerance` (seconds, default 300) must be a finite number, 0 or more;
`NaN`, infinity and a negative number raise `ValueError`.

## Configuration

```python
client = Rendergrid(
    api_key="...",       # default: the RENDERGRID_API_KEY environment variable
    base_url="https://api.rendergrid.io",
    timeout=60.0,        # seconds for one request
    max_retries=3,
    http_client=None,    # your own httpx.Client (proxies, TLS)
)
```

Use the client as a context manager (`with Rendergrid() as client:`) or call `client.close()`.
Plain `http://` is accepted for `localhost` only; a base URL that would send the key in the clear
is refused.

## Versions

The library works with API v1; new fields in answers do not break older library versions. The
version history is in [CHANGELOG.md](CHANGELOG.md).

## Licence

MIT. The text is in [LICENSE](LICENSE).
