dazpy 2.9.0__tar.gz → 2.9.3__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 (71) hide show
  1. {dazpy-2.9.0 → dazpy-2.9.3}/PKG-INFO +63 -9
  2. {dazpy-2.9.0 → dazpy-2.9.3}/README.md +62 -8
  3. {dazpy-2.9.0 → dazpy-2.9.3}/dazpy/__init__.py +1 -1
  4. dazpy-2.9.3/dazpy/_batch.py +219 -0
  5. {dazpy-2.9.0 → dazpy-2.9.3}/dazpy/_client.py +57 -3
  6. {dazpy-2.9.0 → dazpy-2.9.3}/dazpy/_client_aio.py +37 -1
  7. {dazpy-2.9.0 → dazpy-2.9.3}/dazpy/_element.py +46 -2
  8. {dazpy-2.9.0 → dazpy-2.9.3}/dazpy/_node.py +40 -0
  9. {dazpy-2.9.0 → dazpy-2.9.3}/dazpy/_pose.py +30 -4
  10. {dazpy-2.9.0 → dazpy-2.9.3}/dazpy/_render.py +11 -2
  11. {dazpy-2.9.0 → dazpy-2.9.3}/dazpy/_scene.py +24 -3
  12. {dazpy-2.9.0 → dazpy-2.9.3}/dazpy/_scene_state.py +167 -10
  13. {dazpy-2.9.0 → dazpy-2.9.3}/dazpy/_skeleton.py +104 -0
  14. {dazpy-2.9.0 → dazpy-2.9.3}/dazpy/exceptions.py +10 -0
  15. {dazpy-2.9.0 → dazpy-2.9.3}/dazpy/poses.py +7 -7
  16. {dazpy-2.9.0 → dazpy-2.9.3}/dazpy.egg-info/PKG-INFO +63 -9
  17. {dazpy-2.9.0 → dazpy-2.9.3}/pyproject.toml +1 -1
  18. {dazpy-2.9.0 → dazpy-2.9.3}/tests/test_api.py +236 -1
  19. {dazpy-2.9.0 → dazpy-2.9.3}/tests/test_dazpy.py +694 -19
  20. {dazpy-2.9.0 → dazpy-2.9.3}/tests/test_dazpy_aio.py +61 -0
  21. dazpy-2.9.0/dazpy/_batch.py +0 -107
  22. {dazpy-2.9.0 → dazpy-2.9.3}/dazpy/_animation.py +0 -0
  23. {dazpy-2.9.0 → dazpy-2.9.3}/dazpy/_bone.py +0 -0
  24. {dazpy-2.9.0 → dazpy-2.9.3}/dazpy/_camera.py +0 -0
  25. {dazpy-2.9.0 → dazpy-2.9.3}/dazpy/_dforce.py +0 -0
  26. {dazpy-2.9.0 → dazpy-2.9.3}/dazpy/_geometry.py +0 -0
  27. {dazpy-2.9.0 → dazpy-2.9.3}/dazpy/_interaction.py +0 -0
  28. {dazpy-2.9.0 → dazpy-2.9.3}/dazpy/_light.py +0 -0
  29. {dazpy-2.9.0 → dazpy-2.9.3}/dazpy/_material.py +0 -0
  30. {dazpy-2.9.0 → dazpy-2.9.3}/dazpy/_modifier.py +0 -0
  31. {dazpy-2.9.0 → dazpy-2.9.3}/dazpy/_morph.py +0 -0
  32. {dazpy-2.9.0 → dazpy-2.9.3}/dazpy/_polling.py +0 -0
  33. {dazpy-2.9.0 → dazpy-2.9.3}/dazpy/_property.py +0 -0
  34. {dazpy-2.9.0 → dazpy-2.9.3}/dazpy/_render_api.py +0 -0
  35. {dazpy-2.9.0 → dazpy-2.9.3}/dazpy/_render_api_aio.py +0 -0
  36. {dazpy-2.9.0 → dazpy-2.9.3}/dazpy/_result.py +0 -0
  37. {dazpy-2.9.0 → dazpy-2.9.3}/dazpy/_scene_events.py +0 -0
  38. {dazpy-2.9.0 → dazpy-2.9.3}/dazpy/_script_builder.py +0 -0
  39. {dazpy-2.9.0 → dazpy-2.9.3}/dazpy/_shot_geometry.py +0 -0
  40. {dazpy-2.9.0 → dazpy-2.9.3}/dazpy/_timeline.py +0 -0
  41. {dazpy-2.9.0 → dazpy-2.9.3}/dazpy/_undo.py +0 -0
  42. {dazpy-2.9.0 → dazpy-2.9.3}/dazpy/_viewport.py +0 -0
  43. {dazpy-2.9.0 → dazpy-2.9.3}/dazpy/aio.py +0 -0
  44. {dazpy-2.9.0 → dazpy-2.9.3}/dazpy/cinematics.py +0 -0
  45. {dazpy-2.9.0 → dazpy-2.9.3}/dazpy/lighting.py +0 -0
  46. {dazpy-2.9.0 → dazpy-2.9.3}/dazpy/materials.py +0 -0
  47. {dazpy-2.9.0 → dazpy-2.9.3}/dazpy/math3.py +0 -0
  48. {dazpy-2.9.0 → dazpy-2.9.3}/dazpy/py.typed +0 -0
  49. {dazpy-2.9.0 → dazpy-2.9.3}/dazpy.egg-info/SOURCES.txt +0 -0
  50. {dazpy-2.9.0 → dazpy-2.9.3}/dazpy.egg-info/dependency_links.txt +0 -0
  51. {dazpy-2.9.0 → dazpy-2.9.3}/dazpy.egg-info/requires.txt +0 -0
  52. {dazpy-2.9.0 → dazpy-2.9.3}/dazpy.egg-info/top_level.txt +0 -0
  53. {dazpy-2.9.0 → dazpy-2.9.3}/setup.cfg +0 -0
  54. {dazpy-2.9.0 → dazpy-2.9.3}/tests/test_comfyui_client.py +0 -0
  55. {dazpy-2.9.0 → dazpy-2.9.3}/tests/test_comfyui_enhance_integration.py +0 -0
  56. {dazpy-2.9.0 → dazpy-2.9.3}/tests/test_dazpy_integration.py +0 -0
  57. {dazpy-2.9.0 → dazpy-2.9.3}/tests/test_dsp_runner.py +0 -0
  58. {dazpy-2.9.0 → dazpy-2.9.3}/tests/test_file_watcher.py +0 -0
  59. {dazpy-2.9.0 → dazpy-2.9.3}/tests/test_math3.py +0 -0
  60. {dazpy-2.9.0 → dazpy-2.9.3}/tests/test_performance.py +0 -0
  61. {dazpy-2.9.0 → dazpy-2.9.3}/tests/test_render_api.py +0 -0
  62. {dazpy-2.9.0 → dazpy-2.9.3}/tests/test_sprite_matrix_canvas_convert.py +0 -0
  63. {dazpy-2.9.0 → dazpy-2.9.3}/tests/test_sprite_matrix_integration.py +0 -0
  64. {dazpy-2.9.0 → dazpy-2.9.3}/tests/test_sprite_matrix_paths.py +0 -0
  65. {dazpy-2.9.0 → dazpy-2.9.3}/tests/test_sprite_matrix_presets.py +0 -0
  66. {dazpy-2.9.0 → dazpy-2.9.3}/tests/test_sprite_matrix_render_shot.py +0 -0
  67. {dazpy-2.9.0 → dazpy-2.9.3}/tests/test_sprite_matrix_render_util.py +0 -0
  68. {dazpy-2.9.0 → dazpy-2.9.3}/tests/test_sprite_matrix_schema.py +0 -0
  69. {dazpy-2.9.0 → dazpy-2.9.3}/tests/test_sprite_matrix_stylize_stage.py +0 -0
  70. {dazpy-2.9.0 → dazpy-2.9.3}/tests/test_sprite_matrix_workflow_builder.py +0 -0
  71. {dazpy-2.9.0 → dazpy-2.9.3}/tests/test_workflow_builder.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dazpy
