pulze-renderflow 2.0.7__tar.gz → 2.0.9__tar.gz
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.
- {pulze_renderflow-2.0.7 → pulze_renderflow-2.0.9}/CLAUDE.md +19 -1
- {pulze_renderflow-2.0.7 → pulze_renderflow-2.0.9}/PKG-INFO +66 -6
- {pulze_renderflow-2.0.7 → pulze_renderflow-2.0.9}/README.md +65 -5
- {pulze_renderflow-2.0.7 → pulze_renderflow-2.0.9}/pyproject.toml +1 -1
- pulze_renderflow-2.0.9/renderflow/farm.py +243 -0
- pulze_renderflow-2.0.9/renderflow/live.py +533 -0
- {pulze_renderflow-2.0.7 → pulze_renderflow-2.0.9}/renderflow/models.pyi +3134 -2722
- {pulze_renderflow-2.0.7 → pulze_renderflow-2.0.9}/renderflow/openapi.json +3802 -2771
- {pulze_renderflow-2.0.7 → pulze_renderflow-2.0.9}/renderflow/wire.py +15 -3
- pulze_renderflow-2.0.9/scripts/test.mjs +48 -0
- {pulze_renderflow-2.0.7 → pulze_renderflow-2.0.9}/test/test_client.py +242 -136
- pulze_renderflow-2.0.9/test/test_live.py +404 -0
- {pulze_renderflow-2.0.7 → pulze_renderflow-2.0.9}/test/test_runtime.py +184 -0
- pulze_renderflow-2.0.7/renderflow/farm.py +0 -125
- {pulze_renderflow-2.0.7 → pulze_renderflow-2.0.9}/.gitignore +0 -0
- {pulze_renderflow-2.0.7 → pulze_renderflow-2.0.9}/LICENSE +0 -0
- {pulze_renderflow-2.0.7 → pulze_renderflow-2.0.9}/MANIFEST.in +0 -0
- {pulze_renderflow-2.0.7 → pulze_renderflow-2.0.9}/renderflow/__init__.py +0 -0
- {pulze_renderflow-2.0.7 → pulze_renderflow-2.0.9}/renderflow/__init__.pyi +0 -0
- {pulze_renderflow-2.0.7 → pulze_renderflow-2.0.9}/renderflow/models.py +0 -0
- {pulze_renderflow-2.0.7 → pulze_renderflow-2.0.9}/renderflow/py.typed +0 -0
- {pulze_renderflow-2.0.7 → pulze_renderflow-2.0.9}/renderflow/runtime.py +0 -0
- {pulze_renderflow-2.0.7 → pulze_renderflow-2.0.9}/scripts/build.py +0 -0
- {pulze_renderflow-2.0.7 → pulze_renderflow-2.0.9}/scripts/generate.py +0 -0
|
@@ -19,9 +19,27 @@ plugin that embeds it.
|
|
|
19
19
|
|
|
20
20
|
```bash
|
|
21
21
|
python scripts/generate.py # refresh openapi.json + models.pyi after a public route change
|
|
22
|
-
python test/test_runtime.py # stdlib unittest, no pytest
|
|
22
|
+
python test/test_runtime.py # the plugin runtime; stdlib unittest, no pytest
|
|
23
|
+
python test/test_client.py # the wire client
|
|
24
|
+
python test/test_live.py # the hand-rolled WebSocket
|
|
25
|
+
npm run test-python # from the repo root: all three, and what the gate runs
|
|
23
26
|
```
|
|
24
27
|
|
|
28
|
+
All three suites are in `test-suites` through `scripts/test.mjs`, which the root's `test-python`
|
|
29
|
+
calls — this package is not an npm workspace, so there is no `npm test --workspace` that reaches it.
|
|
30
|
+
A missing interpreter **fails** that script rather than skipping: `renderflow/runtime.py` is the
|
|
31
|
+
plugin API a node imports, and no type-checker anywhere can see it.
|
|
32
|
+
|
|
33
|
+
`farm.py` holds what is not an operation of the document: `JobsGroup.settled`, the wait, and
|
|
34
|
+
`LiveGroup.on`, the subscription. `GROUPS` is how one tag gets more than the document gives it, and
|
|
35
|
+
`LIVE_ENTITIES` is the rest — group name to socket entity, so a group whose collection the farm
|
|
36
|
+
pushes gets `on` and one that has no live collection does not. A group that adds something overrides
|
|
37
|
+
`__dir__` too, because `dir(farm.jobs)` is how this client is meant to be explored.
|
|
38
|
+
|
|
39
|
+
`live.py` is the client half of RFC 6455, hand-rolled on `socket`, `ssl` and `struct`, because the
|
|
40
|
+
standard library has no WebSocket client and this package is allowed no dependency. It speaks the
|
|
41
|
+
same protocol as `sdks/typescript/src/live.ts`, frame for frame.
|
|
42
|
+
|
|
25
43
|
Both generated files are **committed** — review them like code. Publishing is manual
|
|
26
44
|
(`python -m build` + `twine upload`), on its own cadence; this package's version field is real.
|
|
27
45
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: pulze-renderflow
|
|
3
|
-
Version: 2.0.
|
|
3
|
+
Version: 2.0.9
|
|
4
4
|
Summary: Official RenderFlow API client for Python
|
|
5
5
|
Project-URL: Homepage, https://pulze.io/products/renderflow
|
|
6
6
|
Author-email: Pulze <support@pulze.io>
|
|
@@ -18,7 +18,7 @@ Classifier: Programming Language :: Python :: 3.13
|
|
|
18
18
|
Requires-Python: >=3.10
|
|
19
19
|
Description-Content-Type: text/markdown
|
|
20
20
|
|
|
21
|
-
# renderflow
|
|
21
|
+
# pulze-renderflow
|
|
22
22
|
|
|
23
23
|
Official Python client for the [RenderFlow](https://pulze.io/products/renderflow) API.
|
|
24
24
|
|
|
@@ -38,7 +38,7 @@ Houdini, Nuke, VRED), where installing anything is a fight.
|
|
|
38
38
|
import renderflow as rf
|
|
39
39
|
|
|
40
40
|
farm = rf.connect() # the local service on 127.0.0.1:44442
|
|
41
|
-
# farm = rf.connect("https://rf.studio.lan:
|
|
41
|
+
# farm = rf.connect("https://rf.studio.lan:44445", api_key="rf_...")
|
|
42
42
|
|
|
43
43
|
for job in farm.jobs.get_jobs(status="working").data:
|
|
44
44
|
print(job.name, job.status)
|
|
@@ -55,7 +55,7 @@ Operations are grouped by tag and named after the API document's `operationId`,
|
|
|
55
55
|
reads the same here, in the TypeScript SDK, in `rfcli` and in the MCP tools:
|
|
56
56
|
|
|
57
57
|
```python
|
|
58
|
-
farm.tags() # every group: jobs, nodes, pools, schedule, ...
|
|
58
|
+
farm.tags() # every group: jobs, nodes, pools, schedule, licenses, ...
|
|
59
59
|
dir(farm.jobs) # every operation in one: get_jobs, create_job, stop_job, ...
|
|
60
60
|
help(farm.jobs.get_jobs) # the operation's own documentation, generated from the document
|
|
61
61
|
```
|
|
@@ -73,10 +73,14 @@ help(farm.jobs.get_jobs) # the operation's own documentation, generated from
|
|
|
73
73
|
A recipe — `type` plus a file and the flat per-DCC fields — is the short way in; the service
|
|
74
74
|
expands it into the step tree:
|
|
75
75
|
|
|
76
|
+
Every recipe requires `type`, `file` and `host` — `host` is which application renders it, and only
|
|
77
|
+
its `id` is mandatory:
|
|
78
|
+
|
|
76
79
|
```python
|
|
77
80
|
job = farm.jobs.create_job(body={
|
|
78
81
|
"type": "blender.render",
|
|
79
82
|
"file": "//share/scenes/shot_010.blend",
|
|
83
|
+
"host": {"id": "blender", "version": "4.5"},
|
|
80
84
|
"frame": "1-100",
|
|
81
85
|
"pool_id": "...",
|
|
82
86
|
})
|
|
@@ -86,7 +90,63 @@ farm.jobs.stop_job(job.id)
|
|
|
86
90
|
```
|
|
87
91
|
|
|
88
92
|
Bodies are plain dicts; answers read with dot access (`job.name`, `page.data`) and hand back the
|
|
89
|
-
raw payload via `.to_dict()`.
|
|
93
|
+
raw payload via `.to_dict()`. An answer the service does not send as JSON comes back as **bytes** —
|
|
94
|
+
`farm.tasks.get_task_preview(id)` is a rendered frame, ready to write to a file.
|
|
95
|
+
|
|
96
|
+
## Waiting for a job
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
job = farm.jobs.settled(job.id) # answers on completed or failed
|
|
100
|
+
job = farm.jobs.settled(job.id, interval=30) # a render measured in hours
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
`None` means the job is no longer there. Archiving and deleting look the same from out here, and
|
|
104
|
+
neither is going to finish.
|
|
105
|
+
|
|
106
|
+
A stop is **not** an end, because suspending a job is resumable — pass
|
|
107
|
+
`statuses=["completed", "failed", "suspended"]` if a stop is an answer for your caller. `timeout` is
|
|
108
|
+
in seconds and there is none by default, since a long render legitimately takes days; it raises
|
|
109
|
+
`TimeoutError` rather than handing back a job that has not finished.
|
|
110
|
+
|
|
111
|
+
This polls rather than watching, so a wait needs no socket, no thread and no reconnect behaviour,
|
|
112
|
+
and it works where a proxy passes HTTP and refuses an upgrade. `interval` is the cost: two seconds is
|
|
113
|
+
right for a test render and wrong for an overnight one. When the moment a job finishes is what
|
|
114
|
+
matters, watch it instead.
|
|
115
|
+
|
|
116
|
+
## Live updates
|
|
117
|
+
|
|
118
|
+
One socket, shared by every subscription, opened on the first and closed with the last. No polling.
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
def draw(event):
|
|
122
|
+
if event.op == "resync":
|
|
123
|
+
return refetch() # events were missed, read again
|
|
124
|
+
print(event.op, event.id, event.patch)
|
|
125
|
+
|
|
126
|
+
stop = farm.jobs.on(draw, {"id": job.id})
|
|
127
|
+
stop()
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
`on` is on every group whose collection the farm pushes: `jobs`, `tasks`, `nodes`, `pools`, `users`,
|
|
131
|
+
`user_groups`, `errors`, `schedule` and `licenses`. The second argument narrows it — `id` on
|
|
132
|
+
any of them, `step_id` on tasks, `node_id` on the node joins — and a list is one subscription over
|
|
133
|
+
several ids rather than several subscriptions.
|
|
134
|
+
|
|
135
|
+
An insert carries the whole row on `event.doc`. An update carries `event.patch`, **keyed by dotted
|
|
136
|
+
path**, so a job's progress arrives as `event.patch["progress.percent"]`. A delete carries the id.
|
|
137
|
+
|
|
138
|
+
**A `resync` event is not a change.** It means events were missed and the collection has to be read
|
|
139
|
+
again, because the socket has no replay: a reconnect, a dropped frame, or a client the farm outran
|
|
140
|
+
all mean the same thing. It arrives through the same handler precisely so there is nothing to forget
|
|
141
|
+
to pass.
|
|
142
|
+
|
|
143
|
+
`farm.live.close()` drops everything, and `farm.live.on_error` reports a refused credential; it
|
|
144
|
+
writes to stderr until you replace it. The socket runs on a daemon thread, so a script that forgot to
|
|
145
|
+
unsubscribe still exits.
|
|
146
|
+
|
|
147
|
+
There is still **no dependency**: `renderflow/live.py` is the client half of RFC 6455 hand-rolled on
|
|
148
|
+
`socket`, `ssl` and `struct`, because installing `websocket-client` into Maya is the fight this
|
|
149
|
+
package exists to avoid.
|
|
90
150
|
|
|
91
151
|
## Models
|
|
92
152
|
|
|
@@ -96,7 +156,7 @@ reads them from `models.pyi` — autocomplete and type checking with nothing to
|
|
|
96
156
|
```python
|
|
97
157
|
from renderflow.models import MayaRenderRecipe
|
|
98
158
|
|
|
99
|
-
recipe: MayaRenderRecipe = {"type": "maya.render", "file": "//share/scenes/hero.mb", "host": {"
|
|
159
|
+
recipe: MayaRenderRecipe = {"type": "maya.render", "file": "//share/scenes/hero.mb", "host": {"id": "maya", "version": "2026"}}
|
|
100
160
|
farm.jobs.create_job(body=recipe)
|
|
101
161
|
```
|
|
102
162
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# renderflow
|
|
1
|
+
# pulze-renderflow
|
|
2
2
|
|
|
3
3
|
Official Python client for the [RenderFlow](https://pulze.io/products/renderflow) API.
|
|
4
4
|
|
|
@@ -18,7 +18,7 @@ Houdini, Nuke, VRED), where installing anything is a fight.
|
|
|
18
18
|
import renderflow as rf
|
|
19
19
|
|
|
20
20
|
farm = rf.connect() # the local service on 127.0.0.1:44442
|
|
21
|
-
# farm = rf.connect("https://rf.studio.lan:
|
|
21
|
+
# farm = rf.connect("https://rf.studio.lan:44445", api_key="rf_...")
|
|
22
22
|
|
|
23
23
|
for job in farm.jobs.get_jobs(status="working").data:
|
|
24
24
|
print(job.name, job.status)
|
|
@@ -35,7 +35,7 @@ Operations are grouped by tag and named after the API document's `operationId`,
|
|
|
35
35
|
reads the same here, in the TypeScript SDK, in `rfcli` and in the MCP tools:
|
|
36
36
|
|
|
37
37
|
```python
|
|
38
|
-
farm.tags() # every group: jobs, nodes, pools, schedule, ...
|
|
38
|
+
farm.tags() # every group: jobs, nodes, pools, schedule, licenses, ...
|
|
39
39
|
dir(farm.jobs) # every operation in one: get_jobs, create_job, stop_job, ...
|
|
40
40
|
help(farm.jobs.get_jobs) # the operation's own documentation, generated from the document
|
|
41
41
|
```
|
|
@@ -53,10 +53,14 @@ help(farm.jobs.get_jobs) # the operation's own documentation, generated from
|
|
|
53
53
|
A recipe — `type` plus a file and the flat per-DCC fields — is the short way in; the service
|
|
54
54
|
expands it into the step tree:
|
|
55
55
|
|
|
56
|
+
Every recipe requires `type`, `file` and `host` — `host` is which application renders it, and only
|
|
57
|
+
its `id` is mandatory:
|
|
58
|
+
|
|
56
59
|
```python
|
|
57
60
|
job = farm.jobs.create_job(body={
|
|
58
61
|
"type": "blender.render",
|
|
59
62
|
"file": "//share/scenes/shot_010.blend",
|
|
63
|
+
"host": {"id": "blender", "version": "4.5"},
|
|
60
64
|
"frame": "1-100",
|
|
61
65
|
"pool_id": "...",
|
|
62
66
|
})
|
|
@@ -66,7 +70,63 @@ farm.jobs.stop_job(job.id)
|
|
|
66
70
|
```
|
|
67
71
|
|
|
68
72
|
Bodies are plain dicts; answers read with dot access (`job.name`, `page.data`) and hand back the
|
|
69
|
-
raw payload via `.to_dict()`.
|
|
73
|
+
raw payload via `.to_dict()`. An answer the service does not send as JSON comes back as **bytes** —
|
|
74
|
+
`farm.tasks.get_task_preview(id)` is a rendered frame, ready to write to a file.
|
|
75
|
+
|
|
76
|
+
## Waiting for a job
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
job = farm.jobs.settled(job.id) # answers on completed or failed
|
|
80
|
+
job = farm.jobs.settled(job.id, interval=30) # a render measured in hours
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
`None` means the job is no longer there. Archiving and deleting look the same from out here, and
|
|
84
|
+
neither is going to finish.
|
|
85
|
+
|
|
86
|
+
A stop is **not** an end, because suspending a job is resumable — pass
|
|
87
|
+
`statuses=["completed", "failed", "suspended"]` if a stop is an answer for your caller. `timeout` is
|
|
88
|
+
in seconds and there is none by default, since a long render legitimately takes days; it raises
|
|
89
|
+
`TimeoutError` rather than handing back a job that has not finished.
|
|
90
|
+
|
|
91
|
+
This polls rather than watching, so a wait needs no socket, no thread and no reconnect behaviour,
|
|
92
|
+
and it works where a proxy passes HTTP and refuses an upgrade. `interval` is the cost: two seconds is
|
|
93
|
+
right for a test render and wrong for an overnight one. When the moment a job finishes is what
|
|
94
|
+
matters, watch it instead.
|
|
95
|
+
|
|
96
|
+
## Live updates
|
|
97
|
+
|
|
98
|
+
One socket, shared by every subscription, opened on the first and closed with the last. No polling.
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
def draw(event):
|
|
102
|
+
if event.op == "resync":
|
|
103
|
+
return refetch() # events were missed, read again
|
|
104
|
+
print(event.op, event.id, event.patch)
|
|
105
|
+
|
|
106
|
+
stop = farm.jobs.on(draw, {"id": job.id})
|
|
107
|
+
stop()
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
`on` is on every group whose collection the farm pushes: `jobs`, `tasks`, `nodes`, `pools`, `users`,
|
|
111
|
+
`user_groups`, `errors`, `schedule` and `licenses`. The second argument narrows it — `id` on
|
|
112
|
+
any of them, `step_id` on tasks, `node_id` on the node joins — and a list is one subscription over
|
|
113
|
+
several ids rather than several subscriptions.
|
|
114
|
+
|
|
115
|
+
An insert carries the whole row on `event.doc`. An update carries `event.patch`, **keyed by dotted
|
|
116
|
+
path**, so a job's progress arrives as `event.patch["progress.percent"]`. A delete carries the id.
|
|
117
|
+
|
|
118
|
+
**A `resync` event is not a change.** It means events were missed and the collection has to be read
|
|
119
|
+
again, because the socket has no replay: a reconnect, a dropped frame, or a client the farm outran
|
|
120
|
+
all mean the same thing. It arrives through the same handler precisely so there is nothing to forget
|
|
121
|
+
to pass.
|
|
122
|
+
|
|
123
|
+
`farm.live.close()` drops everything, and `farm.live.on_error` reports a refused credential; it
|
|
124
|
+
writes to stderr until you replace it. The socket runs on a daemon thread, so a script that forgot to
|
|
125
|
+
unsubscribe still exits.
|
|
126
|
+
|
|
127
|
+
There is still **no dependency**: `renderflow/live.py` is the client half of RFC 6455 hand-rolled on
|
|
128
|
+
`socket`, `ssl` and `struct`, because installing `websocket-client` into Maya is the fight this
|
|
129
|
+
package exists to avoid.
|
|
70
130
|
|
|
71
131
|
## Models
|
|
72
132
|
|
|
@@ -76,7 +136,7 @@ reads them from `models.pyi` — autocomplete and type checking with nothing to
|
|
|
76
136
|
```python
|
|
77
137
|
from renderflow.models import MayaRenderRecipe
|
|
78
138
|
|
|
79
|
-
recipe: MayaRenderRecipe = {"type": "maya.render", "file": "//share/scenes/hero.mb", "host": {"
|
|
139
|
+
recipe: MayaRenderRecipe = {"type": "maya.render", "file": "//share/scenes/hero.mb", "host": {"id": "maya", "version": "2026"}}
|
|
80
140
|
farm.jobs.create_job(body=recipe)
|
|
81
141
|
```
|
|
82
142
|
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""The RenderFlow API client.
|
|
2
|
+
|
|
3
|
+
import renderflow as rf
|
|
4
|
+
|
|
5
|
+
farm = rf.connect() # the local service, no key
|
|
6
|
+
farm = rf.connect("https://rf.studio.lan:44445", api_key="rf_...") # a remote farm
|
|
7
|
+
|
|
8
|
+
for job in farm.jobs.get_jobs(status="working").data:
|
|
9
|
+
print(job.name, job.status)
|
|
10
|
+
|
|
11
|
+
Operations are grouped by tag and named after the document's ``operationId`` — the same names
|
|
12
|
+
the REST route, the TypeScript SDK, ``rfcli`` and the MCP tools use, so one operation reads the
|
|
13
|
+
same everywhere. ``farm.jobs.get_jobs`` is ``GET /jobs``; ``farm.nodes.stop_node`` is
|
|
14
|
+
``POST /nodes/{id}/stop``.
|
|
15
|
+
|
|
16
|
+
Nothing here is written per operation, and nothing is generated: the methods are the operations
|
|
17
|
+
of the OpenAPI document shipped inside this package (``renderflow/openapi.json``), bound to this
|
|
18
|
+
farm on first use — an operation added to the document arrives the moment the copy is refreshed,
|
|
19
|
+
and one that is removed stops existing rather than answering 404. Bodies are plain dicts;
|
|
20
|
+
answers read with dot access (``job.name``) and hand back the raw payload via ``to_dict()``.
|
|
21
|
+
The typed view of it all is ``models.pyi``, which the editor reads and the interpreter never
|
|
22
|
+
pays for — the whole client is standard library, so it runs inside a DCC's own Python.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import os
|
|
28
|
+
import time
|
|
29
|
+
from typing import Any, Callable
|
|
30
|
+
|
|
31
|
+
from . import wire
|
|
32
|
+
from .live import LiveConnection, LiveEvent
|
|
33
|
+
from .wire import ApiError, AuthenticatedClient, Client
|
|
34
|
+
|
|
35
|
+
DEFAULT_URL = "http://127.0.0.1:44442"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Group:
|
|
39
|
+
"""One tag's operations, bound to a farm — ``farm.jobs``, ``farm.nodes``."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, farm: "Farm", tag: str) -> None:
|
|
42
|
+
self._farm = farm
|
|
43
|
+
self._tag = tag
|
|
44
|
+
self._operations = wire.operations()[tag]
|
|
45
|
+
|
|
46
|
+
def __dir__(self) -> list[str]:
|
|
47
|
+
return sorted(self._operations)
|
|
48
|
+
|
|
49
|
+
def __getattr__(self, name: str) -> Callable[..., Any]:
|
|
50
|
+
try:
|
|
51
|
+
operation = self._operations[name]
|
|
52
|
+
except KeyError:
|
|
53
|
+
raise AttributeError(
|
|
54
|
+
f"`{self._tag}` has no operation `{name}`. Available: {', '.join(self.__dir__())}"
|
|
55
|
+
) from None
|
|
56
|
+
|
|
57
|
+
client = self._farm._client
|
|
58
|
+
|
|
59
|
+
def call(*args: Any, **kwargs: Any) -> Any:
|
|
60
|
+
return client.call(operation, args, kwargs)
|
|
61
|
+
|
|
62
|
+
call.__name__ = name
|
|
63
|
+
call.__doc__ = operation.doc
|
|
64
|
+
setattr(self, name, call) # resolved once; afterwards it is a plain attribute
|
|
65
|
+
return call
|
|
66
|
+
|
|
67
|
+
def __repr__(self) -> str:
|
|
68
|
+
return f"<renderflow group {self._tag}: {len(self._operations)} operations>"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
#: Group name to the socket entity it watches. A group absent from this watches nothing: the
|
|
72
|
+
#: archive, `info`, `service` and the webhook subscriptions have no live collection behind them.
|
|
73
|
+
LIVE_ENTITIES = {
|
|
74
|
+
"jobs": "jobs",
|
|
75
|
+
"tasks": "tasks",
|
|
76
|
+
"nodes": "nodes",
|
|
77
|
+
"pools": "pools",
|
|
78
|
+
"users": "users",
|
|
79
|
+
"user_groups": "user-groups",
|
|
80
|
+
"errors": "errors",
|
|
81
|
+
"schedule": "schedule",
|
|
82
|
+
"licenses": "render-licenses"
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class LiveGroup(Group):
|
|
87
|
+
"""A group whose collection the farm also pushes, so it can be watched as well as read."""
|
|
88
|
+
|
|
89
|
+
def __dir__(self) -> list[str]:
|
|
90
|
+
# `dir(farm.jobs)` is how this client is explored, so anything it answers has to be in it.
|
|
91
|
+
return sorted({*super().__dir__(), "on"})
|
|
92
|
+
|
|
93
|
+
def on(self, handler: Callable[[LiveEvent], None], filter: dict[str, Any] | None = None) -> Callable[[], None]:
|
|
94
|
+
"""Watch this collection. Answers the function that stops watching it.
|
|
95
|
+
|
|
96
|
+
One socket carries every subscription, opened on the first and closed with the last::
|
|
97
|
+
|
|
98
|
+
stop = farm.jobs.on(lambda event: print(event.op, event.id), {"id": job.id})
|
|
99
|
+
stop()
|
|
100
|
+
|
|
101
|
+
``filter`` narrows it: ``id`` on any entity, ``step_id`` on tasks, ``node_id`` on the node
|
|
102
|
+
joins. A list is one subscription over several ids rather than several subscriptions.
|
|
103
|
+
|
|
104
|
+
**A ``resync`` event is not a change.** It means events were missed and this collection has
|
|
105
|
+
to be read again, because the socket has no replay. It arrives through this same handler
|
|
106
|
+
precisely so there is nothing to forget to pass.
|
|
107
|
+
"""
|
|
108
|
+
return self._farm.live.subscribe(LIVE_ENTITIES[self._tag], handler, filter)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
#: What counts as the end of a job. A stop is resumable, so `suspended` is not one of them.
|
|
112
|
+
SETTLED = ("completed", "failed")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class JobsGroup(LiveGroup):
|
|
116
|
+
"""`jobs`, plus the one wait that is not an operation of the document."""
|
|
117
|
+
|
|
118
|
+
def __dir__(self) -> list[str]:
|
|
119
|
+
# `dir(farm.jobs)` is how this client is discovered, so anything it answers has to be in it.
|
|
120
|
+
return sorted({*super().__dir__(), "settled"})
|
|
121
|
+
|
|
122
|
+
def settled(self, job_id, *, statuses=None, timeout=None, interval=2.0):
|
|
123
|
+
"""Waits for a job to finish. Answers the job, or ``None`` when it is no longer there.
|
|
124
|
+
|
|
125
|
+
Every integration writes this, and writes it wrong: the naive loop stops on ``completed``
|
|
126
|
+
only, so a failed render waits for ever, and it treats a job that has been removed as one
|
|
127
|
+
still to come.
|
|
128
|
+
|
|
129
|
+
Archiving and deleting are the same thing from out here, so either resolves ``None`` --
|
|
130
|
+
whichever happened, this job is not going to finish.
|
|
131
|
+
|
|
132
|
+
This polls rather than watching, so a wait needs no socket, no thread and no reconnect
|
|
133
|
+
behaviour, and it works where a proxy passes HTTP and refuses an upgrade. The interval is
|
|
134
|
+
the cost: a render measured in hours wants ``interval=30``, not two seconds. Reach for
|
|
135
|
+
``farm.jobs.on(...)`` when the moment a job finishes is what matters.
|
|
136
|
+
|
|
137
|
+
``timeout`` is seconds, and there is none by default -- a long render legitimately takes
|
|
138
|
+
days. Raising ``TimeoutError`` rather than answering is deliberate: a caller that waited in
|
|
139
|
+
vain has not been given a job, and must not be handed something that reads like one.
|
|
140
|
+
"""
|
|
141
|
+
ends = tuple(statuses) if statuses else SETTLED
|
|
142
|
+
deadline = None if timeout is None else time.monotonic() + timeout
|
|
143
|
+
|
|
144
|
+
while True:
|
|
145
|
+
try:
|
|
146
|
+
job = self.get_job(job_id)
|
|
147
|
+
except ApiError as refused:
|
|
148
|
+
if refused.status == 404:
|
|
149
|
+
return None
|
|
150
|
+
raise
|
|
151
|
+
|
|
152
|
+
if getattr(job, "status", None) in ends:
|
|
153
|
+
return job
|
|
154
|
+
|
|
155
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
156
|
+
raise TimeoutError(f"{job_id} had not finished after {timeout}s")
|
|
157
|
+
|
|
158
|
+
# Never overshoot the deadline just to complete a sleep.
|
|
159
|
+
nap = interval if deadline is None else min(interval, max(0.0, deadline - time.monotonic()))
|
|
160
|
+
time.sleep(nap)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
#: The groups that carry more than the document's operations. Every other group whose collection
|
|
164
|
+
#: the farm pushes gets `on` through `LiveGroup`.
|
|
165
|
+
GROUPS = {"jobs": JobsGroup}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _group_class(tag: str) -> type:
|
|
169
|
+
if tag in GROUPS:
|
|
170
|
+
return GROUPS[tag]
|
|
171
|
+
return LiveGroup if tag in LIVE_ENTITIES else Group
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class Farm:
|
|
175
|
+
"""A connection to one RenderFlow installation. Prefer ``renderflow.connect()``."""
|
|
176
|
+
|
|
177
|
+
def __init__(self, url: str | None = None, api_key: str | None = None, *, timeout: float = 60.0) -> None:
|
|
178
|
+
self.url = (url or os.environ.get("RENDERFLOW_URL") or DEFAULT_URL).rstrip("/")
|
|
179
|
+
key = api_key if api_key is not None else os.environ.get("RENDERFLOW_API_KEY")
|
|
180
|
+
|
|
181
|
+
base_url = self.url if self.url.endswith("/api/v1") else f"{self.url}/api/v1"
|
|
182
|
+
|
|
183
|
+
# No key is a real configuration, not a missing one: `POST /jobs` and the loopback
|
|
184
|
+
# routes are reachable from the same machine without a credential, which is how a DCC
|
|
185
|
+
# plugin on a workstation submits.
|
|
186
|
+
if key:
|
|
187
|
+
self._client: Client | AuthenticatedClient = AuthenticatedClient(base_url=base_url, token=key, timeout=timeout)
|
|
188
|
+
else:
|
|
189
|
+
self._client = Client(base_url=base_url, timeout=timeout)
|
|
190
|
+
|
|
191
|
+
self._groups: dict[str, Group] = {}
|
|
192
|
+
self._key = key or ""
|
|
193
|
+
self._live: LiveConnection | None = None
|
|
194
|
+
|
|
195
|
+
def __dir__(self) -> list[str]:
|
|
196
|
+
return sorted({*super().__dir__(), *self.tags()})
|
|
197
|
+
|
|
198
|
+
def tags(self) -> list[str]:
|
|
199
|
+
return sorted(wire.operations())
|
|
200
|
+
|
|
201
|
+
def __getattr__(self, name: str) -> Group:
|
|
202
|
+
if name.startswith("_"):
|
|
203
|
+
raise AttributeError(name)
|
|
204
|
+
|
|
205
|
+
if name not in self._groups:
|
|
206
|
+
if name not in self.tags():
|
|
207
|
+
raise AttributeError(f"no such API group `{name}`. Available: {', '.join(self.tags())}")
|
|
208
|
+
self._groups[name] = _group_class(name)(self, name)
|
|
209
|
+
|
|
210
|
+
return self._groups[name]
|
|
211
|
+
|
|
212
|
+
@property
|
|
213
|
+
def live(self) -> LiveConnection:
|
|
214
|
+
"""The one socket every ``on`` shares. Constructing it opens nothing."""
|
|
215
|
+
if self._live is None:
|
|
216
|
+
self._live = LiveConnection(self.url, self._key)
|
|
217
|
+
|
|
218
|
+
return self._live
|
|
219
|
+
|
|
220
|
+
def close(self) -> None:
|
|
221
|
+
if self._live is not None:
|
|
222
|
+
self._live.close()
|
|
223
|
+
|
|
224
|
+
self._client.close()
|
|
225
|
+
|
|
226
|
+
def __enter__(self) -> "Farm":
|
|
227
|
+
return self
|
|
228
|
+
|
|
229
|
+
def __exit__(self, *exc: Any) -> None:
|
|
230
|
+
self.close()
|
|
231
|
+
|
|
232
|
+
def __repr__(self) -> str:
|
|
233
|
+
return f"<renderflow.Farm {self.url}>"
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def connect(url: str | None = None, api_key: str | None = None, *, timeout: float = 60.0) -> Farm:
|
|
237
|
+
"""Connect to a farm.
|
|
238
|
+
|
|
239
|
+
Each argument falls back to the environment and then to the local service:
|
|
240
|
+
``url`` to ``RENDERFLOW_URL`` then ``http://127.0.0.1:44442``, ``api_key`` to
|
|
241
|
+
``RENDERFLOW_API_KEY`` then to no credential at all.
|
|
242
|
+
"""
|
|
243
|
+
return Farm(url, api_key, timeout=timeout)
|