dazpy 2.6.0__py3-none-any.whl

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.
dazpy/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Blue Moon Foundry
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
dazpy/__init__.py ADDED
@@ -0,0 +1,158 @@
1
+ """dazpy — Python SDK for the DAZ Studio Script Server.
2
+
3
+ Connect to a running DAZ Studio instance, execute DazScript code, and
4
+ manipulate the scene through a type-safe Python API.
5
+
6
+ Typical usage::
7
+
8
+ from dazpy import DazClient, DazScene
9
+
10
+ client = DazClient() # connects to 127.0.0.1:18811
11
+ scene = DazScene(client)
12
+ figure = scene.find_skeleton_by_label("Genesis 9")
13
+ figure.find_bone("r_forearm").set_local_rotation(0, 0, 45)
14
+ """
15
+
16
+ __version__ = "2.6.0"
17
+
18
+ from ._client import DazClient
19
+ from ._scene import DazScene
20
+ from ._node import DazNode, NodeIdentifier
21
+ from ._skeleton import DazSkeleton
22
+ from ._bone import DazBone
23
+ from ._camera import DazCamera
24
+ from ._light import DazLight
25
+ from ._material import DazMaterial
26
+ from ._modifier import DazModifier
27
+ from ._morph import DazMorph
28
+ from ._geometry import DazGeometry
29
+ from ._render import DazRenderSettings
30
+ from ._viewport import DazViewport
31
+ from ._timeline import DazTimeline
32
+ from ._property import DazProperty
33
+ from ._element import DazElement
34
+ from ._batch import Batch, BatchFuture
35
+ from ._interaction import (
36
+ AxisLimit,
37
+ BalanceTarget,
38
+ BoneChain,
39
+ BoneProfile,
40
+ AnchorTarget,
41
+ ContactTarget,
42
+ FigureRigProfile,
43
+ InteractionAnchor,
44
+ InteractionPlan,
45
+ InteractionRecipe,
46
+ InteractionPosePatch,
47
+ LimbAlignmentResult,
48
+ PreparedInteractionRecipe,
49
+ PreparedInteractionResult,
50
+ FootTarget,
51
+ LookAtTarget,
52
+ HandTarget,
53
+ PoseTarget,
54
+ ResolvedInteractionTarget,
55
+ SolveOptions,
56
+ ValidationIssue,
57
+ build_rig_profile,
58
+ build_rig_profiles_from_snapshot,
59
+ build_fight_recipe,
60
+ build_kiss_recipe,
61
+ build_sit_recipe,
62
+ build_touch_recipe,
63
+ align_foot_target,
64
+ align_hand_target,
65
+ default_axis_limits_for_bone,
66
+ align_single_limb_target,
67
+ apply_interaction_recipe_to_scene,
68
+ prepare_interaction_recipe,
69
+ resolve_interaction_target,
70
+ )
71
+ from ._undo import UndoGroup
72
+ from ._pose import DazPose
73
+ from ._animation import DazAnimation
74
+ from .math3 import Vec3, Quat, BoundingBox
75
+ from ._result import ExecutionResult
76
+ from ._polling import execute_long
77
+ from ._render_api import (
78
+ FigureMorphs,
79
+ RenderVariant,
80
+ RenderBase,
81
+ RenderResult,
82
+ render,
83
+ render_variants,
84
+ )
85
+ from .exceptions import RenderError
86
+ from . import exceptions
87
+
88
+ __all__ = [
89
+ "DazClient",
90
+ "DazScene",
91
+ "DazNode",
92
+ "NodeIdentifier",
93
+ "DazSkeleton",
94
+ "DazBone",
95
+ "DazCamera",
96
+ "DazLight",
97
+ "DazMaterial",
98
+ "DazModifier",
99
+ "DazMorph",
100
+ "DazGeometry",
101
+ "DazRenderSettings",
102
+ "DazViewport",
103
+ "DazTimeline",
104
+ "DazProperty",
105
+ "DazElement",
106
+ "Batch",
107
+ "BatchFuture",
108
+ "AxisLimit",
109
+ "BalanceTarget",
110
+ "BoneChain",
111
+ "BoneProfile",
112
+ "AnchorTarget",
113
+ "ContactTarget",
114
+ "FigureRigProfile",
115
+ "InteractionAnchor",
116
+ "InteractionPlan",
117
+ "InteractionRecipe",
118
+ "InteractionPosePatch",
119
+ "LimbAlignmentResult",
120
+ "PreparedInteractionRecipe",
121
+ "PreparedInteractionResult",
122
+ "FootTarget",
123
+ "LookAtTarget",
124
+ "HandTarget",
125
+ "PoseTarget",
126
+ "ResolvedInteractionTarget",
127
+ "SolveOptions",
128
+ "ValidationIssue",
129
+ "build_rig_profile",
130
+ "build_rig_profiles_from_snapshot",
131
+ "build_fight_recipe",
132
+ "build_kiss_recipe",
133
+ "build_sit_recipe",
134
+ "build_touch_recipe",
135
+ "align_foot_target",
136
+ "align_hand_target",
137
+ "default_axis_limits_for_bone",
138
+ "align_single_limb_target",
139
+ "apply_interaction_recipe_to_scene",
140
+ "prepare_interaction_recipe",
141
+ "resolve_interaction_target",
142
+ "UndoGroup",
143
+ "DazPose",
144
+ "DazAnimation",
145
+ "Vec3",
146
+ "Quat",
147
+ "BoundingBox",
148
+ "ExecutionResult",
149
+ "execute_long",
150
+ "FigureMorphs",
151
+ "RenderVariant",
152
+ "RenderBase",
153
+ "RenderResult",
154
+ "RenderError",
155
+ "render",
156
+ "render_variants",
157
+ "exceptions",
158
+ ]
dazpy/_animation.py ADDED
@@ -0,0 +1,402 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import TYPE_CHECKING
6
+
7
+ from ._script_builder import ScriptBuilder
8
+
9
+ if TYPE_CHECKING:
10
+ from ._skeleton import DazSkeleton
11
+ from ._pose import DazPose
12
+
13
+
14
+ class DazAnimation:
15
+ """A captured animation clip: bone rotations (and optionally morphs) for every
16
+ frame in the play range.
17
+
18
+ Created with :meth:`capture`; saved/loaded with :meth:`save` / :meth:`load`.
19
+
20
+ Bones are stored as a parallel-list encoding — a single ordered ``bones``
21
+ list of names, and per-frame ``rotations`` arrays aligned to that index —
22
+ so payload size grows with bones × frames rather than with a full dict per
23
+ frame.
24
+
25
+ Typical workflow::
26
+
27
+ from dazpy import DazScene, DazAnimation
28
+
29
+ scene = DazScene()
30
+ figure = scene.find_skeleton_by_label("Genesis 9")
31
+
32
+ anim = DazAnimation.capture(figure, include_morphs=True)
33
+ anim.save("walk.json")
34
+
35
+ JSON schema (same as the ``animation_frame_dump.py`` output)::
36
+
37
+ {
38
+ "figure": "Genesis 9",
39
+ "frame_range": {"start": 0, "end": 90},
40
+ "bones": ["hip", "rForeArm", ...],
41
+ "frames": [
42
+ {"frame": 0, "rotations": [[x,y,z], ...], "morphs": {"PHMSmile": 0.5}},
43
+ ...
44
+ ]
45
+ }
46
+
47
+ Args:
48
+ figure: The label of the figure.
49
+ frame_range: ``{"start": int, "end": int}`` in frames.
50
+ bones: Ordered list of bone names matching ``rotations`` columns.
51
+ frames: Per-frame data; each entry has ``"frame"``, ``"rotations"``,
52
+ and ``"morphs"`` keys.
53
+ """
54
+
55
+ def __init__(
56
+ self,
57
+ figure: str,
58
+ frame_range: dict,
59
+ bones: list[str],
60
+ frames: list[dict],
61
+ ) -> None:
62
+ self.figure = figure
63
+ self.frame_range = frame_range
64
+ self.bones = bones
65
+ self.frames = frames
66
+
67
+ # ── construction ──────────────────────────────────────────────────────────
68
+
69
+ @classmethod
70
+ def capture(cls, skeleton: "DazSkeleton", include_morphs: bool = False) -> "DazAnimation":
71
+ """Capture the full animation of *skeleton* in a single HTTP call.
72
+
73
+ Scrubs every frame in the scene's play range server-side (no Python
74
+ round-trip per frame). The timeline is restored to its original frame
75
+ before the call returns.
76
+
77
+ When *include_morphs* is ``True``, the script first identifies which
78
+ geometry morphs and node-level properties actually vary across the
79
+ timeline (channels that are keyed but static are excluded), then
80
+ records their values per frame.
81
+
82
+ Args:
83
+ skeleton: The figure to capture.
84
+ include_morphs: Also capture animated morph and property channels.
85
+
86
+ Returns:
87
+ A new :class:`DazAnimation`.
88
+
89
+ Raises:
90
+ ~dazpy.exceptions.NodeNotFoundError: If the skeleton is not found.
91
+ """
92
+ lookup = ScriptBuilder.skeleton_lookup(skeleton._identifier)
93
+ include_morphs_js = "true" if include_morphs else "false"
94
+
95
+ script = f"""(function(){{
96
+ {lookup}
97
+ if (!_skel) return null;
98
+
99
+ var _origFrame = Scene.getFrame();
100
+
101
+ var allBones = _skel.getAllBones();
102
+ var boneNames = [];
103
+ for (var i = 0; i < allBones.length; i++) boneNames.push(allBones[i].getName());
104
+
105
+ // Detect channels whose values actually vary across the timeline.
106
+ // Channels that are keyed but hold a constant value are excluded —
107
+ // they add payload without contributing animation data.
108
+ function isVaryingMorph(ch) {{
109
+ var n = ch.getNumKeys();
110
+ if (n < 2) return false;
111
+ var first = ch.getKeyValue(0);
112
+ for (var k = 1; k < n; k++) {{
113
+ if (Math.abs(ch.getKeyValue(k) - first) > 0.0001) return true;
114
+ }}
115
+ return false;
116
+ }}
117
+
118
+ var animatedChannels = [];
119
+ if ({include_morphs_js}) {{
120
+ var obj = _skel.getObject();
121
+ if (obj) {{
122
+ for (var i = 0; i < obj.getNumModifiers(); i++) {{
123
+ var m = obj.getModifier(i);
124
+ if (m.className() === "DzMorph" && isVaryingMorph(m.getValueChannel()))
125
+ animatedChannels.push([m.getName(), m.getValueChannel()]);
126
+ }}
127
+ }}
128
+ for (var i = 0; i < _skel.getNumProperties(); i++) {{
129
+ var p = _skel.getProperty(i);
130
+ if (p && p.getNumKeys && isVaryingMorph(p))
131
+ animatedChannels.push([p.getName(), p]);
132
+ }}
133
+ }}
134
+
135
+ var step = Scene.getTimeStep();
136
+ var range = Scene.getPlayRange();
137
+ var start = Math.round(range.start / step);
138
+ var end = Math.round(range.end / step);
139
+
140
+ var frames = [];
141
+ for (var f = start; f <= end; f++) {{
142
+ Scene.setFrame(f);
143
+
144
+ var rotations = [];
145
+ for (var i = 0; i < allBones.length; i++) {{
146
+ var b = allBones[i];
147
+ rotations.push([
148
+ b.getXRotControl().getValue(),
149
+ b.getYRotControl().getValue(),
150
+ b.getZRotControl().getValue()
151
+ ]);
152
+ }}
153
+
154
+ var morphs = {{}};
155
+ for (var i = 0; i < animatedChannels.length; i++) {{
156
+ var v = animatedChannels[i][1].getValue();
157
+ if (Math.abs(v) > 0.0001) morphs[animatedChannels[i][0]] = v;
158
+ }}
159
+
160
+ frames.push({{frame: f, rotations: rotations, morphs: morphs}});
161
+ }}
162
+
163
+ Scene.setFrame(_origFrame);
164
+
165
+ return {{
166
+ figure: _skel.getLabel(),
167
+ frame_range: {{start: start, end: end}},
168
+ bones: boneNames,
169
+ frames: frames
170
+ }};
171
+ }})()"""
172
+
173
+ result = skeleton._client.execute(script).value
174
+ if result is None:
175
+ from .exceptions import NodeNotFoundError
176
+ raise NodeNotFoundError(f"Skeleton not found: {skeleton._identifier.value!r}")
177
+
178
+ return cls(
179
+ figure=result["figure"],
180
+ frame_range=result["frame_range"],
181
+ bones=result["bones"],
182
+ frames=result["frames"],
183
+ )
184
+
185
+ @classmethod
186
+ def load(cls, path: str | Path) -> "DazAnimation":
187
+ """Load an animation from a JSON file.
188
+
189
+ Accepts files produced by :meth:`save` and by the original
190
+ ``animation_frame_dump.py`` example script.
191
+
192
+ Args:
193
+ path: Path to the JSON file.
194
+
195
+ Returns:
196
+ A new :class:`DazAnimation`.
197
+ """
198
+ with open(path, encoding="utf-8") as f:
199
+ data = json.load(f)
200
+ return cls(
201
+ figure=data.get("figure", ""),
202
+ frame_range=data.get("frame_range", {"start": 0, "end": 0}),
203
+ bones=data.get("bones", []),
204
+ frames=data.get("frames", []),
205
+ )
206
+
207
+ # ── serialisation ─────────────────────────────────────────────────────────
208
+
209
+ def save(self, path: str | Path) -> None:
210
+ """Write this animation to a JSON file.
211
+
212
+ Args:
213
+ path: Destination path. Parent directories must exist.
214
+ """
215
+ with open(path, "w", encoding="utf-8") as f:
216
+ json.dump(self.to_dict(), f, indent=2)
217
+
218
+ def to_dict(self) -> dict:
219
+ """Return the animation as a plain dict (same schema as the JSON file)."""
220
+ return {
221
+ "figure": self.figure,
222
+ "frame_range": self.frame_range,
223
+ "bones": self.bones,
224
+ "frames": self.frames,
225
+ }
226
+
227
+ # ── clip operations ───────────────────────────────────────────────────────
228
+
229
+ def clip(self, start: int, end: int) -> "DazAnimation":
230
+ """Return a new animation containing only frames in [start, end].
231
+
232
+ *start* and *end* are scene frame numbers (the ``"frame"`` value stored
233
+ in each frame, not Python list indices). Both endpoints are inclusive.
234
+
235
+ Args:
236
+ start: First scene frame to include.
237
+ end: Last scene frame to include.
238
+
239
+ Returns:
240
+ A new :class:`DazAnimation` with the same bones but a subset of frames.
241
+ """
242
+ frames = [f for f in self.frames if start <= f["frame"] <= end]
243
+ new_start = frames[0]["frame"] if frames else start
244
+ new_end = frames[-1]["frame"] if frames else end
245
+ return DazAnimation(
246
+ figure=self.figure,
247
+ frame_range={"start": new_start, "end": new_end},
248
+ bones=self.bones,
249
+ frames=frames,
250
+ )
251
+
252
+ def blend(self, other: "DazAnimation", t: float) -> "DazAnimation":
253
+ """Blend this animation with *other* frame by frame.
254
+
255
+ Each frame's bone rotations and morph values are linearly interpolated
256
+ between *self* (``t=0``) and *other* (``t=1``). This is a pure-Python
257
+ operation — no HTTP round-trip.
258
+
259
+ If the two clips have different frame counts, the result is truncated to
260
+ the shorter clip. Frame numbers are taken from *self*.
261
+
262
+ Args:
263
+ other: The target animation.
264
+ t: Blend factor — 0.0 = self, 1.0 = other.
265
+
266
+ Returns:
267
+ A new :class:`DazAnimation` at the interpolated position.
268
+
269
+ Raises:
270
+ ValueError: If *self* and *other* have different bone lists.
271
+ """
272
+ if self.bones != other.bones:
273
+ raise ValueError(
274
+ f"Cannot blend animations with different bone lists "
275
+ f"({len(self.bones)} vs {len(other.bones)} bones)"
276
+ )
277
+ s = 1.0 - t
278
+ frames = []
279
+ for fa, fb in zip(self.frames, other.frames):
280
+ rotations = [
281
+ [fa["rotations"][i][j] * s + fb["rotations"][i][j] * t for j in range(3)]
282
+ for i in range(len(self.bones))
283
+ ]
284
+ all_keys = set(fa["morphs"]) | set(fb["morphs"])
285
+ morphs = {}
286
+ for k in all_keys:
287
+ v = fa["morphs"].get(k, 0.0) * s + fb["morphs"].get(k, 0.0) * t
288
+ if abs(v) > 1e-9:
289
+ morphs[k] = v
290
+ frames.append({"frame": fa["frame"], "rotations": rotations, "morphs": morphs})
291
+ new_end = frames[-1]["frame"] if frames else self.frame_range["start"]
292
+ return DazAnimation(
293
+ figure=self.figure,
294
+ frame_range={"start": self.frame_range["start"], "end": new_end},
295
+ bones=self.bones,
296
+ frames=frames,
297
+ )
298
+
299
+ def as_pose(self, frame_index: int = 0) -> "DazPose":
300
+ """Extract a single frame as a :class:`~dazpy.DazPose`.
301
+
302
+ Bone rotations are stored sparsely — only bones with at least one
303
+ non-zero component are included. Morph values are copied as-is.
304
+
305
+ This is a pure-Python operation — no HTTP round-trip.
306
+
307
+ Args:
308
+ frame_index: 0-based index into :attr:`frames`.
309
+
310
+ Returns:
311
+ A new :class:`~dazpy.DazPose`.
312
+ """
313
+ from ._pose import DazPose
314
+ frame = self.frames[frame_index]
315
+ bones = {
316
+ name: list(rot)
317
+ for name, rot in zip(self.bones, frame["rotations"])
318
+ if any(abs(v) > 1e-9 for v in rot)
319
+ }
320
+ return DazPose(
321
+ figure=self.figure,
322
+ bones=bones,
323
+ morphs=dict(frame["morphs"]),
324
+ props={},
325
+ )
326
+
327
+ def apply(self, skeleton: "DazSkeleton", frame_index: int = 0) -> None:
328
+ """Apply a single frame of this animation to *skeleton*.
329
+
330
+ Equivalent to ``anim.as_pose(frame_index).apply(skeleton)`` — one HTTP
331
+ call that sets only the channels present in the frame.
332
+
333
+ Args:
334
+ skeleton: The figure to pose.
335
+ frame_index: 0-based index into :attr:`frames`. Defaults to 0.
336
+ """
337
+ self.as_pose(frame_index).apply(skeleton)
338
+
339
+ def append(self, other: "DazAnimation") -> "DazAnimation":
340
+ """Concatenate *other* immediately after this animation.
341
+
342
+ The other clip's frame numbers are shifted so it starts on the frame
343
+ following this animation's last frame. The figure label and bone list
344
+ are taken from *self*.
345
+
346
+ This is a pure-Python operation — no HTTP round-trip.
347
+
348
+ Args:
349
+ other: The animation to append.
350
+
351
+ Returns:
352
+ A new :class:`DazAnimation` containing all frames from both clips.
353
+
354
+ Raises:
355
+ ValueError: If *self* and *other* have different bone lists.
356
+ """
357
+ if self.bones != other.bones:
358
+ raise ValueError(
359
+ f"Cannot append animations with different bone lists "
360
+ f"({len(self.bones)} vs {len(other.bones)} bones)"
361
+ )
362
+ if not self.frames:
363
+ return DazAnimation(self.figure, dict(other.frame_range), list(other.bones), list(other.frames))
364
+ offset = self.frame_range["end"] + 1 - other.frame_range["start"]
365
+ other_frames = [
366
+ {"frame": f["frame"] + offset, "rotations": f["rotations"], "morphs": f["morphs"]}
367
+ for f in other.frames
368
+ ]
369
+ frames = list(self.frames) + other_frames
370
+ return DazAnimation(
371
+ figure=self.figure,
372
+ frame_range={"start": self.frame_range["start"], "end": frames[-1]["frame"]},
373
+ bones=self.bones,
374
+ frames=frames,
375
+ )
376
+
377
+ # ── convenience ───────────────────────────────────────────────────────────
378
+
379
+ @property
380
+ def frame_count(self) -> int:
381
+ """Number of captured frames."""
382
+ return len(self.frames)
383
+
384
+ @property
385
+ def bone_count(self) -> int:
386
+ """Number of bones in the skeleton at capture time."""
387
+ return len(self.bones)
388
+
389
+ def __len__(self) -> int:
390
+ return len(self.frames)
391
+
392
+ def __getitem__(self, index: int) -> dict:
393
+ """Return the frame dict at *index* (0-based Python index)."""
394
+ return self.frames[index]
395
+
396
+ def __repr__(self) -> str:
397
+ fr = self.frame_range
398
+ return (
399
+ f"DazAnimation(figure={self.figure!r}, "
400
+ f"frames={self.frame_count}, bones={self.bone_count}, "
401
+ f"range={fr.get('start')}–{fr.get('end')})"
402
+ )
dazpy/_batch.py ADDED
@@ -0,0 +1,107 @@
1
+ from __future__ import annotations
2
+
3
+ from ._client import DazClient
4
+
5
+
6
+ class BatchFuture:
7
+ """Placeholder for a single result within a :class:`Batch` execution.
8
+
9
+ Created by :meth:`Batch.add`; the :attr:`value` property blocks until the
10
+ batch has been executed.
11
+ """
12
+
13
+ def __init__(self, key: str):
14
+ self._key = key
15
+ self._resolved = False
16
+ self._value = None
17
+
18
+ @property
19
+ def value(self) -> object:
20
+ """The result value.
21
+
22
+ Raises:
23
+ RuntimeError: If :meth:`Batch.execute` has not been called yet.
24
+ """
25
+ if not self._resolved:
26
+ raise RuntimeError("Batch has not been executed yet")
27
+ return self._value
28
+
29
+ def _resolve(self, value: object) -> None:
30
+ self._value = value
31
+ self._resolved = True
32
+
33
+
34
+ class Batch:
35
+ """Collect multiple DazScript operations and execute them in a single HTTP round-trip.
36
+
37
+ Usage as a context manager (recommended)::
38
+
39
+ with Batch(client) as b:
40
+ pos_future = b.add(["var pos = Scene.findNode('Figure').getWSPos();",
41
+ "var pos = [pos.x, pos.y, pos.z];"])
42
+ name_future = b.add(["var name = Scene.findNode('Figure').getName();"])
43
+ # Both futures resolved after the `with` block
44
+ print(pos_future.value, name_future.value)
45
+
46
+ Or manually::
47
+
48
+ b = Batch(client)
49
+ f = b.add(["var x = 42;"])
50
+ b.execute()
51
+ print(f.value)
52
+
53
+ Args:
54
+ client: The :class:`~dazpy.DazClient` to use.
55
+ """
56
+
57
+ def __init__(self, client: DazClient):
58
+ self._client = client
59
+ self._ops: list[tuple[str, list[str], BatchFuture]] = []
60
+ self._counter = 0
61
+
62
+ def add(self, lines: list[str]) -> BatchFuture:
63
+ """Queue a list of DazScript lines to be included in the batch.
64
+
65
+ The last line in *lines* should assign the desired result to a variable
66
+ named after the key that will be referenced internally.
67
+
68
+ Args:
69
+ lines: DazScript source lines (no ``return`` needed).
70
+
71
+ Returns:
72
+ A :class:`BatchFuture` that resolves after :meth:`execute`.
73
+ """
74
+ key = f"_r{self._counter}"
75
+ self._counter += 1
76
+ future = BatchFuture(key)
77
+ self._ops.append((key, lines, future))
78
+ return future
79
+
80
+ def _build_script(self) -> str:
81
+ body_lines = []
82
+ return_parts = []
83
+ for key, lines, _ in self._ops:
84
+ body_lines.extend(lines)
85
+ return_parts.append(f'"{key}": {key}')
86
+ return_obj = "{" + ", ".join(return_parts) + "}"
87
+ body_lines.append(f"return {return_obj};")
88
+ body = "\n".join(body_lines)
89
+ return f"(function(){{\n{body}\n}})()"
90
+
91
+ def execute(self) -> None:
92
+ """Execute all queued operations in a single HTTP request and resolve all futures."""
93
+ if not self._ops:
94
+ return
95
+ script = self._build_script()
96
+ result = self._client.execute(script)
97
+ data = result.value or {}
98
+ for key, _, future in self._ops:
99
+ future._resolve(data.get(key))
100
+
101
+ def __enter__(self) -> "Batch":
102
+ return self
103
+
104
+ def __exit__(self, exc_type, exc_val, exc_tb) -> bool:
105
+ if exc_type is None:
106
+ self.execute()
107
+ return False