pulze-scenemanager 3.0.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.
@@ -0,0 +1,3 @@
1
+ dist/
2
+ __pycache__/
3
+ *.egg-info/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sourceline / Pulze
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.
@@ -0,0 +1,51 @@
1
+ Metadata-Version: 2.5
2
+ Name: pulze-scenemanager
3
+ Version: 3.0.0
4
+ Summary: Official Scene Manager API client for Python
5
+ Project-URL: Homepage, https://pulze.io/products/scene-manager
6
+ Author-email: Pulze <support@pulze.io>
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Keywords: 3dsmax,corona,pulze,render-setups,scene-manager,vray
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+
21
+ # pulze-scenemanager
22
+
23
+ The official Python client for [Scene Manager](https://pulze.io/products/scene-manager)'s local API: setups, folders, modules, batch renders and scene comments in 3ds Max.
24
+
25
+ ```bash
26
+ pip install pulze-scenemanager
27
+ ```
28
+
29
+ ```python
30
+ import scenemanager as sm
31
+
32
+ scene = sm.connect() # Scene Manager on this computer
33
+
34
+ for setup in scene.setups.list_setups().setups:
35
+ print(setup.name)
36
+
37
+ first = scene.setups.list_setups().setups[0]
38
+ scene.setups.activate_setup(first.id)
39
+ scene.render.start_render(body={"setups": [first.id]})
40
+ batch = scene.render.wait() # until the batch render is over
41
+ ```
42
+
43
+ - **Standard library only.** No dependencies, so it also imports inside 3ds Max's own Python.
44
+ - **Scene Manager must be running** on this computer (open it from 3ds Max). It answers on `http://127.0.0.1:45710`; set `SCENEMANAGER_URL` or `SCENEMANAGER_PORT`, or pass a URL to `connect()`, to change that.
45
+ - **A Scene Manager licence** on this computer is needed for every call but `scene.health.get_health()`, which says whether there is one. Without it, a call raises `ApiError` with status `402`.
46
+ - **Methods are the API's operations**, grouped by the route's first segment (`scene.setups`, `scene.scene`, `scene.comments`, `scene.render`, ...) and named as in the API reference, the TypeScript client and `smcli`, in snake_case. Path parameters are positional, query parameters are named, and a request body is `body=`.
47
+ - **Answers read with dots** (`setup.name`); `to_dict()` gives the raw payload. Pictures (thumbnails, shots) are `bytes`. A refused call raises `ApiError` with `status` and Scene Manager's reason.
48
+
49
+ `models.pyi` types every payload for editors and type checkers.
50
+
51
+ MIT licensed. The Scene Manager application itself is licensed separately by Pulze.
@@ -0,0 +1,31 @@
1
+ # pulze-scenemanager
2
+
3
+ The official Python client for [Scene Manager](https://pulze.io/products/scene-manager)'s local API: setups, folders, modules, batch renders and scene comments in 3ds Max.
4
+
5
+ ```bash
6
+ pip install pulze-scenemanager
7
+ ```
8
+
9
+ ```python
10
+ import scenemanager as sm
11
+
12
+ scene = sm.connect() # Scene Manager on this computer
13
+
14
+ for setup in scene.setups.list_setups().setups:
15
+ print(setup.name)
16
+
17
+ first = scene.setups.list_setups().setups[0]
18
+ scene.setups.activate_setup(first.id)
19
+ scene.render.start_render(body={"setups": [first.id]})
20
+ batch = scene.render.wait() # until the batch render is over
21
+ ```
22
+
23
+ - **Standard library only.** No dependencies, so it also imports inside 3ds Max's own Python.
24
+ - **Scene Manager must be running** on this computer (open it from 3ds Max). It answers on `http://127.0.0.1:45710`; set `SCENEMANAGER_URL` or `SCENEMANAGER_PORT`, or pass a URL to `connect()`, to change that.
25
+ - **A Scene Manager licence** on this computer is needed for every call but `scene.health.get_health()`, which says whether there is one. Without it, a call raises `ApiError` with status `402`.
26
+ - **Methods are the API's operations**, grouped by the route's first segment (`scene.setups`, `scene.scene`, `scene.comments`, `scene.render`, ...) and named as in the API reference, the TypeScript client and `smcli`, in snake_case. Path parameters are positional, query parameters are named, and a request body is `body=`.
27
+ - **Answers read with dots** (`setup.name`); `to_dict()` gives the raw payload. Pictures (thumbnails, shots) are `bytes`. A refused call raises `ApiError` with `status` and Scene Manager's reason.
28
+
29
+ `models.pyi` types every payload for editors and type checkers.
30
+
31
+ MIT licensed. The Scene Manager application itself is licensed separately by Pulze.
@@ -0,0 +1,37 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "pulze-scenemanager"
7
+ version = "3.0.0"
8
+ description = "Official Scene Manager API client for Python"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ authors = [
13
+ { name = "Pulze", email = "support@pulze.io" }
14
+ ]
15
+ keywords = ["3dsmax", "corona", "vray", "scene-manager", "render-setups", "pulze"]
16
+ classifiers = [
17
+ "Development Status :: 4 - Beta",
18
+ "Intended Audience :: Developers",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ ]
26
+ # None, deliberately (as RenderFlow's SDK): the client binds to the OpenAPI document shipped inside
27
+ # the package (`scenemanager/wire.py` + `scenemanager/openapi.json`), so the whole SDK is standard
28
+ # library and imports inside 3ds Max's Python with nothing installed. The typed models are
29
+ # `models.pyi`, which only editors read; datamodel-code-generator is a dev-only tool of
30
+ # `scripts/generate.py`, never a requirement of the package.
31
+ dependencies = []
32
+
33
+ [project.urls]
34
+ Homepage = "https://pulze.io/products/scene-manager"
35
+
36
+ [tool.hatch.build.targets.wheel]
37
+ packages = ["scenemanager"]
@@ -0,0 +1,23 @@
1
+ """Scene Manager's Python client: setups, modules, renders and comments over its local REST API.
2
+
3
+ import scenemanager as sm
4
+
5
+ scene = sm.connect()
6
+ for setup in scene.setups.list_setups().setups:
7
+ print(setup.name)
8
+
9
+ Standard library only, so it imports inside 3ds Max's own Python with nothing installed. Scene
10
+ Manager must be running on this computer, with a licence.
11
+ """
12
+
13
+ from importlib.metadata import PackageNotFoundError, version as _version
14
+
15
+ from .client import Group, RenderGroup, SceneManager, connect, default_url
16
+ from .wire import ApiError, Data
17
+
18
+ try:
19
+ __version__ = _version("pulze-scenemanager")
20
+ except PackageNotFoundError: # from a checkout
21
+ __version__ = "0.0.0"
22
+
23
+ __all__ = ["ApiError", "Data", "Group", "RenderGroup", "SceneManager", "connect", "default_url", "__version__"]
@@ -0,0 +1,138 @@
1
+ """The Scene Manager API client.
2
+
3
+ import scenemanager as sm
4
+
5
+ scene = sm.connect() # Scene Manager on this computer
6
+ for setup in scene.setups.list_setups().setups:
7
+ print(setup.name)
8
+ scene.setups.activate_setup(setup.id)
9
+ scene.render.start_render(body={"setups": [setup.id]})
10
+ scene.render.wait() # until the batch render is over
11
+
12
+ Operations are grouped by the route's first segment and named after the document's
13
+ ``operationId``: the same names the REST route, the TypeScript SDK and ``smcli`` use, so one
14
+ operation reads the same everywhere. ``scene.setups.list_setups`` is ``GET /setups``;
15
+ ``scene.setups.activate_setup(id)`` is ``POST /setups/{id}/activate``.
16
+
17
+ Nothing here is written per operation, and nothing is generated: the methods are the operations of
18
+ the OpenAPI document shipped inside this package (``scenemanager/openapi.json``), bound on first
19
+ use. Bodies are plain dicts; answers read with dot access (``setup.name``) and hand back the raw
20
+ payload via ``to_dict()``. The typed view of it all is ``models.pyi``, which the editor reads and
21
+ the interpreter never pays for: the whole client is standard library, so it runs inside 3ds Max's
22
+ own Python.
23
+
24
+ Scene Manager needs a licence on this computer: without one every call but
25
+ ``scene.health.get_health()`` raises ``ApiError`` with status 402.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import os
31
+ import time
32
+ from typing import Any, Callable
33
+
34
+ from . import wire
35
+ from .wire import ApiError, Client
36
+
37
+ DEFAULT_PORT = 45710
38
+
39
+
40
+ def default_url() -> str:
41
+ """Where Scene Manager answers on this computer: ``SCENEMANAGER_URL``, else ``SCENEMANAGER_PORT`` (45710)."""
42
+ url = os.environ.get("SCENEMANAGER_URL", "").strip()
43
+ if url:
44
+ return url
45
+ return "http://127.0.0.1:{0}".format(os.environ.get("SCENEMANAGER_PORT", "").strip() or DEFAULT_PORT)
46
+
47
+
48
+ class Group:
49
+ """One group's operations, bound to Scene Manager: ``scene.setups``, ``scene.comments``."""
50
+
51
+ def __init__(self, owner: "SceneManager", name: str) -> None:
52
+ self._owner = owner
53
+ self._name = name
54
+ self._operations = wire.operations()[name]
55
+
56
+ def __dir__(self) -> list[str]:
57
+ return sorted(self._operations)
58
+
59
+ def __getattr__(self, name: str) -> Callable[..., Any]:
60
+ try:
61
+ operation = self._operations[name]
62
+ except KeyError:
63
+ raise AttributeError(f"`{self._name}` has no operation `{name}`. Available: {', '.join(self.__dir__())}") from None
64
+ client = self._owner._client
65
+
66
+ def call(*args: Any, **kwargs: Any) -> Any:
67
+ return client.call(operation, args, kwargs)
68
+
69
+ call.__name__ = name
70
+ call.__doc__ = operation.doc
71
+ setattr(self, name, call) # resolved once; afterwards a plain attribute
72
+ return call
73
+
74
+ def __repr__(self) -> str:
75
+ return f"<scenemanager group {self._name}: {len(self._operations)} operations>"
76
+
77
+
78
+ #: How a batch render ends.
79
+ FINISHED = ("done", "stopped")
80
+
81
+
82
+ class RenderGroup(Group):
83
+ """``render``, plus the one wait that is not an operation of the document."""
84
+
85
+ def __dir__(self) -> list[str]:
86
+ return sorted({*super().__dir__(), "wait"})
87
+
88
+ def wait(self, *, timeout: float | None = None, interval: float = 2.0) -> Any:
89
+ """Waits for the batch render to end (done or stopped). Answers the batch, or ``None`` when
90
+ there is none. Polls ``get_render``; ``timeout`` is seconds (none by default: a batch can
91
+ render for hours), and running out raises ``TimeoutError``."""
92
+ deadline = None if timeout is None else time.monotonic() + timeout
93
+ while True:
94
+ batch = self.get_render().batch
95
+ if batch is None or batch.state in FINISHED:
96
+ return batch
97
+ if deadline is not None and time.monotonic() >= deadline:
98
+ raise TimeoutError(f"the batch render had not ended after {timeout}s")
99
+ time.sleep(interval if deadline is None else min(interval, max(0.0, deadline - time.monotonic())))
100
+
101
+
102
+ GROUPS = {"render": RenderGroup}
103
+
104
+
105
+ class SceneManager:
106
+ """A connection to Scene Manager on this computer. Prefer ``scenemanager.connect()``."""
107
+
108
+ def __init__(self, url: str | None = None, *, timeout: float = 120.0) -> None:
109
+ self.url = (url or default_url()).rstrip("/")
110
+ base_url = self.url if self.url.endswith("/api/v1") else f"{self.url}/api/v1"
111
+ self._client = Client(base_url=base_url, timeout=timeout)
112
+ self._groups: dict[str, Group] = {}
113
+
114
+ def __dir__(self) -> list[str]:
115
+ return sorted({*super().__dir__(), *self.groups()})
116
+
117
+ def groups(self) -> list[str]:
118
+ return sorted(wire.operations())
119
+
120
+ def __getattr__(self, name: str) -> Group:
121
+ if name.startswith("_"):
122
+ raise AttributeError(name)
123
+ if name not in self._groups:
124
+ if name not in self.groups():
125
+ raise AttributeError(f"Scene Manager has no `{name}`. Available: {', '.join(self.groups())}")
126
+ self._groups[name] = GROUPS.get(name, Group)(self, name)
127
+ return self._groups[name]
128
+
129
+ def __repr__(self) -> str:
130
+ return f"<SceneManager {self.url}>"
131
+
132
+
133
+ def connect(url: str | None = None, *, timeout: float = 120.0) -> SceneManager:
134
+ """Scene Manager on this computer (``SCENEMANAGER_URL`` or ``SCENEMANAGER_PORT`` to change)."""
135
+ return SceneManager(url, timeout=timeout)
136
+
137
+
138
+ __all__ = ["ApiError", "Group", "RenderGroup", "SceneManager", "connect", "default_url"]
@@ -0,0 +1,331 @@
1
+ # generated by datamodel-codegen:
2
+ # filename: openapi.json
3
+
4
+ from __future__ import annotations
5
+
6
+ from typing import Any, Literal, NotRequired, TypeAlias, TypedDict
7
+
8
+
9
+ class Busy(TypedDict):
10
+ what: str
11
+ label: str
12
+ since: str
13
+
14
+
15
+ class Health(TypedDict):
16
+ ok: bool
17
+ version: str
18
+ build: NotRequired[str]
19
+ connected: bool
20
+ licensed: bool
21
+ pid: NotRequired[float]
22
+ person: str
23
+ names: NotRequired[float]
24
+ busy: NotRequired[Busy]
25
+ generation: NotRequired[float]
26
+ helper: str
27
+
28
+
29
+ class Error(TypedDict):
30
+ error: str
31
+
32
+
33
+ SceneDocument: TypeAlias = dict[str, Any]
34
+
35
+
36
+ class SkippedItem(TypedDict):
37
+ what: str
38
+ reason: str
39
+ setups: list[str]
40
+
41
+
42
+ class ImportReport(TypedDict):
43
+ setups: float
44
+ modules: list[str]
45
+ skipped: list[SkippedItem]
46
+ at: NotRequired[str]
47
+
48
+
49
+ class RefusedItem(TypedDict):
50
+ key: str
51
+ reason: str
52
+
53
+
54
+ class Source(TypedDict):
55
+ kind: Literal["setup", "folder", "quality"]
56
+ id: str
57
+
58
+
59
+ class Change(TypedDict):
60
+ target: str
61
+ param: str
62
+ value: NotRequired[Any]
63
+ module: str
64
+ source: Source
65
+
66
+
67
+ class SkippedItem1(TypedDict):
68
+ name: str
69
+ reason: str
70
+
71
+
72
+ class Result(TypedDict):
73
+ target: str
74
+ applied: list[str]
75
+ skipped: list[SkippedItem1]
76
+
77
+
78
+ class ActivationStep(TypedDict):
79
+ id: str
80
+ label: str
81
+ state: Literal["queued", "running", "done", "skipped", "failed"]
82
+ ms: NotRequired[float]
83
+ error: NotRequired[str]
84
+
85
+
86
+ class ActivationProgress(TypedDict):
87
+ setup: str
88
+ name: str
89
+ started: str
90
+ steps: list[ActivationStep]
91
+ finished: NotRequired[str]
92
+ ms: NotRequired[float]
93
+
94
+
95
+ class SetupSummary(TypedDict):
96
+ id: str
97
+ name: str
98
+ folder: NotRequired[str]
99
+ description: NotRequired[str]
100
+ tags: NotRequired[list[str]]
101
+ color: NotRequired[str]
102
+ modulesOff: list[str]
103
+
104
+
105
+ class FolderSummary(TypedDict):
106
+ id: str
107
+ name: str
108
+ parent: NotRequired[str]
109
+ color: NotRequired[str]
110
+
111
+
112
+ class Item(TypedDict):
113
+ setup: str
114
+ set: NotRequired[str]
115
+ row: NotRequired[list[float]]
116
+ name: str
117
+ status: Literal["queued", "applying", "rendering", "done", "failed", "stopped"]
118
+ error: NotRequired[str]
119
+ skipped: NotRequired[float]
120
+ output: NotRequired[str]
121
+ capped: NotRequired[str]
122
+ started: NotRequired[str]
123
+ finished: NotRequired[str]
124
+
125
+
126
+ class BatchRender(TypedDict):
127
+ id: str
128
+ state: Literal["running", "done", "stopped"]
129
+ agent: NotRequired[bool]
130
+ started: str
131
+ finished: NotRequired[str]
132
+ items: list[Item]
133
+
134
+
135
+ class Link(TypedDict):
136
+ target: str
137
+ name: NotRequired[str]
138
+ detail: NotRequired[str]
139
+
140
+
141
+ class Author(TypedDict):
142
+ name: str
143
+ kind: Literal["person", "agent"]
144
+ via: NotRequired[str]
145
+ picture: NotRequired[str]
146
+
147
+
148
+ ShotPoint: TypeAlias = list[Any]
149
+
150
+
151
+ ShotArea: TypeAlias = list[Any]
152
+
153
+
154
+ class Mark1(TypedDict):
155
+ kind: Literal["pen"]
156
+ color: str
157
+ points: list[float]
158
+
159
+
160
+ Mark2 = TypedDict(
161
+ "Mark2",
162
+ {
163
+ "kind": Literal["line", "arrow", "rect", "ellipse"],
164
+ "color": str,
165
+ "from": ShotPoint,
166
+ "to": ShotPoint,
167
+ },
168
+ )
169
+
170
+
171
+ class Mark3(TypedDict):
172
+ kind: Literal["text"]
173
+ color: str
174
+ at: ShotPoint
175
+ text: str
176
+ size: NotRequired[Literal["small", "normal", "medium", "large"]]
177
+
178
+
179
+ Mark: TypeAlias = Mark1 | Mark2 | Mark3
180
+
181
+
182
+ class MadeChange(TypedDict):
183
+ target: str
184
+ name: NotRequired[str]
185
+ param: str
186
+ before: NotRequired[Any]
187
+ after: NotRequired[Any]
188
+ setup: NotRequired[str]
189
+
190
+
191
+ class SavedView(TypedDict):
192
+ type: str
193
+ camera: NotRequired[str]
194
+ cameraName: NotRequired[str]
195
+ matrix: NotRequired[list[float]]
196
+ fov: NotRequired[float]
197
+ focus: NotRequired[float]
198
+ target: NotRequired[list[Any]]
199
+ size: NotRequired[list[Any]]
200
+
201
+
202
+ class SetupThumbnail(TypedDict):
203
+ source: Literal["viewport", "framebuffer", "file"]
204
+ width: float
205
+ height: float
206
+ mime: str
207
+ at: str
208
+
209
+
210
+ class PreviewJob(TypedDict):
211
+ set: str
212
+ state: Literal["running", "done", "stopped", "failed"]
213
+ folder: str
214
+ done: float
215
+ total: float
216
+ error: NotRequired[str]
217
+ started: str
218
+ finished: NotRequired[str]
219
+
220
+
221
+ class ResolutionPreset(TypedDict):
222
+ name: str
223
+ width: float
224
+ height: float
225
+
226
+
227
+ class Switching(TypedDict):
228
+ viewport: Literal["active", "views", "render"]
229
+ redraw: bool
230
+ coronaInteractive: bool
231
+ vrayIpr: bool
232
+ notify: bool
233
+
234
+
235
+ class Settings(TypedDict):
236
+ hdriFolders: list[str]
237
+ resolutionPresets: list[ResolutionPreset]
238
+ team: str | None
239
+ switching: Switching
240
+
241
+
242
+ class HdriMap(TypedDict):
243
+ path: str
244
+ name: str
245
+ folder: str
246
+ size: float
247
+ modified: float
248
+
249
+
250
+ class DocumentState(TypedDict):
251
+ readOnly: bool
252
+ document: SceneDocument
253
+ imported: NotRequired[ImportReport]
254
+ generation: NotRequired[float]
255
+ revision: NotRequired[float]
256
+ unreadable: NotRequired[str]
257
+
258
+
259
+ class ChangeList(TypedDict):
260
+ setup: str
261
+ changes: list[Change]
262
+ unset: list[str]
263
+ unknownTokens: list[str]
264
+ refused: list[RefusedItem]
265
+
266
+
267
+ class Activation(TypedDict):
268
+ setup: str
269
+ results: list[Result]
270
+ unset: list[str]
271
+ unknownTokens: list[str]
272
+ refused: list[RefusedItem]
273
+ steps: NotRequired[list[ActivationStep]]
274
+ ms: NotRequired[float]
275
+
276
+
277
+ class Resolved(TypedDict):
278
+ by: Author
279
+ at: str
280
+
281
+
282
+ class Taken(TypedDict):
283
+ by: Author
284
+ at: str
285
+
286
+
287
+ class Done(TypedDict):
288
+ by: Author
289
+ at: str
290
+
291
+
292
+ class Reply(TypedDict):
293
+ id: str
294
+ author: Author
295
+ text: str
296
+ at: str
297
+ changes: NotRequired[list[MadeChange]]
298
+
299
+
300
+ class Shot(TypedDict):
301
+ id: str
302
+ setup: NotRequired[str]
303
+ source: Literal["viewport", "framebuffer", "file"]
304
+ name: str
305
+ width: float
306
+ height: float
307
+ mime: str
308
+ view: NotRequired[SavedView]
309
+ marks: NotRequired[list[Mark]]
310
+ author: Author
311
+ at: str
312
+
313
+
314
+ class Comment(TypedDict):
315
+ id: str
316
+ n: float
317
+ author: Author
318
+ text: str
319
+ at: str
320
+ status: Literal["open", "resolved"]
321
+ resolved: NotRequired[Resolved]
322
+ shot: NotRequired[str]
323
+ setup: NotRequired[str]
324
+ pin: NotRequired[ShotPoint]
325
+ area: NotRequired[ShotArea]
326
+ marks: list[Mark]
327
+ links: list[Link]
328
+ assignee: NotRequired[str]
329
+ taken: NotRequired[Taken]
330
+ done: NotRequired[Done]
331
+ replies: list[Reply]