Metadata-Version: 2.4
Name: rendex
Version: 1.5.2
Summary: Official Python SDK for Rendex — render HTML or Markdown to images, generate PDFs, capture screenshots, monitor pages for changes with Rendex Watch, mint hosted render links, and extract clean Markdown/JSON/HTML from URLs
Project-URL: Homepage, https://rendex.dev
Project-URL: Documentation, https://rendex.dev/docs
Author-email: Copperline Labs LLC <support@rendex.dev>
License-Expression: MIT
License-File: LICENSE
Keywords: ai-agent,automation,change-detection,html-to-image,no-code,pdf,rendering,rendex,screenshot,sdk,webpage-capture,website-monitoring
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
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: Typing :: Typed
Requires-Python: >=3.9
Requires-Dist: httpx>=0.27
Description-Content-Type: text/markdown

# rendex

Official Python SDK for [Rendex](https://rendex.dev) — render HTML, a URL, or Markdown to an image or PDF with one call.

**Common job:** render HTML charts, financial tables, and report sections to high-resolution PNG from a backend script, then embed them in the PDF or report your app generates — no headless browser to install.

```python
# pip install rendex
from rendex import Rendex
result = Rendex("YOUR_API_KEY").render_html("<table>…your report HTML…</table>", format="png")
open("table.png", "wb").write(result.image)  # embed result.image in your PDF
```

- Skip the `requests` boilerplate — typed `render_html()` returns image bytes + metadata
- Full type hints (PEP 561 compatible)
- Single dependency (`httpx`)
- Sync API with context manager support
- Typed error handling with API error codes

## Install

```bash
pip install rendex
```

## Quick Start

```python
from pathlib import Path
from rendex import Rendex

rendex = Rendex("your-api-key")

# Render raw HTML straight to a PNG
result = rendex.render_html("<h1>Hello, world</h1>")
Path("hello.png").write_bytes(result.image)

# Or render Markdown (converted to HTML server-side)
md = rendex.render_markdown("# Hello, world\n\nRendered from **Markdown**.")
Path("hello-md.png").write_bytes(md.image)

# Or capture a live URL
shot = rendex.screenshot("https://example.com", format="png", full_page=True)
Path("screenshot.png").write_bytes(shot.image)

print(f"{result.metadata.bytes_size} bytes, loaded in {result.metadata.load_time_ms}ms")
```

## API Reference

### `Rendex(api_key, *, base_url="https://api.rendex.dev")`

Create a new Rendex client.

```python
rendex = Rendex("your-api-key")

# Or with context manager for connection reuse
with Rendex("your-api-key") as rendex:
    result = rendex.screenshot("https://example.com")
```

### `rendex.screenshot(url, **options)`

Capture a screenshot and return the binary image with metadata.

```python
result = rendex.screenshot(
    "https://example.com",
    format="webp",
    width=1920,
    height=1080,
    dark_mode=True,
)
Path("screenshot.webp").write_bytes(result.image)
print(result.metadata.load_time_ms)  # 350
```

**Returns** `ScreenshotResult`:
- `image` — `bytes` of the captured image
- `metadata` — `ScreenshotMetadata` with url, dimensions, format, bytes_size, load_time_ms, quality, etc.

### `rendex.render_html(html, **options)`

Render raw HTML straight to an image and return the binary result. Convenience wrapper over `screenshot(html=...)`.

```python
result = rendex.render_html("<h1>Hello, world</h1>", format="png", width=1200)
Path("hello.png").write_bytes(result.image)
```

**Returns** `ScreenshotResult` — same shape as `screenshot()`.

You can also pass `html` directly to `screenshot()` / `screenshot_json()`:

```python
result = rendex.screenshot(html="<h1>Hello, world</h1>", full_page=True)
```

> HTML rendering is POST-only and accepts up to 5MB. It is not available via `screenshot_url()` (the GET endpoint can't carry an HTML body). Provide exactly one of `url`, `html`, or `markdown`; the SDK raises `RendexError` locally if you supply none or more than one.

### `rendex.render_markdown(markdown, **options)`

Render Markdown straight to an image and return the binary result. The server converts the Markdown to HTML before rendering. Convenience wrapper over `screenshot(markdown=...)`.

```python
result = rendex.render_markdown("# Hello, world\n\nRendered from **Markdown**.", format="png", width=1200)
Path("hello.png").write_bytes(result.image)
```

**Returns** `ScreenshotResult` — same shape as `screenshot()`.

You can also pass `markdown` directly to `screenshot()` / `screenshot_json()`:

```python
result = rendex.screenshot(markdown="# Hello, world", full_page=True)
```

> Markdown rendering is POST-only and accepts up to 5MB. Like `html`, it is not available via `screenshot_url()`. Provide exactly one of `url`, `html`, or `markdown`.

### `rendex.screenshot_json(url, **options)`

Capture a screenshot and return JSON with a base64-encoded image.

```python
result = rendex.screenshot_json("https://example.com")
print(result["data"]["bytesSize"])        # 45823
print(result["meta"]["usage"]["remaining"])  # 499
```

**Returns** `ScreenshotJsonResponse` dict with `data` (image + metadata) and `meta` (request ID, usage).

### `rendex.render_html_json(html, **options)`

Render raw HTML and return JSON with a base64-encoded image. Convenience wrapper over `screenshot_json(html=...)`.

```python
result = rendex.render_html_json("<h1>Invoice #1042</h1>", format="pdf", pdf_format="Letter")
print(result["data"]["format"])  # "pdf"
```

### `rendex.render_markdown_json(markdown, **options)`

Render Markdown and return JSON with a base64-encoded image. The server converts the Markdown to HTML before rendering. Convenience wrapper over `screenshot_json(markdown=...)`.

```python
result = rendex.render_markdown_json("# Invoice #1042", format="pdf", pdf_format="Letter")
print(result["data"]["format"])  # "pdf"
```

### Mustache data templating

Pass a `data` dict alongside `html` or `markdown` to render logic-less [Mustache](https://mustache.github.io/) templates before capture. This lets you generate invoices, reports, or any dynamic content without string-formatting Python-side.

```python
from pathlib import Path
from rendex import Rendex

rendex = Rendex("your-api-key")

# Markdown invoice with variable substitution and a loop
template = """
# Invoice \#{{number}}

| Item | Qty | Price |
|------|-----|-------|
{{#items}}
| {{name}} | {{qty}} | {{price}} |
{{/items}}

**Total: {{total}}**
"""

result = rendex.render_markdown(
    template,
    data={
        "number": "1042",
        "items": [
            {"name": "Screenshot API — Pro", "qty": 1, "price": "€ 49.00"},
            {"name": "Priority support", "qty": 1, "price": "€ 5.00"},
        ],
        "total": "€ 54.00",
    },
    format="pdf",
    pdf_format="A4",
)
Path("invoice-1042.pdf").write_bytes(result.image)
```

The same `data` parameter works on `render_html`, `screenshot`, `screenshot_json`, `render_html_json`, and `render_markdown_json`.

**Template syntax** (server-side, logic-less Mustache):

| Syntax | Behaviour |
|--------|-----------|
| `{{var}}` | HTML-escaped substitution |
| `{{{var}}}` | Raw (unescaped) substitution |
| `{{#section}}...{{/section}}` | Render block for each item in a list (or once if truthy) |
| `{{^section}}...{{/section}}` | Render block when section is falsy / empty |
| `{{a.b}}` | Nested property access |

> `data` is not valid with `url` (the server returns 400). Serialized payload is capped at 256 KB server-side.

### `rendex.screenshot_url(url, **options)`

Generate a GET URL for embedding. No network call — pure URL builder.

```python
url = rendex.screenshot_url("https://example.com", format="png", width=1200)
# Use in <img> tags, OpenGraph, etc.
```

> **Note**: The API key is included in the URL. Use server-side only.

### Async jobs & batches

For long-running captures or many URLs at once, submit work asynchronously and poll for results.

```python
# Single async job: returns immediately, poll for the result
job = rendex.screenshot_json("https://example.com", async_mode=True)
job_id = job["data"]["jobId"]

status = rendex.job_status(job_id)
if status["data"]["status"] == "completed":
    print(status["data"]["resultUrl"])

# Batch: 1–500 URLs with shared defaults
batch = rendex.batch(
    ["https://example.com", "https://github.com"],
    defaults={"format": "webp", "full_page": True},
    cache_ttl=7200,
)
batch_id = batch["data"]["batchId"]

progress = rendex.batch_status(batch_id)
print(f'{progress["data"]["completedJobs"]}/{progress["data"]["totalJobs"]} done')
```

- `rendex.batch(urls, *, defaults=None, webhook_url=None, cache_ttl=None)` → `BatchCreateResponse`
- `rendex.job_status(job_id)` → `JobStatusResponse`
- `rendex.batch_status(batch_id)` → `BatchStatusResponse`

> Batch capture is URL-only (raw HTML is single-request); `defaults` accepts the same snake_case options as `screenshot()`.

### Screenshot Options

All options are keyword arguments in snake_case. Only `url` (positional) is required:

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `format` | `str` | `"png"` | `"png"`, `"jpeg"`, `"webp"`, or `"pdf"` |
| `width` | `int` | `1280` | Viewport width (320–3840) |
| `height` | `int` | `800` | Viewport height (240–2160) |
| `full_page` | `bool` | `False` | Capture the full scrollable page |
| `quality` | `int` | `80` | JPEG/WebP quality (1–100, default 80) |
| `delay` | `int` | `0` | Delay before capture in ms (0–10000) |
| `dark_mode` | `bool` | `False` | Emulate dark mode |
| `device_scale_factor` | `float` | `2` | Device pixel ratio (1–3). 2× Retina by default |
| `block_ads` | `bool` | `True` | Block ads and trackers |
| `block_resource_types` | `list` | — | Block: `"font"`, `"image"`, `"media"`, `"stylesheet"`, `"other"` |
| `timeout` | `int` | `30` | Page load timeout in seconds (5–60) |
| `wait_until` | `str` | `"networkidle2"` | `"load"`, `"domcontentloaded"`, `"networkidle0"`, `"networkidle2"` |
| `wait_for_selector` | `str` | — | CSS selector to wait for |
| `best_attempt` | `bool` | `True` | Return best-effort screenshot on timeout |
| `selector` | `str` | — | Capture a specific element by CSS selector |
| `css` / `js` | `str` | — | Inject custom CSS / JavaScript before capture (max 50KB each) |
| `cookies` / `headers` | `list` / `dict` | — | Cookies (max 50) and custom HTTP headers for the request |
| `user_agent` | `str` | — | Override the browser user-agent string |
| `geo` / `geo_city` / `geo_state` | `str` | — | Geo-targeted capture (Pro/Enterprise) |
| `pdf_format` | `str` | `"A4"` | PDF page size: `"A4"`, `"Letter"`, `"Legal"`, `"Tabloid"`, `"A3"` (format=`"pdf"`) |
| `pdf_landscape` | `bool` | `False` | PDF landscape orientation |
| `pdf_print_background` | `bool` | `True` | Print background colors/images in the PDF |
| `pdf_margin` | `dict` | — | PDF margins, e.g. `{"top": "1cm", "bottom": "1cm"}` |
| `pdf_scale` | `float` | `1` | PDF render scale (0.1–2) |
| `async_mode` | `bool` | `False` | Return a job ID immediately (sent as `async`) |
| `webhook_url` | `str` | — | URL to call when an async capture completes |
| `cache_ttl` | `int` | `86400` | Signed result URL TTL in seconds (3600–2592000) |

### Rendex Watch

Monitor a URL on a schedule and get alerted when it changes — real-Chrome visual diff (with a highlighted overlay), an extracted-text diff, or both. One API key, your shared credit pool.

```python
from rendex import Rendex

with Rendex("your-api-key") as rendex:
    # Create a watch (an active watch captures its baseline immediately — 1 credit)
    watch = rendex.create_watch(
        "https://example.com/pricing",
        diff_mode="visual",            # "visual" | "text" | "both"
        interval_minutes=1440,         # your plan's floor is the minimum
        webhook_url="https://hooks.example.com/rendex",  # Starter+; HMAC-signed
        notify_email="you@example.com",                  # any plan; your account email
    )

    # Dry-run a config BEFORE saving — what we'd capture + is the page reachable
    test = rendex.test_watch("https://example.com", render_params={"selector": "#price"})
    if test["reachable"]:
        print(test["screenshotUrl"])

    rendex.list_watches(status="active")          # {"items": [...], "nextCursor": ...}
    rendex.get_watch(watch["id"])
    rendex.run_watch(watch["id"])                 # run a check now → {"runId": ...}
    runs = rendex.list_watch_runs(watch["id"], limit=10)
    rendex.update_watch(watch["id"], paused=True) # pause; paused=False resumes; webhook_url=None clears it
    rendex.delete_watch(watch["id"])
```

| Method | Endpoint | Returns |
|--------|----------|---------|
| `create_watch(url, **opts)` | `POST /v1/watches` | `Watch` |
| `list_watches(*, status, cursor, limit)` | `GET /v1/watches` | `{"items": [...], "nextCursor"}` |
| `get_watch(watch_id)` | `GET /v1/watches/:id` | `Watch` |
| `update_watch(watch_id, **patch)` | `PATCH /v1/watches/:id` | `Watch` |
| `delete_watch(watch_id)` | `DELETE /v1/watches/:id` | `None` |
| `run_watch(watch_id)` | `POST /v1/watches/:id/run` | `{"runId", "watchId", "status"}` |
| `list_watch_runs(watch_id, *, cursor, limit)` | `GET /v1/watches/:id/runs` | `{"items": [...], "nextCursor"}` |
| `test_watch(url, **opts)` | `POST /v1/watches/test` | `WatchTestResult` |

Render knobs (`full_page`, `selector`, `ignore_regions`, `ignore_text`, `ua_mode`, …) go under `render_params=` (snake_case, camelCased on send). To clear an alert channel on update, pass it explicitly as `None` (e.g. `update_watch(id, webhook_url=None)`).

### Verifying Watch webhooks

When a watched page changes Rendex POSTs an HMAC-signed `watch.changed` (or `watch.recovered` / `watch.error`) event to your `webhook_url`. Verify it with the raw request body:

```python
from rendex import verify_webhook, RendexWebhookPayload
import json, os

# raw_body is the unparsed request body string
ok = verify_webhook(
    raw_body,
    request.headers["x-rendex-signature"],
    request.headers["x-rendex-timestamp"],
    os.environ["WEBHOOK_SIGNING_SECRET"],
)
if not ok:
    abort(400)

event: RendexWebhookPayload = json.loads(raw_body)
if event["event"] == "watch.changed":
    print(event["url"], event["diffScore"], event["diffOverlayUrl"])
```

> A change event uses one vocabulary everywhere: `diffScore`, `diffPixels`, `beforeUrl`, `afterUrl`, `diffOverlayUrl` — identical on the webhook payload and on a REST `WatchRun`.

## Error Handling

```python
from rendex import Rendex, RendexApiError, RendexNetworkError

rendex = Rendex("your-api-key")

try:
    rendex.screenshot("https://example.com")
except RendexApiError as e:
    # API returned an error
    print(e.error_code)   # "RATE_LIMITED", "VALIDATION_ERROR", etc.
    print(e.status_code)  # 429, 400, etc.
    print(e.request_id)   # For debugging with Rendex support
    print(e.details)      # Validation details (if any)
except RendexNetworkError as e:
    # Network failure (DNS, timeout, connection refused)
    print(f"Network error: {e}")
```

### Error Codes

| Code | HTTP Status | Description |
|------|-------------|-------------|
| `VALIDATION_ERROR` | 400 | Invalid request parameters |
| `INVALID_URL` | 400 | URL failed SSRF validation |
| `TIMEOUT` | 408 | Page took too long to load |
| `CAPTURE_FAILED` | 500 | Browser rendering error |
| `RATE_LIMITED` | 429 | Rate limit exceeded |
| `USAGE_EXCEEDED` | 429 | Monthly credit limit reached |
| `MISSING_API_KEY` | 401 | No API key provided |
| `INVALID_API_KEY` | 401 | API key verification failed |

## License

MIT - [Copperline Labs LLC](https://copperlinelabs.com)
