dazpy 2.7.2__tar.gz → 2.8.0__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 (50) hide show
  1. {dazpy-2.7.2/dazpy.egg-info → dazpy-2.8.0}/PKG-INFO +48 -3
  2. dazpy-2.7.2/PKG-INFO → dazpy-2.8.0/README.md +47 -17
  3. {dazpy-2.7.2 → dazpy-2.8.0}/dazpy/__init__.py +1 -1
  4. {dazpy-2.7.2 → dazpy-2.8.0}/dazpy/_client.py +165 -25
  5. {dazpy-2.7.2 → dazpy-2.8.0}/dazpy/exceptions.py +24 -0
  6. dazpy-2.7.2/README.md → dazpy-2.8.0/dazpy.egg-info/PKG-INFO +62 -2
  7. {dazpy-2.7.2 → dazpy-2.8.0}/pyproject.toml +1 -1
  8. {dazpy-2.7.2 → dazpy-2.8.0}/tests/test_dazpy.py +103 -0
  9. {dazpy-2.7.2 → dazpy-2.8.0}/dazpy/_animation.py +0 -0
  10. {dazpy-2.7.2 → dazpy-2.8.0}/dazpy/_batch.py +0 -0
  11. {dazpy-2.7.2 → dazpy-2.8.0}/dazpy/_bone.py +0 -0
  12. {dazpy-2.7.2 → dazpy-2.8.0}/dazpy/_camera.py +0 -0
  13. {dazpy-2.7.2 → dazpy-2.8.0}/dazpy/_dforce.py +0 -0
  14. {dazpy-2.7.2 → dazpy-2.8.0}/dazpy/_element.py +0 -0
  15. {dazpy-2.7.2 → dazpy-2.8.0}/dazpy/_geometry.py +0 -0
  16. {dazpy-2.7.2 → dazpy-2.8.0}/dazpy/_interaction.py +0 -0
  17. {dazpy-2.7.2 → dazpy-2.8.0}/dazpy/_light.py +0 -0
  18. {dazpy-2.7.2 → dazpy-2.8.0}/dazpy/_material.py +0 -0
  19. {dazpy-2.7.2 → dazpy-2.8.0}/dazpy/_modifier.py +0 -0
  20. {dazpy-2.7.2 → dazpy-2.8.0}/dazpy/_morph.py +0 -0
  21. {dazpy-2.7.2 → dazpy-2.8.0}/dazpy/_node.py +0 -0
  22. {dazpy-2.7.2 → dazpy-2.8.0}/dazpy/_polling.py +0 -0
  23. {dazpy-2.7.2 → dazpy-2.8.0}/dazpy/_pose.py +0 -0
  24. {dazpy-2.7.2 → dazpy-2.8.0}/dazpy/_property.py +0 -0
  25. {dazpy-2.7.2 → dazpy-2.8.0}/dazpy/_render.py +0 -0
  26. {dazpy-2.7.2 → dazpy-2.8.0}/dazpy/_render_api.py +0 -0
  27. {dazpy-2.7.2 → dazpy-2.8.0}/dazpy/_result.py +0 -0
  28. {dazpy-2.7.2 → dazpy-2.8.0}/dazpy/_scene.py +0 -0
  29. {dazpy-2.7.2 → dazpy-2.8.0}/dazpy/_scene_events.py +0 -0
  30. {dazpy-2.7.2 → dazpy-2.8.0}/dazpy/_scene_state.py +0 -0
  31. {dazpy-2.7.2 → dazpy-2.8.0}/dazpy/_script_builder.py +0 -0
  32. {dazpy-2.7.2 → dazpy-2.8.0}/dazpy/_skeleton.py +0 -0
  33. {dazpy-2.7.2 → dazpy-2.8.0}/dazpy/_timeline.py +0 -0
  34. {dazpy-2.7.2 → dazpy-2.8.0}/dazpy/_undo.py +0 -0
  35. {dazpy-2.7.2 → dazpy-2.8.0}/dazpy/_viewport.py +0 -0
  36. {dazpy-2.7.2 → dazpy-2.8.0}/dazpy/math3.py +0 -0
  37. {dazpy-2.7.2 → dazpy-2.8.0}/dazpy/py.typed +0 -0
  38. {dazpy-2.7.2 → dazpy-2.8.0}/dazpy.egg-info/SOURCES.txt +0 -0
  39. {dazpy-2.7.2 → dazpy-2.8.0}/dazpy.egg-info/dependency_links.txt +0 -0
  40. {dazpy-2.7.2 → dazpy-2.8.0}/dazpy.egg-info/requires.txt +0 -0
  41. {dazpy-2.7.2 → dazpy-2.8.0}/dazpy.egg-info/top_level.txt +0 -0
  42. {dazpy-2.7.2 → dazpy-2.8.0}/setup.cfg +0 -0
  43. {dazpy-2.7.2 → dazpy-2.8.0}/tests/test_api.py +0 -0
  44. {dazpy-2.7.2 → dazpy-2.8.0}/tests/test_comfyui_client.py +0 -0
  45. {dazpy-2.7.2 → dazpy-2.8.0}/tests/test_comfyui_enhance_integration.py +0 -0
  46. {dazpy-2.7.2 → dazpy-2.8.0}/tests/test_dazpy_integration.py +0 -0
  47. {dazpy-2.7.2 → dazpy-2.8.0}/tests/test_dsp_runner.py +0 -0
  48. {dazpy-2.7.2 → dazpy-2.8.0}/tests/test_file_watcher.py +0 -0
  49. {dazpy-2.7.2 → dazpy-2.8.0}/tests/test_performance.py +0 -0
  50. {dazpy-2.7.2 → dazpy-2.8.0}/tests/test_workflow_builder.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dazpy
