Metadata-Version: 2.5
Name: pulze-renderflow
Version: 2.0.9
Summary: Official RenderFlow API client for Python
Project-URL: Homepage, https://pulze.io/products/renderflow
Author-email: Pulze <support@pulze.io>
License-Expression: MIT
License-File: LICENSE
Keywords: 3dsmax,arnold,blender,cinema4d,corona,fusion,houdini,maya,network-rendering,nuke,pulze,redshift,render-farm,renderflow,vray
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Requires-Python: >=3.10
Description-Content-Type: text/markdown

# pulze-renderflow

Official Python client for the [RenderFlow](https://pulze.io/products/renderflow) API.

## Installation

```bash
pip install pulze-renderflow
```

The package has **no dependencies** — the whole client is standard library. That is a feature,
not an accident: drop the folder on `sys.path` and it works inside a DCC's bundled Python (Maya,
Houdini, Nuke, VRED), where installing anything is a fight.

## Quick start

```python
import renderflow as rf

farm = rf.connect()                     # the local service on 127.0.0.1:44442
# farm = rf.connect("https://rf.studio.lan:44445", api_key="rf_...")

for job in farm.jobs.get_jobs(status="working").data:
    print(job.name, job.status)
```

`connect()` falls back to the environment for each argument it isn't given: `RENDERFLOW_URL`
for the address, `RENDERFLOW_API_KEY` for the key (the same variable `rfcli` reads). With
neither, it talks to the service on this machine — which needs no credential, and is how a DCC
plugin on a workstation submits.

## Finding the operation you want

Operations are grouped by tag and named after the API document's `operationId`, so a route
reads the same here, in the TypeScript SDK, in `rfcli` and in the MCP tools:

```python
farm.tags()                 # every group: jobs, nodes, pools, schedule, licenses, ...
dir(farm.jobs)              # every operation in one: get_jobs, create_job, stop_job, ...
help(farm.jobs.get_jobs)    # the operation's own documentation, generated from the document
```

| you want | you write |
| --- | --- |
| `GET /jobs` | `farm.jobs.get_jobs()` |
| `GET /jobs/{id}` | `farm.jobs.get_job(id)` |
| `POST /jobs` | `farm.jobs.create_job(body=...)` |
| `POST /jobs/{id}/stop` | `farm.jobs.stop_job(id)` |
| `GET /nodes` | `farm.nodes.get_nodes()` |

## Submitting a job

A recipe — `type` plus a file and the flat per-DCC fields — is the short way in; the service
expands it into the step tree:

Every recipe requires `type`, `file` and `host` — `host` is which application renders it, and only
its `id` is mandatory:

```python
job = farm.jobs.create_job(body={
    "type": "blender.render",
    "file": "//share/scenes/shot_010.blend",
    "host": {"id": "blender", "version": "4.5"},
    "frame": "1-100",
    "pool_id": "...",
})

print(job.id, job.status)
farm.jobs.stop_job(job.id)
```

Bodies are plain dicts; answers read with dot access (`job.name`, `page.data`) and hand back the
raw payload via `.to_dict()`. An answer the service does not send as JSON comes back as **bytes** —
`farm.tasks.get_task_preview(id)` is a rendered frame, ready to write to a file.

## Waiting for a job

```python
job = farm.jobs.settled(job.id)                  # answers on completed or failed
job = farm.jobs.settled(job.id, interval=30)     # a render measured in hours
```

`None` means the job is no longer there. Archiving and deleting look the same from out here, and
neither is going to finish.

A stop is **not** an end, because suspending a job is resumable — pass
`statuses=["completed", "failed", "suspended"]` if a stop is an answer for your caller. `timeout` is
in seconds and there is none by default, since a long render legitimately takes days; it raises
`TimeoutError` rather than handing back a job that has not finished.

This polls rather than watching, so a wait needs no socket, no thread and no reconnect behaviour,
and it works where a proxy passes HTTP and refuses an upgrade. `interval` is the cost: two seconds is
right for a test render and wrong for an overnight one. When the moment a job finishes is what
matters, watch it instead.

## Live updates

One socket, shared by every subscription, opened on the first and closed with the last. No polling.

```python
def draw(event):
    if event.op == "resync":
        return refetch()          # events were missed, read again
    print(event.op, event.id, event.patch)

stop = farm.jobs.on(draw, {"id": job.id})
stop()
```

`on` is on every group whose collection the farm pushes: `jobs`, `tasks`, `nodes`, `pools`, `users`,
`user_groups`, `errors`, `schedule` and `licenses`. The second argument narrows it — `id` on
any of them, `step_id` on tasks, `node_id` on the node joins — and a list is one subscription over
several ids rather than several subscriptions.

An insert carries the whole row on `event.doc`. An update carries `event.patch`, **keyed by dotted
path**, so a job's progress arrives as `event.patch["progress.percent"]`. A delete carries the id.

**A `resync` event is not a change.** It means events were missed and the collection has to be read
again, because the socket has no replay: a reconnect, a dropped frame, or a client the farm outran
all mean the same thing. It arrives through the same handler precisely so there is nothing to forget
to pass.

`farm.live.close()` drops everything, and `farm.live.on_error` reports a refused credential; it
writes to stderr until you replace it. The socket runs on a daemon thread, so a script that forgot to
unsubscribe still exits.

There is still **no dependency**: `renderflow/live.py` is the client half of RFC 6455 hand-rolled on
`socket`, `ssl` and `struct`, because installing `websocket-client` into Maya is the fight this
package exists to avoid.

## Models

Typed names for every request and response schema live in `renderflow.models`, and your editor
reads them from `models.pyi` — autocomplete and type checking with nothing to install:

```python
from renderflow.models import MayaRenderRecipe

recipe: MayaRenderRecipe = {"type": "maya.render", "file": "//share/scenes/hero.mb", "host": {"id": "maya", "version": "2026"}}
farm.jobs.create_job(body=recipe)
```

At runtime a model is a dict — the wire is JSON and the service validates.

## Errors

A refused call raises `renderflow.wire.ApiError`, carrying the status and the service's own
message — `str(error)` is the sentence an operator should read.

## How this package is built

There is no generated client. `renderflow/wire.py` binds operations off the OpenAPI document at
runtime, and the document ships inside the package as `renderflow/openapi.json` — a committed
copy of the service's `openapi.public.json`. An operation added to the API arrives here the
moment that copy is refreshed:

```bash
python scripts/generate.py     # copies ../../service/openapi.public.json + rewrites models.pyi
```

`models.pyi` is written by [datamodel-code-generator](https://github.com/koxudaxi/datamodel-code-generator),
a dev-only tool — never a dependency of the package.