3
- Version: 2.9.0
3
+ Version: 2.9.3
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
@@ -17,7 +17,7 @@ Requires-Dist: httpx>=0.24; extra == "aio"
17
17
 
18
18
  # DazScript Server
19
19
 
20
- **Version 2.9.0** | DAZ Studio 4.5+ | DAZ Studio 6.25+ | Windows & macOS
20
+ **Version 2.9.3** | DAZ Studio 4.5+ | DAZ Studio 6.25+ | Windows & macOS
21
21
 
22
22
  [![Docs](https://img.shields.io/badge/docs-dazpy%20SDK-blue)](https://bluemoonfoundry.github.io/daz-script-server/)
23
23
  [![HTTP API](https://img.shields.io/badge/docs-HTTP%20API%20reference-blue)](https://bluemoonfoundry.github.io/daz-script-server/api-reference/)
@@ -80,6 +80,20 @@ print(response.json())
80
80
 
81
81
  ---
82
82
 
83
+ ## Examples
84
+
85
+ **Complete example collection:** [daz-script-server-examples repository](https://github.com/bluemoonfoundry/daz-script-server-examples)
86
+
87
+ Production-ready examples ranging from beginner tutorials to advanced pipeline implementations:
88
+ - **48 examples** across 9 categories
89
+ - Individual READMEs with skill level classification
90
+ - Requirements.txt per example
91
+ - Comprehensive searchable table
92
+
93
+ Categories: fundamentals, character, animation, rendering, geometry, export, ml_data, ai_vision, bvh
94
+
95
+ ---
96
+
83
97
  ## 📋 Table of Contents
84
98
 
85
99
  ### Getting Started
@@ -242,7 +256,7 @@ Async frameworks (FastAPI, FastMCP, ComfyUI custom nodes, asyncio/Temporal
242
256
  workflows) previously had to wrap every `DazClient` call in
243
257
  `asyncio.to_thread()` or hand-roll an `httpx.AsyncClient` wrapper.
244
258
  `dazpy.aio.AsyncDazClient` mirrors `DazClient`'s full method surface —
245
- `execute`, `execute_file`, async submit/status/result/list/cancel, render
259
+ `execute`, `execute_file`, inline/file async submit/status/result/list/cancel, render
246
260
  submit/batch/animation, USD export, and server-health endpoints — as
247
261
  native `async def` methods backed by `httpx.AsyncClient`, including the
248
262
  same `retry_on_busy`/`max_wait` backoff semantics (via `asyncio.sleep`
@@ -253,8 +267,9 @@ from dazpy.aio import AsyncDazClient
253
267
 
254
268
  async def main():
255
269
  async with AsyncDazClient() as client:
256
- result = await client.execute("1 + 1;")
257
- print(result.value)
270
+ request_id = await client.execute_file_async_submit("C:/jobs/pose-probe.dsa")
271
+ result = await client.get_request_result(request_id, wait=True)
272
+ print(result)
258
273
  ```
259
274
 
260
275
  Requires the optional `httpx` dependency: `pip install dazpy[aio]`.
@@ -787,7 +802,7 @@ See the [full migration guide](MIGRATION.md) for upgrade steps and rollback inst
787
802
 
788
803
  Long-running operations (renders, exports, batch jobs) no longer need to block the HTTP connection:
789
804
 
790
- - **`POST /execute/async`** — Submit any inline script asynchronously; returns a `request_id` immediately
805
+ - **`POST /execute/async`** — Submit inline `script` or host-side `scriptFile` work asynchronously; returns a `request_id` immediately
791
806
  - **`POST /scripts/:id/async`** — Submit a registered script asynchronously
792
807
  - **`GET /requests/:id/status`** — Poll for progress (`queued`, `running`, `completed`, `failed`, `cancelled`)
793
808
  - **`GET /requests/:id/result`** — Fetch the final result; supports `?wait=true` to long-poll until complete
@@ -1771,10 +1786,36 @@ result = requests.get(f"{BASE}/requests/{req_id}/result?wait=true&timeout=300",
1771
1786
 
1772
1787
  ### `POST /execute/async`
1773
1788
 
1774
- **Purpose:** Submit an inline script for asynchronous execution
1789
+ **Purpose:** Submit an inline script or host-side script file for asynchronous execution
1775
1790
  **Authentication:** Required (if enabled)
1776
1791
 
1777
- **Request Body:** Same as `POST /execute`
1792
+ **Request Body:** Same as `POST /execute`, plus optional `reportFile` for
1793
+ structured job observation.
1794
+
1795
+ `reportFile` is an absolute host-side path to a JSONL file owned by this job.
1796
+ The server truncates it when the request is accepted. The script may then write
1797
+ one JSON object per line. The path is injected into the script's argument object
1798
+ as reserved field `getArguments()[0].__dssReportFile`, so callers do not have to
1799
+ duplicate it inside `args`:
1800
+
1801
+ ```jsonl
1802
+ {"type":"progress","value":0.25,"current":1,"total":4,"phase":"render","message":"Plate 1"}
1803
+ {"type":"log","level":"info","message":"Loaded scene"}
1804
+ {"type":"output","path":"C:/renders/run/plate-1.png","kind":"image","label":"plate"}
1805
+ ```
1806
+
1807
+ Progress events require either `value` (0–1) or `current` plus `total`. Log
1808
+ events require `message`. Output events require `path`; repeating a path updates
1809
+ that manifest entry. A `manifest` event may provide an `outputs` array. Use a
1810
+ unique report file for every job.
1811
+ `reportFile` may not be the submitted `scriptFile`, because the server owns and
1812
+ truncates the former before the job is queued.
1813
+
1814
+ DAZ Studio 6 ingests report events from polling workers, so progress, logs, and
1815
+ outputs are visible while the main thread is occupied by the script. DAZ Studio
1816
+ 4's JSON parser is main-thread-only; Studio 4 therefore ingests the final report
1817
+ after execution returns and does not expose report-driven progress while the job
1818
+ is still running.
1778
1819
 
1779
1820
  **Response (immediate):**
1780
1821
  ```json
@@ -1808,7 +1849,15 @@ result = requests.get(f"{BASE}/requests/{req_id}/result?wait=true&timeout=300",
1808
1849
  {
1809
1850
  "request_id": "a3f2b891",
1810
1851
  "status": "running",
1811
- "progress": 0.0,
1852
+ "progress": 0.25,
1853
+ "observation": {
1854
+ "progress": {"value": 0.25, "current": 1, "total": 4, "phase": "render"},
1855
+ "log_tail": [{"level": "info", "source": "script", "message": "Loaded scene"}],
1856
+ "log_total": 1,
1857
+ "log_truncated": false,
1858
+ "output_manifest": {"count": 0, "outputs": []},
1859
+ "report_file": "C:/renders/run/job.jsonl"
1860
+ },
1812
1861
  "elapsed_ms": 1240,
1813
1862
  "queue_position": 0
1814
1863
  }
@@ -1846,6 +1895,11 @@ result = requests.get(f"{BASE}/requests/{req_id}/result?wait=true&timeout=300",
1846
1895
  }
1847
1896
  ```
1848
1897
 
1898
+ Completed, failed, and cancelled results also include `observation`. The log
1899
+ tail is capped at the newest 100 entries; `log_total` and `log_truncated` make
1900
+ the loss explicit. Ordinary captured `print()` output is folded into the same
1901
+ tail at completion while remaining available in the legacy `output` field.
1902
+
1849
1903
  Returns HTTP 404 if the request ID is unknown or has been purged (TTL: 1 hour).
1850
1904
 
1851
1905
  ---
@@ -1,6 +1,6 @@
1
1
  # DazScript Server
2
2
 
3
- **Version 2.9.0** | DAZ Studio 4.5+ | DAZ Studio 6.25+ | Windows & macOS
3
+ **Version 2.9.3** | DAZ Studio 4.5+ | DAZ Studio 6.25+ | Windows & macOS
4
4
 
5
5
  [![Docs](https://img.shields.io/badge/docs-dazpy%20SDK-blue)](https://bluemoonfoundry.github.io/daz-script-server/)
6
6
  [![HTTP API](https://img.shields.io/badge/docs-HTTP%20API%20reference-blue)](https://bluemoonfoundry.github.io/daz-script-server/api-reference/)
@@ -63,6 +63,20 @@ print(response.json())
63
63
 
64
64
  ---
65
65
 
66
+ ## Examples
67
+
68
+ **Complete example collection:** [daz-script-server-examples repository](https://github.com/bluemoonfoundry/daz-script-server-examples)
69
+
70
+ Production-ready examples ranging from beginner tutorials to advanced pipeline implementations:
71
+ - **48 examples** across 9 categories
72
+ - Individual READMEs with skill level classification
73
+ - Requirements.txt per example
74
+ - Comprehensive searchable table
75
+
76
+ Categories: fundamentals, character, animation, rendering, geometry, export, ml_data, ai_vision, bvh
77
+
78
+ ---
79
+
66
80
  ## 📋 Table of Contents
67
81
 
68
82
  ### Getting Started
@@ -225,7 +239,7 @@ Async frameworks (FastAPI, FastMCP, ComfyUI custom nodes, asyncio/Temporal
225
239
  workflows) previously had to wrap every `DazClient` call in
226
240
  `asyncio.to_thread()` or hand-roll an `httpx.AsyncClient` wrapper.
227
241
  `dazpy.aio.AsyncDazClient` mirrors `DazClient`'s full method surface —
228
- `execute`, `execute_file`, async submit/status/result/list/cancel, render
242
+ `execute`, `execute_file`, inline/file async submit/status/result/list/cancel, render
229
243
  submit/batch/animation, USD export, and server-health endpoints — as
230
244
  native `async def` methods backed by `httpx.AsyncClient`, including the
231
245
  same `retry_on_busy`/`max_wait` backoff semantics (via `asyncio.sleep`
@@ -236,8 +250,9 @@ from dazpy.aio import AsyncDazClient
236
250
 
237
251
  async def main():
238
252
  async with AsyncDazClient() as client:
239
- result = await client.execute("1 + 1;")
240
- print(result.value)
253
+ request_id = await client.execute_file_async_submit("C:/jobs/pose-probe.dsa")
254
+ result = await client.get_request_result(request_id, wait=True)
255
+ print(result)
241
256
  ```
242
257
 
243
258
  Requires the optional `httpx` dependency: `pip install dazpy[aio]`.
@@ -770,7 +785,7 @@ See the [full migration guide](MIGRATION.md) for upgrade steps and rollback inst
770
785
 
771
786
  Long-running operations (renders, exports, batch jobs) no longer need to block the HTTP connection:
772
787
 
773
- - **`POST /execute/async`** — Submit any inline script asynchronously; returns a `request_id` immediately
788
+ - **`POST /execute/async`** — Submit inline `script` or host-side `scriptFile` work asynchronously; returns a `request_id` immediately
774
789
  - **`POST /scripts/:id/async`** — Submit a registered script asynchronously
775
790
  - **`GET /requests/:id/status`** — Poll for progress (`queued`, `running`, `completed`, `failed`, `cancelled`)
776
791
  - **`GET /requests/:id/result`** — Fetch the final result; supports `?wait=true` to long-poll until complete
@@ -1754,10 +1769,36 @@ result = requests.get(f"{BASE}/requests/{req_id}/result?wait=true&timeout=300",
1754
1769
 
1755
1770
  ### `POST /execute/async`
1756
1771
 
1757
- **Purpose:** Submit an inline script for asynchronous execution
1772
+ **Purpose:** Submit an inline script or host-side script file for asynchronous execution
1758
1773
  **Authentication:** Required (if enabled)
1759
1774
 
1760
- **Request Body:** Same as `POST /execute`
1775
+ **Request Body:** Same as `POST /execute`, plus optional `reportFile` for
1776
+ structured job observation.
1777
+
1778
+ `reportFile` is an absolute host-side path to a JSONL file owned by this job.
1779
+ The server truncates it when the request is accepted. The script may then write
1780
+ one JSON object per line. The path is injected into the script's argument object
1781
+ as reserved field `getArguments()[0].__dssReportFile`, so callers do not have to
1782
+ duplicate it inside `args`:
1783
+
1784
+ ```jsonl
1785
+ {"type":"progress","value":0.25,"current":1,"total":4,"phase":"render","message":"Plate 1"}
1786
+ {"type":"log","level":"info","message":"Loaded scene"}
1787
+ {"type":"output","path":"C:/renders/run/plate-1.png","kind":"image","label":"plate"}
1788
+ ```
1789
+
1790
+ Progress events require either `value` (0–1) or `current` plus `total`. Log
1791
+ events require `message`. Output events require `path`; repeating a path updates
1792
+ that manifest entry. A `manifest` event may provide an `outputs` array. Use a
1793
+ unique report file for every job.
1794
+ `reportFile` may not be the submitted `scriptFile`, because the server owns and
1795
+ truncates the former before the job is queued.
1796
+
1797
+ DAZ Studio 6 ingests report events from polling workers, so progress, logs, and
1798
+ outputs are visible while the main thread is occupied by the script. DAZ Studio
1799
+ 4's JSON parser is main-thread-only; Studio 4 therefore ingests the final report
1800
+ after execution returns and does not expose report-driven progress while the job
1801
+ is still running.
1761
1802
 
1762
1803
  **Response (immediate):**
1763
1804
  ```json
@@ -1791,7 +1832,15 @@ result = requests.get(f"{BASE}/requests/{req_id}/result?wait=true&timeout=300",
1791
1832
  {
1792
1833
  "request_id": "a3f2b891",
1793
1834
  "status": "running",
1794
- "progress": 0.0,
1835
+ "progress": 0.25,
1836
+ "observation": {
1837
+ "progress": {"value": 0.25, "current": 1, "total": 4, "phase": "render"},
1838
+ "log_tail": [{"level": "info", "source": "script", "message": "Loaded scene"}],
1839
+ "log_total": 1,
1840
+ "log_truncated": false,
1841
+ "output_manifest": {"count": 0, "outputs": []},
1842
+ "report_file": "C:/renders/run/job.jsonl"
1843
+ },
1795
1844
  "elapsed_ms": 1240,
1796
1845
  "queue_position": 0
1797
1846
  }
@@ -1829,6 +1878,11 @@ result = requests.get(f"{BASE}/requests/{req_id}/result?wait=true&timeout=300",
1829
1878
  }
1830
1879
  ```
1831
1880
 
1881
+ Completed, failed, and cancelled results also include `observation`. The log
1882
+ tail is capped at the newest 100 entries; `log_total` and `log_truncated` make
1883
+ the loss explicit. Ordinary captured `print()` output is folded into the same
1884
+ tail at completion while remaining available in the legacy `output` field.
1885
+
1832
1886
  Returns HTTP 404 if the request ID is unknown or has been purged (TTL: 1 hour).
1833
1887
 
1834
1888
  ---
@@ -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.9.0"
16
+ __version__ = "2.9.3"
17
17
 
18
18
  from ._client import DazClient
19
19
  from ._scene import DazScene
@@ -0,0 +1,219 @@
1
+ from __future__ import annotations
2
+
3
+ from ._client import DazClient
4
+ from .exceptions import BatchLimitExceededError
5
+
6
+ DEFAULT_MAX_OPERATIONS = 500
7
+ DEFAULT_MAX_SCRIPT_LENGTH = 900_000 # stays under the server's default 1MB script cap
8
+
9
+
10
+ def build_operations_script(operations: list[tuple[list[str], str]]) -> str:
11
+ """Build one IIFE script from a list of (body_lines, result_expression) pairs.
12
+
13
+ Shared by :meth:`Batch._build_script` and
14
+ :meth:`~dazpy.DazClient.execute_batch_async` so both produce scripts with
15
+ identical shape (keyed return object over ``_r0``, ``_r1``, ...).
16
+ """
17
+ body_lines = []
18
+ return_parts = []
19
+ for i, (lines, result_expression) in enumerate(operations):
20
+ key = f"_r{i}"
21
+ body_lines.extend(lines)
22
+ body_lines.append(f"var {key} = {result_expression};")
23
+ return_parts.append(f'"{key}": {key}')
24
+ return_obj = "{" + ", ".join(return_parts) + "}"
25
+ body_lines.append(f"return {return_obj};")
26
+ body = "\n".join(body_lines)
27
+ return f"(function(){{\n{body}\n}})()"
28
+
29
+
30
+ class BatchFuture:
31
+ """Placeholder for a single result within a :class:`Batch` execution.
32
+
33
+ Created by :meth:`Batch.add` or :meth:`Batch.add_operation`; the
34
+ :attr:`value` property blocks until the batch has been executed.
35
+ """
36
+
37
+ def __init__(self, key: str):
38
+ self._key = key
39
+ self._resolved = False
40
+ self._value = None
41
+
42
+ @property
43
+ def value(self) -> object:
44
+ """The result value.
45
+
46
+ Raises:
47
+ RuntimeError: If :meth:`Batch.execute` has not been called yet.
48
+ """
49
+ if not self._resolved:
50
+ raise RuntimeError("Batch has not been executed yet")
51
+ return self._value
52
+
53
+ def _resolve(self, value: object) -> None:
54
+ self._value = value
55
+ self._resolved = True
56
+
57
+
58
+ class Batch:
59
+ """Collect multiple DazScript operations and execute them in a single HTTP round-trip.
60
+
61
+ Usage as a context manager (recommended)::
62
+
63
+ with Batch(client) as b:
64
+ pos_future = b.add(["var pos = Scene.findNode('Figure').getWSPos();",
65
+ "var pos = [pos.x, pos.y, pos.z];"])
66
+ name_future = b.add(["var name = Scene.findNode('Figure').getName();"])
67
+ # Both futures resolved after the `with` block
68
+ print(pos_future.value, name_future.value)
69
+
70
+ Or manually::
71
+
72
+ b = Batch(client)
73
+ f = b.add(["var x = 42;"])
74
+ b.execute()
75
+ print(f.value)
76
+
77
+ High-level helpers that generate operations programmatically should use
78
+ :meth:`add_operation` instead of :meth:`add` — it does not require the
79
+ caller to know the internally generated result-variable name, and
80
+ :meth:`add_prelude` lets multiple operations share one setup block (e.g.
81
+ a node lookup) emitted only once.
82
+
83
+ Args:
84
+ client: The :class:`~dazpy.DazClient` to use.
85
+ max_operations: Maximum number of queued operations before
86
+ :meth:`add_operation` raises :class:`~dazpy.exceptions.BatchLimitExceededError`.
87
+ max_script_length: Maximum generated script length (characters)
88
+ before :meth:`execute` raises :class:`~dazpy.exceptions.BatchLimitExceededError`.
89
+ """
90
+
91
+ def __init__(
92
+ self,
93
+ client: DazClient,
94
+ max_operations: int = DEFAULT_MAX_OPERATIONS,
95
+ max_script_length: int = DEFAULT_MAX_SCRIPT_LENGTH,
96
+ ):
97
+ self._client = client
98
+ self._ops: list[tuple[str, list[str], BatchFuture]] = []
99
+ self._preludes: dict[str, list[str]] = {}
100
+ self._prelude_order: list[str] = []
101
+ self._counter = 0
102
+ self._max_operations = max_operations
103
+ self._max_script_length = max_script_length
104
+
105
+ def add(self, lines: list[str]) -> BatchFuture:
106
+ """Queue a list of DazScript lines to be included in the batch.
107
+
108
+ The last line in *lines* should assign the desired result to a
109
+ variable named after the internally generated key (``_r0``, ``_r1``,
110
+ ... in call order) — inspect a prior :meth:`execute` call's generated
111
+ script if the exact naming matters, or prefer :meth:`add_operation`,
112
+ which does not require guessing the key name.
113
+
114
+ Args:
115
+ lines: DazScript source lines (no ``return`` needed).
116
+
117
+ Returns:
118
+ A :class:`BatchFuture` that resolves after :meth:`execute`.
119
+ """
120
+ key = f"_r{self._counter}"
121
+ self._counter += 1
122
+ future = BatchFuture(key)
123
+ self._ops.append((key, lines, future))
124
+ return future
125
+
126
+ def add_prelude(self, prelude_key: str, lines: list[str]) -> None:
127
+ """Register a shared setup block, emitted once per unique *prelude_key*.
128
+
129
+ Call this before :meth:`add_operation` calls whose bodies depend on
130
+ the prelude's bound variable(s) (e.g. a node lookup bound to
131
+ ``_node_Fig``). Repeated calls with the same *prelude_key* are no-ops
132
+ after the first — use this instead of re-emitting an identical lookup
133
+ once per operation.
134
+
135
+ Args:
136
+ prelude_key: Stable identifier for this setup block (e.g.
137
+ ``"node:Fig"``). Callers must pick keys that collide exactly
138
+ when — and only when — the generated lines are identical.
139
+ lines: DazScript source lines for the shared setup.
140
+ """
141
+ if prelude_key not in self._preludes:
142
+ self._preludes[prelude_key] = list(lines)
143
+ self._prelude_order.append(prelude_key)
144
+
145
+ def add_operation(self, body_lines: list[str], result_expression: str) -> BatchFuture:
146
+ """Queue an operation whose result the builder assigns internally.
147
+
148
+ Unlike :meth:`add`, the caller does not need to know the generated
149
+ key name — pass the JS expression that yields the result
150
+ (*result_expression*, e.g. a variable set inside *body_lines*, or a
151
+ literal expression), and the builder emits
152
+ ``var _rN = <result_expression>;`` itself.
153
+
154
+ Args:
155
+ body_lines: DazScript source lines with no trailing result
156
+ assignment (side effects only, e.g. property writes).
157
+ result_expression: A JS expression evaluated once, immediately
158
+ after *body_lines* run, and used as this operation's result.
159
+ Mutation-only operations should pass ``"null"``.
160
+
161
+ Returns:
162
+ A :class:`BatchFuture` that resolves after :meth:`execute`.
163
+
164
+ Raises:
165
+ BatchLimitExceededError: If this call would exceed the batch's
166
+ configured ``max_operations``.
167
+ """
168
+ if len(self._ops) >= self._max_operations:
169
+ raise BatchLimitExceededError(
170
+ f"Batch already has {len(self._ops)} operations "
171
+ f"(max_operations={self._max_operations})"
172
+ )
173
+ key = f"_r{self._counter}"
174
+ self._counter += 1
175
+ future = BatchFuture(key)
176
+ lines = list(body_lines) + [f"var {key} = {result_expression};"]
177
+ self._ops.append((key, lines, future))
178
+ return future
179
+
180
+ def _build_script(self) -> str:
181
+ body_lines = []
182
+ for prelude_key in self._prelude_order:
183
+ body_lines.extend(self._preludes[prelude_key])
184
+ return_parts = []
185
+ for key, lines, _ in self._ops:
186
+ body_lines.extend(lines)
187
+ return_parts.append(f'"{key}": {key}')
188
+ return_obj = "{" + ", ".join(return_parts) + "}"
189
+ body_lines.append(f"return {return_obj};")
190
+ body = "\n".join(body_lines)
191
+ return f"(function(){{\n{body}\n}})()"
192
+
193
+ def execute(self) -> None:
194
+ """Execute all queued operations in a single HTTP request and resolve all futures.
195
+
196
+ Raises:
197
+ BatchLimitExceededError: If the generated script exceeds
198
+ ``max_script_length``. Raised before any HTTP call.
199
+ """
200
+ if not self._ops:
201
+ return
202
+ script = self._build_script()
203
+ if len(script) > self._max_script_length:
204
+ raise BatchLimitExceededError(
205
+ f"Generated batch script is {len(script)} characters "
206
+ f"(max_script_length={self._max_script_length})"
207
+ )
208
+ result = self._client.execute(script)
209
+ data = result.value or {}
210
+ for key, _, future in self._ops:
211
+ future._resolve(data.get(key))
212
+
213
+ def __enter__(self) -> "Batch":
214
+ return self
215
+
216
+ def __exit__(self, exc_type, exc_val, exc_tb) -> bool:
217
+ if exc_type is None:
218
+ self.execute()
219
+ return False
@@ -262,13 +262,16 @@ class DazClient:
262
262
  return self._with_busy_retry(_do, retry_on_busy, max_wait)
263
263
 
264
264
  def execute_async_submit(
265
- self, script: str, args: object = None, *, retry_on_busy: bool = False, max_wait: float = 30.0
265
+ self, script: str, args: object = None, *, report_file: str | None = None,
266
+ retry_on_busy: bool = False, max_wait: float = 30.0
266
267
  ) -> str:
267
268
  """Submit a script for asynchronous execution and return immediately.
268
269
 
269
270
  Args:
270
271
  script: DazScript source code.
271
272
  args: Optional argument for the script.
273
+ report_file: Optional host-side JSONL file used by the script to
274
+ report structured progress, logs, and output artefacts.
272
275
  retry_on_busy: If ``True``, transparently retry with backoff when
273
276
  the server reports ``StudioBusyError``/``ConcurrencyLimitError``,
274
277
  instead of raising immediately.
@@ -292,6 +295,8 @@ class DazClient:
292
295
  payload: dict = {"script": script}
293
296
  if args is not None:
294
297
  payload["args"] = args
298
+ if report_file is not None:
299
+ payload["reportFile"] = report_file
295
300
 
296
301
  def _do():
297
302
  resp = self._post("/execute/async", payload)
@@ -300,6 +305,53 @@ class DazClient:
300
305
 
301
306
  return self._with_busy_retry(_do, retry_on_busy, max_wait)
302
307
 
308
+ def execute_file_async_submit(
309
+ self, script_file: str, args: object = None, *, report_file: str | None = None,
310
+ retry_on_busy: bool = False, max_wait: float = 30.0
311
+ ) -> str:
312
+ """Submit a host-side ``.dsa`` file for asynchronous execution.
313
+
314
+ The file is loaded by DAZ Studio when the queued job starts, preserving
315
+ its filename for ``getScriptFileName()`` and relative ``include()``
316
+ calls. Returns the server-assigned request id immediately; use the
317
+ request status/result/cancel methods to manage its lifecycle.
318
+ """
319
+ payload: dict = {"scriptFile": script_file}
320
+ if args is not None:
321
+ payload["args"] = args
322
+ if report_file is not None:
323
+ payload["reportFile"] = report_file
324
+
325
+ def _do():
326
+ resp = self._post("/execute/async", payload)
327
+ _raise_for_error(resp)
328
+ return resp.json().get("request_id", "")
329
+
330
+ return self._with_busy_retry(_do, retry_on_busy, max_wait)
331
+
332
+ def execute_batch_async(
333
+ self, operations: list[dict], args: object = None, *, report_file: str | None = None
334
+ ) -> str:
335
+ """Submit multiple operations as one async request (one queue slot, one script).
336
+
337
+ Args:
338
+ operations: List of ``{"body_lines": [...], "result_expression": "..."}``
339
+ dicts — same shape as :meth:`~dazpy.Batch.add_operation`'s arguments.
340
+ args: Optional argument passed to the combined script.
341
+ report_file: Optional host-side JSONL file used for structured job
342
+ observation, as in :meth:`execute_async_submit`.
343
+
344
+ Returns:
345
+ The server-assigned ``request_id``. Poll it like any other async
346
+ request; the result's ``result`` field is a dict keyed ``"_r0"``,
347
+ ``"_r1"``, ... in submission order.
348
+ """
349
+ from ._batch import build_operations_script
350
+
351
+ pairs = [(op["body_lines"], op["result_expression"]) for op in operations]
352
+ script = build_operations_script(pairs)
353
+ return self.execute_async_submit(script, args=args, report_file=report_file)
354
+
303
355
  def get_request_status(self, request_id: str) -> dict:
304
356
  """Return the current status of an async request.
305
357
 
@@ -307,7 +359,9 @@ class DazClient:
307
359
  request_id: The ID returned by :meth:`execute_async_submit`.
308
360
 
309
361
  Returns:
310
- A dict with at least a ``"status"`` key. Possible values:
362
+ A dict with at least ``"status"`` and ``"observation"`` keys.
363
+ Observation contains structured progress, the bounded log tail,
364
+ and the output manifest. Possible status values:
311
365
  ``"queued"``, ``"running"``, ``"completed"``, ``"failed"``,
312
366
  ``"cancelled"``, or ``"not_found"``.
313
367
  """
@@ -328,7 +382,7 @@ class DazClient:
328
382
 
329
383
  Returns:
330
384
  A dict containing ``success``, ``result``, ``output``, ``error``,
331
- ``duration_ms``, and ``status`` keys.
385
+ ``duration_ms``, ``status``, and ``observation`` keys.
332
386
  """
333
387
  params = {}
334
388
  if wait:
@@ -127,12 +127,15 @@ class AsyncDazClient:
127
127
  return await self._with_busy_retry(_do, retry_on_busy, max_wait)
128
128
 
129
129
  async def execute_async_submit(
130
- self, script: str, args: object = None, *, retry_on_busy: bool = False, max_wait: float = 30.0
130
+ self, script: str, args: object = None, *, report_file: str | None = None,
131
+ retry_on_busy: bool = False, max_wait: float = 30.0
131
132
  ) -> str:
132
133
  """Submit a script for async execution. See :meth:`dazpy.DazClient.execute_async_submit`."""
133
134
  payload: dict = {"script": script}
134
135
  if args is not None:
135
136
  payload["args"] = args
137
+ if report_file is not None:
138
+ payload["reportFile"] = report_file
136
139
 
137
140
  async def _do():
138
141
  resp = await self._post("/execute/async", payload)
@@ -141,6 +144,39 @@ class AsyncDazClient:
141
144
 
142
145
  return await self._with_busy_retry(_do, retry_on_busy, max_wait)
143
146
 
147
+ async def execute_file_async_submit(
148
+ self, script_file: str, args: object = None, *, report_file: str | None = None,
149
+ retry_on_busy: bool = False, max_wait: float = 30.0
150
+ ) -> str:
151
+ """Submit a host-side ``.dsa`` file asynchronously.
152
+
153
+ See :meth:`dazpy.DazClient.execute_file_async_submit`.
154
+ """
155
+ payload: dict = {"scriptFile": script_file}
156
+ if args is not None:
157
+ payload["args"] = args
158
+ if report_file is not None:
159
+ payload["reportFile"] = report_file
160
+
161
+ async def _do():
162
+ resp = await self._post("/execute/async", payload)
163
+ _raise_for_error(resp)
164
+ return resp.json().get("request_id", "")
165
+
166
+ return await self._with_busy_retry(_do, retry_on_busy, max_wait)
167
+
168
+ async def execute_batch_async(
169
+ self, operations: list[dict], args: object = None, *, report_file: str | None = None
170
+ ) -> str:
171
+ """Submit multiple operations as one async request. See :meth:`dazpy.DazClient.execute_batch_async`."""
172
+ from ._batch import build_operations_script
173
+
174
+ pairs = [(op["body_lines"], op["result_expression"]) for op in operations]
175
+ script = build_operations_script(pairs)
176
+ return await self.execute_async_submit(
177
+ script, args=args, report_file=report_file
178
+ )
179
+
144
180
  async def get_request_status(self, request_id: str) -> dict:
145
181
  """See :meth:`dazpy.DazClient.get_request_status`."""
146
182
  resp = await self._get(f"/requests/{request_id}/status")