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.
Files changed (24) hide show
  1. {pulze_renderflow-2.0.7 → pulze_renderflow-2.0.9}/CLAUDE.md +19 -1
  2. {pulze_renderflow-2.0.7 → pulze_renderflow-2.0.9}/PKG-INFO +66 -6
  3. {pulze_renderflow-2.0.7 → pulze_renderflow-2.0.9}/README.md +65 -5
  4. {pulze_renderflow-2.0.7 → pulze_renderflow-2.0.9}/pyproject.toml +1 -1
  5. pulze_renderflow-2.0.9/renderflow/farm.py +243 -0
  6. pulze_renderflow-2.0.9/renderflow/live.py +533 -0
  7. {pulze_renderflow-2.0.7 → pulze_renderflow-2.0.9}/renderflow/models.pyi +3134 -2722
  8. {pulze_renderflow-2.0.7 → pulze_renderflow-2.0.9}/renderflow/openapi.json +3802 -2771
  9. {pulze_renderflow-2.0.7 → pulze_renderflow-2.0.9}/renderflow/wire.py +15 -3
  10. pulze_renderflow-2.0.9/scripts/test.mjs +48 -0
  11. {pulze_renderflow-2.0.7 → pulze_renderflow-2.0.9}/test/test_client.py +242 -136
  12. pulze_renderflow-2.0.9/test/test_live.py +404 -0
  13. {pulze_renderflow-2.0.7 → pulze_renderflow-2.0.9}/test/test_runtime.py +184 -0
  14. pulze_renderflow-2.0.7/renderflow/farm.py +0 -125
  15. {pulze_renderflow-2.0.7 → pulze_renderflow-2.0.9}/.gitignore +0 -0
  16. {pulze_renderflow-2.0.7 → pulze_renderflow-2.0.9}/LICENSE +0 -0
  17. {pulze_renderflow-2.0.7 → pulze_renderflow-2.0.9}/MANIFEST.in +0 -0
  18. {pulze_renderflow-2.0.7 → pulze_renderflow-2.0.9}/renderflow/__init__.py +0 -0
  19. {pulze_renderflow-2.0.7 → pulze_renderflow-2.0.9}/renderflow/__init__.pyi +0 -0
  20. {pulze_renderflow-2.0.7 → pulze_renderflow-2.0.9}/renderflow/models.py +0 -0
  21. {pulze_renderflow-2.0.7 → pulze_renderflow-2.0.9}/renderflow/py.typed +0 -0
  22. {pulze_renderflow-2.0.7 → pulze_renderflow-2.0.9}/renderflow/runtime.py +0 -0
  23. {pulze_renderflow-2.0.7 → pulze_renderflow-2.0.9}/scripts/build.py +0 -0
  24. {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.7
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:44442", api_key="rf_...")
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": {"name": "maya"}}
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:44442", api_key="rf_...")
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": {"name": "maya"}}
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
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "pulze-renderflow"
7
- version = "2.0.7"
7
+ version = "2.0.9"
8
8
  description = "Official RenderFlow API client for Python"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -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)