3
- Version: 2.7.2
3
+ Version: 2.8.0
4
4
  Summary: Python SDK for the DAZ Studio Script Server
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://github.com/bluemoonfoundry/daz-script-server
@@ -15,7 +15,7 @@ Requires-Dist: sphinx-rtd-theme>=2.0; extra == "docs"
15
15
 
16
16
  # DazScript Server
17
17
 
18
- **Version 2.7.2** | DAZ Studio 4.5+ | DAZ Studio 6.25+ | Windows & macOS
18
+ **Version 2.8.0** | DAZ Studio 4.5+ | DAZ Studio 6.25+ | Windows & macOS
19
19
 
20
20
  [![Docs](https://img.shields.io/badge/docs-dazpy%20SDK-blue)](https://bluemoonfoundry.github.io/daz-script-server/)
21
21
  [![HTTP API](https://img.shields.io/badge/docs-HTTP%20API%20reference-blue)](https://bluemoonfoundry.github.io/daz-script-server/api-reference/)
@@ -83,6 +83,7 @@ print(response.json())
83
83
  ### Getting Started
84
84
  - [Quick Start](#-quick-start)
85
85
  - [Why This Exists](#why-this-exists)
86
+ - [What's New in v2.8.0](#whats-new-in-v280)
86
87
  - [What's New in v2.7.2](#whats-new-in-v272)
87
88
  - [What's New in v2.7.1](#whats-new-in-v271)
88
89
  - [What's New in v2.7.0](#whats-new-in-v270)
@@ -169,6 +170,50 @@ DAZ Studio is powerful for 3D content creation, but automation is limited to man
169
170
 
170
171
  ---
171
172
 
173
+ ## What's New in v2.8.0
174
+
175
+ ### 🚦 Fail Fast When DAZ Studio Is Busy — `503 STUDIO_BUSY`
176
+
177
+ DAZ Studio's Qt main thread is single-threaded: if a user loads a scene,
178
+ saves, clears the scene, or renders through the DAZ Studio UI while the
179
+ server is also being driven remotely, requests that need the main thread
180
+ used to block indefinitely with no signal to the caller. `/execute`,
181
+ `/scripts/:id/execute`, the async-enqueue endpoints, and the render-submit
182
+ endpoints now check DAZ Studio's busy state first and return `503` with
183
+ `error_code: "STUDIO_BUSY"` (plus a `Retry-After` header) immediately
184
+ instead of hanging.
185
+
186
+ `dazpy` surfaces this as a new exception hierarchy — `DazBusyError`, with
187
+ `StudioBusyError` (503) and `ConcurrencyLimitError` (429) subclasses, each
188
+ carrying `reason` and `retry_after`. This also fixes a pre-existing bug
189
+ where `429 CONCURRENT_LIMIT_EXCEEDED` responses were misreported as
190
+ `ScriptRuntimeError`, as if the script itself had failed.
191
+
192
+ ```python
193
+ from dazpy import DazClient
194
+ from dazpy.exceptions import DazBusyError
195
+
196
+ client = DazClient()
197
+ try:
198
+ result = client.execute("1+1;")
199
+ except DazBusyError as e:
200
+ print(f"DAZ Studio is busy: {e.reason} — retry after {e.retry_after}s")
201
+ ```
202
+
203
+ Opt in to automatic retry with backoff instead of handling the exception
204
+ yourself:
205
+
206
+ ```python
207
+ result = client.execute("1+1;", retry_on_busy=True, max_wait=30.0)
208
+ ```
209
+
210
+ `retry_on_busy`/`max_wait` are available on `execute`, `execute_file`,
211
+ `execute_async_submit`, `render_submit`, `render_batch_submit`, and
212
+ `render_animation_submit`. Retry is opt-in and off by default — callers
213
+ (and any LLM driving them) stay informed rather than silently blocked.
214
+
215
+ ---
216
+
172
217
  ## What's New in v2.7.2
173
218
 
174
219
  No functional changes to `dazpy`'s behavior. `2.7.1`'s version bump missed
@@ -667,7 +712,7 @@ automation code without authoring DazScript by hand.
667
712
  Download the `.whl` file from the [latest release](https://github.com/bluemoonfoundry/daz-script-server/releases/latest) and install it:
668
713
 
669
714
  ```bash
670
- pip install dazpy-2.7.2-py3-none-any.whl
715
+ pip install dazpy-2.8.0-py3-none-any.whl
671
716
  ```
672
717
 
673
718
  Or install directly from the repo for development:
@@ -1,21 +1,6 @@
1
- Metadata-Version: 2.4
2
- Name: dazpy
3
- Version: 2.7.2
4
- Summary: Python SDK for the DAZ Studio Script Server
5
- License: MIT
6
- Project-URL: Homepage, https://github.com/bluemoonfoundry/daz-script-server
7
- Project-URL: Repository, https://github.com/bluemoonfoundry/daz-script-server
8
- Project-URL: Bug Tracker, https://github.com/bluemoonfoundry/daz-script-server/issues
9
- Requires-Python: >=3.10
10
- Description-Content-Type: text/markdown
11
- Requires-Dist: requests>=2.28
12
- Provides-Extra: docs
13
- Requires-Dist: sphinx>=7.0; extra == "docs"
14
- Requires-Dist: sphinx-rtd-theme>=2.0; extra == "docs"
15
-
16
1
  # DazScript Server
17
2
 
18
- **Version 2.7.2** | DAZ Studio 4.5+ | DAZ Studio 6.25+ | Windows & macOS
3
+ **Version 2.8.0** | DAZ Studio 4.5+ | DAZ Studio 6.25+ | Windows & macOS
19
4
 
20
5
  [![Docs](https://img.shields.io/badge/docs-dazpy%20SDK-blue)](https://bluemoonfoundry.github.io/daz-script-server/)
21
6
  [![HTTP API](https://img.shields.io/badge/docs-HTTP%20API%20reference-blue)](https://bluemoonfoundry.github.io/daz-script-server/api-reference/)
@@ -83,6 +68,7 @@ print(response.json())
83
68
  ### Getting Started
84
69
  - [Quick Start](#-quick-start)
85
70
  - [Why This Exists](#why-this-exists)
71
+ - [What's New in v2.8.0](#whats-new-in-v280)
86
72
  - [What's New in v2.7.2](#whats-new-in-v272)
87
73
  - [What's New in v2.7.1](#whats-new-in-v271)
88
74
  - [What's New in v2.7.0](#whats-new-in-v270)
@@ -169,6 +155,50 @@ DAZ Studio is powerful for 3D content creation, but automation is limited to man
169
155
 
170
156
  ---
171
157
 
158
+ ## What's New in v2.8.0
159
+
160
+ ### 🚦 Fail Fast When DAZ Studio Is Busy — `503 STUDIO_BUSY`
161
+
162
+ DAZ Studio's Qt main thread is single-threaded: if a user loads a scene,
163
+ saves, clears the scene, or renders through the DAZ Studio UI while the
164
+ server is also being driven remotely, requests that need the main thread
165
+ used to block indefinitely with no signal to the caller. `/execute`,
166
+ `/scripts/:id/execute`, the async-enqueue endpoints, and the render-submit
167
+ endpoints now check DAZ Studio's busy state first and return `503` with
168
+ `error_code: "STUDIO_BUSY"` (plus a `Retry-After` header) immediately
169
+ instead of hanging.
170
+
171
+ `dazpy` surfaces this as a new exception hierarchy — `DazBusyError`, with
172
+ `StudioBusyError` (503) and `ConcurrencyLimitError` (429) subclasses, each
173
+ carrying `reason` and `retry_after`. This also fixes a pre-existing bug
174
+ where `429 CONCURRENT_LIMIT_EXCEEDED` responses were misreported as
175
+ `ScriptRuntimeError`, as if the script itself had failed.
176
+
177
+ ```python
178
+ from dazpy import DazClient
179
+ from dazpy.exceptions import DazBusyError
180
+
181
+ client = DazClient()
182
+ try:
183
+ result = client.execute("1+1;")
184
+ except DazBusyError as e:
185
+ print(f"DAZ Studio is busy: {e.reason} — retry after {e.retry_after}s")
186
+ ```
187
+
188
+ Opt in to automatic retry with backoff instead of handling the exception
189
+ yourself:
190
+
191
+ ```python
192
+ result = client.execute("1+1;", retry_on_busy=True, max_wait=30.0)
193
+ ```
194
+
195
+ `retry_on_busy`/`max_wait` are available on `execute`, `execute_file`,
196
+ `execute_async_submit`, `render_submit`, `render_batch_submit`, and
197
+ `render_animation_submit`. Retry is opt-in and off by default — callers
198
+ (and any LLM driving them) stay informed rather than silently blocked.
199
+
200
+ ---
201
+
172
202
  ## What's New in v2.7.2
173
203
 
174
204
  No functional changes to `dazpy`'s behavior. `2.7.1`'s version bump missed
@@ -667,7 +697,7 @@ automation code without authoring DazScript by hand.
667
697
  Download the `.whl` file from the [latest release](https://github.com/bluemoonfoundry/daz-script-server/releases/latest) and install it:
668
698
 
669
699
  ```bash
670
- pip install dazpy-2.7.2-py3-none-any.whl
700
+ pip install dazpy-2.8.0-py3-none-any.whl
671
701
  ```
672
702
 
673
703
  Or install directly from the repo for development:
@@ -13,7 +13,7 @@ Typical usage::
13
13
  figure.find_bone("r_forearm").set_local_rotation(0, 0, 45)
14
14
  """
15
15
 
16
- __version__ = "2.7.2"
16
+ __version__ = "2.8.0"
17
17
 
18
18
  from ._client import DazClient
19
19
  from ._scene import DazScene
@@ -2,14 +2,18 @@ from __future__ import annotations
2
2
 
3
3
  import os
4
4
  import re
5
+ import time
5
6
 
6
7
  import requests as _requests
7
8
 
8
9
  from .exceptions import (
9
10
  AuthenticationError,
11
+ ConcurrencyLimitError,
10
12
  ConnectionError,
13
+ DazBusyError,
11
14
  ScriptRuntimeError,
12
15
  ScriptSyntaxError,
16
+ StudioBusyError,
13
17
  TimeoutError,
14
18
  )
15
19
  from ._result import ExecutionResult
@@ -27,10 +31,37 @@ def _load_token() -> str:
27
31
  return ""
28
32
 
29
33
 
30
- def _map_response(resp: _requests.Response, script: str = "") -> ExecutionResult:
34
+ def _parse_retry_after(resp: _requests.Response) -> float:
35
+ raw = resp.headers.get("Retry-After", "")
36
+ try:
37
+ return float(raw)
38
+ except (TypeError, ValueError):
39
+ return 2.0
40
+
41
+
42
+ def _raise_for_error(resp: _requests.Response) -> None:
43
+ """Raise a typed exception for authentication or server-busy responses.
44
+
45
+ Leaves 2xx responses (and DazScript's own success:false runtime/syntax
46
+ errors, which use HTTP 200) for the caller to handle.
47
+ """
31
48
  status = resp.status_code
32
49
  if status == 401 or status == 403:
33
50
  raise AuthenticationError(f"HTTP {status}: {resp.text[:200]}")
51
+ if status < 400:
52
+ return
53
+ data = resp.json()
54
+ error_code = data.get("error_code", "")
55
+ error_msg = data.get("error") or f"HTTP {status}"
56
+ retry_after = _parse_retry_after(resp)
57
+ if error_code == "STUDIO_BUSY":
58
+ raise StudioBusyError(error_msg, reason=data.get("detail", error_msg), retry_after=retry_after)
59
+ if error_code == "CONCURRENT_LIMIT_EXCEEDED":
60
+ raise ConcurrencyLimitError(error_msg, reason=error_msg, retry_after=retry_after)
61
+
62
+
63
+ def _map_response(resp: _requests.Response, script: str = "") -> ExecutionResult:
64
+ _raise_for_error(resp)
34
65
 
35
66
  data = resp.json()
36
67
  request_id = data.get("request_id", "")
@@ -118,13 +149,37 @@ class DazClient:
118
149
  except _requests.exceptions.Timeout as e:
119
150
  raise TimeoutError(f"Request timed out after {self._timeout}s") from e
120
151
 
121
- def execute(self, script: str, args: object = None) -> ExecutionResult:
152
+ def _with_busy_retry(self, fn, retry_on_busy: bool, max_wait: float):
153
+ """Run *fn* (a zero-arg callable), retrying on DazBusyError while
154
+ *retry_on_busy* is true, up to *max_wait* seconds total."""
155
+ if not retry_on_busy:
156
+ return fn()
157
+ deadline = time.monotonic() + max_wait
158
+ backoff = 1.0
159
+ while True:
160
+ try:
161
+ return fn()
162
+ except DazBusyError:
163
+ remaining = deadline - time.monotonic()
164
+ if remaining <= 0:
165
+ raise
166
+ time.sleep(min(backoff, remaining))
167
+ backoff = min(backoff + 1.0, 5.0)
168
+
169
+ def execute(
170
+ self, script: str, args: object = None, *, retry_on_busy: bool = False, max_wait: float = 30.0
171
+ ) -> ExecutionResult:
122
172
  """Execute a DazScript string synchronously.
123
173
 
124
174
  Args:
125
175
  script: DazScript source code to execute.
126
176
  args: Optional value passed into the script as ``getArguments()[0]``.
127
177
  Must be JSON-serialisable.
178
+ retry_on_busy: If ``True``, transparently retry with backoff when
179
+ the server reports ``StudioBusyError``/``ConcurrencyLimitError``,
180
+ instead of raising immediately.
181
+ max_wait: Maximum total seconds to retry when *retry_on_busy* is
182
+ ``True``, before re-raising the busy error.
128
183
 
129
184
  Returns:
130
185
  The execution result containing the script return value and any
@@ -135,20 +190,36 @@ class DazClient:
135
190
  AuthenticationError: If the token is invalid or the IP is blocked.
136
191
  ScriptSyntaxError: If the script contains a parse error.
137
192
  ScriptRuntimeError: If the script raises a runtime exception.
193
+ StudioBusyError: If DAZ Studio's main thread is busy and
194
+ *retry_on_busy* is ``False`` or *max_wait* is exceeded.
195
+ ConcurrencyLimitError: If too many concurrent requests are in
196
+ flight and *retry_on_busy* is ``False`` or *max_wait* is
197
+ exceeded.
138
198
  TimeoutError: If the request exceeds *timeout* seconds.
139
199
  """
140
200
  payload: dict = {"script": script}
141
201
  if args is not None:
142
202
  payload["args"] = args
143
- resp = self._post("/execute", payload)
144
- return _map_response(resp, script=script)
145
203
 
146
- def execute_file(self, script_file: str, args: object = None) -> ExecutionResult:
204
+ def _do():
205
+ resp = self._post("/execute", payload)
206
+ return _map_response(resp, script=script)
207
+
208
+ return self._with_busy_retry(_do, retry_on_busy, max_wait)
209
+
210
+ def execute_file(
211
+ self, script_file: str, args: object = None, *, retry_on_busy: bool = False, max_wait: float = 30.0
212
+ ) -> ExecutionResult:
147
213
  """Execute a ``.dsa`` script file that resides on the DAZ Studio host.
148
214
 
149
215
  Args:
150
216
  script_file: Absolute path to the ``.dsa`` file on the server host.
151
217
  args: Optional argument passed to the script.
218
+ retry_on_busy: If ``True``, transparently retry with backoff when
219
+ the server reports ``StudioBusyError``/``ConcurrencyLimitError``,
220
+ instead of raising immediately.
221
+ max_wait: Maximum total seconds to retry when *retry_on_busy* is
222
+ ``True``, before re-raising the busy error.
152
223
 
153
224
  Returns:
154
225
  The execution result.
@@ -158,20 +229,36 @@ class DazClient:
158
229
  AuthenticationError: On auth failure.
159
230
  ScriptSyntaxError: On parse error.
160
231
  ScriptRuntimeError: On runtime error.
232
+ StudioBusyError: If DAZ Studio's main thread is busy and
233
+ *retry_on_busy* is ``False`` or *max_wait* is exceeded.
234
+ ConcurrencyLimitError: If too many concurrent requests are in
235
+ flight and *retry_on_busy* is ``False`` or *max_wait* is
236
+ exceeded.
161
237
  TimeoutError: On HTTP timeout.
162
238
  """
163
239
  payload: dict = {"scriptFile": script_file}
164
240
  if args is not None:
165
241
  payload["args"] = args
166
- resp = self._post("/execute", payload)
167
- return _map_response(resp)
168
242
 
169
- def execute_async_submit(self, script: str, args: object = None) -> str:
243
+ def _do():
244
+ resp = self._post("/execute", payload)
245
+ return _map_response(resp)
246
+
247
+ return self._with_busy_retry(_do, retry_on_busy, max_wait)
248
+
249
+ def execute_async_submit(
250
+ self, script: str, args: object = None, *, retry_on_busy: bool = False, max_wait: float = 30.0
251
+ ) -> str:
170
252
  """Submit a script for asynchronous execution and return immediately.
171
253
 
172
254
  Args:
173
255
  script: DazScript source code.
174
256
  args: Optional argument for the script.
257
+ retry_on_busy: If ``True``, transparently retry with backoff when
258
+ the server reports ``StudioBusyError``/``ConcurrencyLimitError``,
259
+ instead of raising immediately.
260
+ max_wait: Maximum total seconds to retry when *retry_on_busy* is
261
+ ``True``, before re-raising the busy error.
175
262
 
176
263
  Returns:
177
264
  The server-assigned ``request_id`` string. Use it with
@@ -181,14 +268,22 @@ class DazClient:
181
268
  Raises:
182
269
  ConnectionError: If the server cannot be reached.
183
270
  AuthenticationError: On auth failure.
271
+ StudioBusyError: If DAZ Studio's main thread is busy and
272
+ *retry_on_busy* is ``False`` or *max_wait* is exceeded.
273
+ ConcurrencyLimitError: If too many concurrent requests are in
274
+ flight and *retry_on_busy* is ``False`` or *max_wait* is
275
+ exceeded.
184
276
  """
185
277
  payload: dict = {"script": script}
186
278
  if args is not None:
187
279
  payload["args"] = args
188
- resp = self._post("/execute/async", payload)
189
- if resp.status_code in (401, 403):
190
- raise AuthenticationError(f"HTTP {resp.status_code}")
191
- return resp.json().get("request_id", "")
280
+
281
+ def _do():
282
+ resp = self._post("/execute/async", payload)
283
+ _raise_for_error(resp)
284
+ return resp.json().get("request_id", "")
285
+
286
+ return self._with_busy_retry(_do, retry_on_busy, max_wait)
192
287
 
193
288
  def get_request_status(self, request_id: str) -> dict:
194
289
  """Return the current status of an async request.
@@ -282,6 +377,8 @@ class DazClient:
282
377
  engine: str = "",
283
378
  iray_samples: int = 0,
284
379
  reset_morphs: bool = False,
380
+ retry_on_busy: bool = False,
381
+ max_wait: float = 30.0,
285
382
  ) -> dict:
286
383
  """Submit a render job and return immediately.
287
384
 
@@ -296,6 +393,11 @@ class DazClient:
296
393
  engine: Render engine (``"iray"``, ``"filament"``).
297
394
  iray_samples: iRay sample count (0 = use scene default).
298
395
  reset_morphs: If ``True``, reset all morphs to defaults before applying.
396
+ retry_on_busy: If ``True``, transparently retry with backoff when
397
+ the server reports ``StudioBusyError``/``ConcurrencyLimitError``,
398
+ instead of raising immediately.
399
+ max_wait: Maximum total seconds to retry when *retry_on_busy* is
400
+ ``True``, before re-raising the busy error.
299
401
 
300
402
  Returns:
301
403
  A dict with ``request_id``, ``status`` (``"queued"``), and
@@ -304,6 +406,11 @@ class DazClient:
304
406
  Raises:
305
407
  ConnectionError: If the server cannot be reached.
306
408
  AuthenticationError: On HTTP 401/403.
409
+ StudioBusyError: If DAZ Studio's main thread is busy and
410
+ *retry_on_busy* is ``False`` or *max_wait* is exceeded.
411
+ ConcurrencyLimitError: If too many concurrent requests are in
412
+ flight and *retry_on_busy* is ``False`` or *max_wait* is
413
+ exceeded.
307
414
  """
308
415
  payload: dict = {"output_path": output_path}
309
416
  if width and height:
@@ -323,18 +430,28 @@ class DazClient:
323
430
  payload["figure"] = figure
324
431
  if morphs:
325
432
  payload["morphs"] = morphs
326
- resp = self._post("/render", payload)
327
- if resp.status_code in (401, 403):
328
- raise AuthenticationError(f"HTTP {resp.status_code}: {resp.text[:200]}")
329
- return resp.json()
330
433
 
331
- def render_batch_submit(self, variants: list, base: dict | None = None) -> dict:
434
+ def _do():
435
+ resp = self._post("/render", payload)
436
+ _raise_for_error(resp)
437
+ return resp.json()
438
+
439
+ return self._with_busy_retry(_do, retry_on_busy, max_wait)
440
+
441
+ def render_batch_submit(
442
+ self, variants: list, base: dict | None = None, *, retry_on_busy: bool = False, max_wait: float = 30.0
443
+ ) -> dict:
332
444
  """Submit a batch render job and return immediately.
333
445
 
334
446
  Args:
335
447
  variants: List of variant dicts, each with at least ``output_path``.
336
448
  Supported keys mirror :meth:`render_submit` optional fields.
337
449
  base: Optional shared defaults applied to all variants.
450
+ retry_on_busy: If ``True``, transparently retry with backoff when
451
+ the server reports ``StudioBusyError``/``ConcurrencyLimitError``,
452
+ instead of raising immediately.
453
+ max_wait: Maximum total seconds to retry when *retry_on_busy* is
454
+ ``True``, before re-raising the busy error.
338
455
 
339
456
  Returns:
340
457
  A dict with ``batch_id``, ``request_ids`` (list), and ``total`` keys.
@@ -342,14 +459,22 @@ class DazClient:
342
459
  Raises:
343
460
  ConnectionError: If the server cannot be reached.
344
461
  AuthenticationError: On HTTP 401/403.
462
+ StudioBusyError: If DAZ Studio's main thread is busy and
463
+ *retry_on_busy* is ``False`` or *max_wait* is exceeded.
464
+ ConcurrencyLimitError: If too many concurrent requests are in
465
+ flight and *retry_on_busy* is ``False`` or *max_wait* is
466
+ exceeded.
345
467
  """
346
468
  payload: dict = {"variants": variants}
347
469
  if base:
348
470
  payload["base"] = base
349
- resp = self._post("/render/batch", payload)
350
- if resp.status_code in (401, 403):
351
- raise AuthenticationError(f"HTTP {resp.status_code}: {resp.text[:200]}")
352
- return resp.json()
471
+
472
+ def _do():
473
+ resp = self._post("/render/batch", payload)
474
+ _raise_for_error(resp)
475
+ return resp.json()
476
+
477
+ return self._with_busy_retry(_do, retry_on_busy, max_wait)
353
478
 
354
479
  def render_animation_submit(
355
480
  self,
@@ -362,6 +487,8 @@ class DazClient:
362
487
  height: int = 0,
363
488
  camera: str = "",
364
489
  engine: str = "",
490
+ retry_on_busy: bool = False,
491
+ max_wait: float = 30.0,
365
492
  ) -> dict:
366
493
  """Submit an animation render job spanning a frame range and return immediately.
367
494
 
@@ -381,6 +508,11 @@ class DazClient:
381
508
  height: Image height in pixels (must be paired with *width*).
382
509
  camera: Camera label to render from.
383
510
  engine: Render engine (``"iray"``, ``"filament"``).
511
+ retry_on_busy: If ``True``, transparently retry with backoff when
512
+ the server reports ``StudioBusyError``/``ConcurrencyLimitError``,
513
+ instead of raising immediately.
514
+ max_wait: Maximum total seconds to retry when *retry_on_busy* is
515
+ ``True``, before re-raising the busy error.
384
516
 
385
517
  Returns:
386
518
  A dict with ``request_id``, ``status`` (``"queued"``), and
@@ -389,6 +521,11 @@ class DazClient:
389
521
  Raises:
390
522
  ConnectionError: If the server cannot be reached.
391
523
  AuthenticationError: On HTTP 401/403.
524
+ StudioBusyError: If DAZ Studio's main thread is busy and
525
+ *retry_on_busy* is ``False`` or *max_wait* is exceeded.
526
+ ConcurrencyLimitError: If too many concurrent requests are in
527
+ flight and *retry_on_busy* is ``False`` or *max_wait* is
528
+ exceeded.
392
529
  """
393
530
  payload: dict = {
394
531
  "output_path": output_path,
@@ -403,10 +540,13 @@ class DazClient:
403
540
  payload["camera"] = camera
404
541
  if engine:
405
542
  payload["engine"] = engine
406
- resp = self._post("/render/animation", payload)
407
- if resp.status_code in (401, 403):
408
- raise AuthenticationError(f"HTTP {resp.status_code}: {resp.text[:200]}")
409
- return resp.json()
543
+
544
+ def _do():
545
+ resp = self._post("/render/animation", payload)
546
+ _raise_for_error(resp)
547
+ return resp.json()
548
+
549
+ return self._with_busy_retry(_do, retry_on_busy, max_wait)
410
550
 
411
551
  def cancel_render(self, request_id: str) -> bool:
412
552
  """Cancel a queued or running render job.
@@ -10,6 +10,30 @@ class AuthenticationError(DazError):
10
10
  """Raised on HTTP 401 or 403 (bad or missing API token, or IP blocked)."""
11
11
 
12
12
 
13
+ class DazBusyError(DazError):
14
+ """Base class for transient "DAZ Studio is busy, please retry" conditions.
15
+
16
+ Attributes:
17
+ reason: Human-readable explanation of why the server is busy.
18
+ retry_after: Server-suggested seconds to wait before retrying.
19
+ """
20
+
21
+ def __init__(self, message: str, reason: str = "", retry_after: float = 2.0):
22
+ super().__init__(message)
23
+ self.reason = reason
24
+ self.retry_after = retry_after
25
+
26
+
27
+ class StudioBusyError(DazBusyError):
28
+ """Raised on HTTP 503 STUDIO_BUSY: DAZ Studio's main thread is occupied
29
+ with a scene load, save, clear, or render and cannot service the request."""
30
+
31
+
32
+ class ConcurrencyLimitError(DazBusyError):
33
+ """Raised on HTTP 429 CONCURRENT_LIMIT_EXCEEDED: too many requests are
34
+ already in flight against the server."""
35
+
36
+
13
37
  class ScriptError(DazError):
14
38
  """Base class for errors that originate inside a DazScript execution.
15
39
 
@@ -1,6 +1,21 @@
1
+ Metadata-Version: 2.4
2
+ Name: dazpy
3
+ Version: 2.8.0
4
+ Summary: Python SDK for the DAZ Studio Script Server
5
+ License: MIT
6
+ Project-URL: Homepage, https://github.com/bluemoonfoundry/daz-script-server
7
+ Project-URL: Repository, https://github.com/bluemoonfoundry/daz-script-server
8
+ Project-URL: Bug Tracker, https://github.com/bluemoonfoundry/daz-script-server/issues
9
+ Requires-Python: >=3.10
10
+ Description-Content-Type: text/markdown
11
+ Requires-Dist: requests>=2.28
12
+ Provides-Extra: docs
13
+ Requires-Dist: sphinx>=7.0; extra == "docs"
14
+ Requires-Dist: sphinx-rtd-theme>=2.0; extra == "docs"
15
+
1
16
  # DazScript Server
2
17
 
3
- **Version 2.7.2** | DAZ Studio 4.5+ | DAZ Studio 6.25+ | Windows & macOS
18
+ **Version 2.8.0** | DAZ Studio 4.5+ | DAZ Studio 6.25+ | Windows & macOS
4
19
 
5
20
  [![Docs](https://img.shields.io/badge/docs-dazpy%20SDK-blue)](https://bluemoonfoundry.github.io/daz-script-server/)
6
21
  [![HTTP API](https://img.shields.io/badge/docs-HTTP%20API%20reference-blue)](https://bluemoonfoundry.github.io/daz-script-server/api-reference/)
@@ -68,6 +83,7 @@ print(response.json())
68
83
  ### Getting Started
69
84
  - [Quick Start](#-quick-start)
70
85
  - [Why This Exists](#why-this-exists)
86
+ - [What's New in v2.8.0](#whats-new-in-v280)
71
87
  - [What's New in v2.7.2](#whats-new-in-v272)
72
88
  - [What's New in v2.7.1](#whats-new-in-v271)
73
89
  - [What's New in v2.7.0](#whats-new-in-v270)
@@ -154,6 +170,50 @@ DAZ Studio is powerful for 3D content creation, but automation is limited to man
154
170
 
155
171
  ---
156
172
 
173
+ ## What's New in v2.8.0
174
+
175
+ ### 🚦 Fail Fast When DAZ Studio Is Busy — `503 STUDIO_BUSY`
176
+
177
+ DAZ Studio's Qt main thread is single-threaded: if a user loads a scene,
178
+ saves, clears the scene, or renders through the DAZ Studio UI while the
179
+ server is also being driven remotely, requests that need the main thread
180
+ used to block indefinitely with no signal to the caller. `/execute`,
181
+ `/scripts/:id/execute`, the async-enqueue endpoints, and the render-submit
182
+ endpoints now check DAZ Studio's busy state first and return `503` with
183
+ `error_code: "STUDIO_BUSY"` (plus a `Retry-After` header) immediately
184
+ instead of hanging.
185
+
186
+ `dazpy` surfaces this as a new exception hierarchy — `DazBusyError`, with
187
+ `StudioBusyError` (503) and `ConcurrencyLimitError` (429) subclasses, each
188
+ carrying `reason` and `retry_after`. This also fixes a pre-existing bug
189
+ where `429 CONCURRENT_LIMIT_EXCEEDED` responses were misreported as
190
+ `ScriptRuntimeError`, as if the script itself had failed.
191
+
192
+ ```python
193
+ from dazpy import DazClient
194
+ from dazpy.exceptions import DazBusyError
195
+
196
+ client = DazClient()
197
+ try:
198
+ result = client.execute("1+1;")
199
+ except DazBusyError as e:
200
+ print(f"DAZ Studio is busy: {e.reason} — retry after {e.retry_after}s")
201
+ ```
202
+
203
+ Opt in to automatic retry with backoff instead of handling the exception
204
+ yourself:
205
+
206
+ ```python
207
+ result = client.execute("1+1;", retry_on_busy=True, max_wait=30.0)
208
+ ```
209
+
210
+ `retry_on_busy`/`max_wait` are available on `execute`, `execute_file`,
211
+ `execute_async_submit`, `render_submit`, `render_batch_submit`, and
212
+ `render_animation_submit`. Retry is opt-in and off by default — callers
213
+ (and any LLM driving them) stay informed rather than silently blocked.
214
+
215
+ ---
216
+
157
217
  ## What's New in v2.7.2
158
218
 
159
219
  No functional changes to `dazpy`'s behavior. `2.7.1`'s version bump missed
@@ -652,7 +712,7 @@ automation code without authoring DazScript by hand.
652
712
  Download the `.whl` file from the [latest release](https://github.com/bluemoonfoundry/daz-script-server/releases/latest) and install it:
653
713
 
654
714
  ```bash
655
- pip install dazpy-2.7.2-py3-none-any.whl
715
+ pip install dazpy-2.8.0-py3-none-any.whl
656
716
  ```
657
717
 
658
718
  Or install directly from the repo for development:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "dazpy"
7
- version = "2.7.2"
7
+ version = "2.8.0"
8
8
  description = "Python SDK for the DAZ Studio Script Server"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -935,6 +935,109 @@ class TestErrorMapping(unittest.TestCase):
935
935
  with self.assertRaises(exceptions.ScriptSyntaxError):
936
936
  client.execute("{bad syntax")
937
937
 
938
+ def test_studio_busy_error_503(self):
939
+ import requests as req
940
+ resp = MagicMock(spec=req.Response)
941
+ resp.status_code = 503
942
+ resp.json.return_value = {
943
+ "success": False,
944
+ "error_code": "STUDIO_BUSY",
945
+ "error": "DAZ Studio's main thread is busy; please retry shortly",
946
+ "detail": "DAZ Studio is currently loading a scene",
947
+ }
948
+ resp.headers = {"Retry-After": "2"}
949
+ resp.text = ""
950
+ client = DazClient.__new__(DazClient)
951
+ object.__setattr__(client, "_base", "http://127.0.0.1:18811")
952
+ object.__setattr__(client, "_token", "")
953
+ object.__setattr__(client, "_timeout", 30.0)
954
+ with patch("dazpy._client._requests.post", return_value=resp):
955
+ with self.assertRaises(exceptions.StudioBusyError) as ctx:
956
+ client.execute("1+1;")
957
+ self.assertIsInstance(ctx.exception, exceptions.DazBusyError)
958
+ self.assertEqual(ctx.exception.retry_after, 2.0)
959
+ self.assertIn("loading a scene", ctx.exception.reason)
960
+
961
+ def test_concurrency_limit_error_429(self):
962
+ import requests as req
963
+ resp = MagicMock(spec=req.Response)
964
+ resp.status_code = 429
965
+ resp.json.return_value = {
966
+ "success": False,
967
+ "error_code": "CONCURRENT_LIMIT_EXCEEDED",
968
+ "error": "Server busy: maximum concurrent requests reached, please retry",
969
+ }
970
+ resp.headers = {}
971
+ resp.text = ""
972
+ client = DazClient.__new__(DazClient)
973
+ object.__setattr__(client, "_base", "http://127.0.0.1:18811")
974
+ object.__setattr__(client, "_token", "")
975
+ object.__setattr__(client, "_timeout", 30.0)
976
+ with patch("dazpy._client._requests.post", return_value=resp):
977
+ with self.assertRaises(exceptions.ConcurrencyLimitError) as ctx:
978
+ client.execute("1+1;")
979
+ self.assertIsInstance(ctx.exception, exceptions.DazBusyError)
980
+ self.assertNotIsInstance(ctx.exception, exceptions.ScriptRuntimeError)
981
+ self.assertEqual(ctx.exception.retry_after, 2.0)
982
+
983
+ def _busy_resp(self):
984
+ import requests as req
985
+ resp = MagicMock(spec=req.Response)
986
+ resp.status_code = 503
987
+ resp.json.return_value = {
988
+ "success": False,
989
+ "error_code": "STUDIO_BUSY",
990
+ "error": "DAZ Studio's main thread is busy; please retry shortly",
991
+ "detail": "DAZ Studio is currently rendering",
992
+ }
993
+ resp.headers = {"Retry-After": "1"}
994
+ resp.text = ""
995
+ return resp
996
+
997
+ def _success_resp(self):
998
+ import requests as req
999
+ resp = MagicMock(spec=req.Response)
1000
+ resp.status_code = 200
1001
+ resp.json.return_value = {"success": True, "result": 2, "output": [], "request_id": "ok1"}
1002
+ resp.headers = {}
1003
+ resp.text = ""
1004
+ return resp
1005
+
1006
+ def _busy_client(self):
1007
+ client = DazClient.__new__(DazClient)
1008
+ object.__setattr__(client, "_base", "http://127.0.0.1:18811")
1009
+ object.__setattr__(client, "_token", "")
1010
+ object.__setattr__(client, "_timeout", 30.0)
1011
+ return client
1012
+
1013
+ def test_execute_without_retry_on_busy_raises_immediately(self):
1014
+ client = self._busy_client()
1015
+ with patch("dazpy._client._requests.post", return_value=self._busy_resp()) as post:
1016
+ with self.assertRaises(exceptions.StudioBusyError):
1017
+ client.execute("1+1;")
1018
+ self.assertEqual(post.call_count, 1)
1019
+
1020
+ def test_execute_retry_on_busy_succeeds_after_retries(self):
1021
+ client = self._busy_client()
1022
+ responses = [self._busy_resp(), self._busy_resp(), self._success_resp()]
1023
+ with patch("dazpy._client._requests.post", side_effect=responses) as post:
1024
+ with patch("dazpy._client.time.sleep") as sleep:
1025
+ result = client.execute("1+1;", retry_on_busy=True, max_wait=10.0)
1026
+ self.assertEqual(result.value, 2)
1027
+ self.assertEqual(post.call_count, 3)
1028
+ self.assertEqual(sleep.call_count, 2)
1029
+
1030
+ def test_execute_retry_on_busy_gives_up_after_max_wait(self):
1031
+ client = self._busy_client()
1032
+ with patch("dazpy._client._requests.post", return_value=self._busy_resp()):
1033
+ with patch("dazpy._client.time.sleep"):
1034
+ with patch(
1035
+ "dazpy._client.time.monotonic",
1036
+ side_effect=[0.0, 0.0, 5.0, 11.0, 20.0, 20.0],
1037
+ ):
1038
+ with self.assertRaises(exceptions.StudioBusyError):
1039
+ client.execute("1+1;", retry_on_busy=True, max_wait=10.0)
1040
+
938
1041
  def test_connection_error(self):
939
1042
  import requests as req
940
1043
  client = DazClient.__new__(DazClient)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes