trame-dataclass 1.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,26 @@
1
+ .DS_Store
2
+ node_modules
3
+ .venv
4
+
5
+ # local env files
6
+ .env.local
7
+ .env.*.local
8
+
9
+ # Log files
10
+ npm-debug.log*
11
+ yarn-debug.log*
12
+ yarn-error.log*
13
+ pnpm-debug.log*
14
+
15
+ # Editor directories and files
16
+ .idea
17
+ .vscode
18
+ *.suo
19
+ *.ntvs*
20
+ *.njsproj
21
+ *.sln
22
+ *.sw?
23
+
24
+ __pycache__
25
+ *egg-info
26
+ *pyc
@@ -0,0 +1,15 @@
1
+ Apache Software License 2.0
2
+
3
+ Copyright (c) 2025, Kitware Inc.
4
+
5
+ Licensed under the Apache License, Version 2.0 (the "License");
6
+ you may not use this file except in compliance with the License.
7
+ You may obtain a copy of the License at
8
+
9
+ http://www.apache.org/licenses/LICENSE-2.0
10
+
11
+ Unless required by applicable law or agreed to in writing, software
12
+ distributed under the License is distributed on an "AS IS" BASIS,
13
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ See the License for the specific language governing permissions and
15
+ limitations under the License.
@@ -0,0 +1,100 @@
1
+ Metadata-Version: 2.4
2
+ Name: trame-dataclass
3
+ Version: 1.0.0
4
+ Summary: Dataclass for trame UI binding
5
+ Author: Kitware Inc.
6
+ License: Apache Software License
7
+ License-File: LICENSE
8
+ Keywords: Application,Framework,Interactive,Python,Web
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Environment :: Web Environment
11
+ Classifier: License :: OSI Approved :: Apache Software License
12
+ Classifier: Natural Language :: English
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
16
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
17
+ Requires-Python: >=3.9
18
+ Requires-Dist: trame-client>=3.10
19
+ Provides-Extra: app
20
+ Requires-Dist: pywebview; extra == 'app'
21
+ Provides-Extra: dev
22
+ Requires-Dist: nox; extra == 'dev'
23
+ Requires-Dist: pre-commit; extra == 'dev'
24
+ Requires-Dist: pytest-asyncio; extra == 'dev'
25
+ Requires-Dist: pytest-cov>=3; extra == 'dev'
26
+ Requires-Dist: pytest>=6; extra == 'dev'
27
+ Requires-Dist: ruff; extra == 'dev'
28
+ Provides-Extra: jupyter
29
+ Requires-Dist: jupyterlab; extra == 'jupyter'
30
+ Description-Content-Type: text/x-rst
31
+
32
+ trame-dataclass
33
+ ----------------------------------------
34
+
35
+ Dataclass for trame UI binding
36
+
37
+ License
38
+ ----------------------------------------
39
+
40
+ This library is OpenSource and follow the Apache Software License
41
+
42
+ Installation
43
+ ----------------------------------------
44
+
45
+ Install the application/library
46
+
47
+ .. code-block:: console
48
+
49
+ pip install trame-dataclass
50
+
51
+
52
+ Development setup
53
+ ----------------------------------------
54
+
55
+ We recommend using uv for setting up and managing a virtual environment for your development.
56
+
57
+ .. code-block:: console
58
+
59
+ # Create venv and install all dependencies
60
+ uv sync --all-extras --dev
61
+
62
+ # Activate environment
63
+ source .venv/bin/activate
64
+
65
+ # Install commit analysis
66
+ pre-commit install
67
+ pre-commit install --hook-type commit-msg
68
+
69
+ # Allow live code edit
70
+ uv pip install -e .
71
+
72
+
73
+ Build and install the Vue components
74
+
75
+ .. code-block:: console
76
+
77
+ cd vue-components
78
+ npm i
79
+ npm run build
80
+ cd -
81
+
82
+ For running tests and checks, you can run ``nox``.
83
+
84
+ .. code-block:: console
85
+
86
+ # run all
87
+ nox
88
+
89
+ # lint
90
+ nox -s lint
91
+
92
+ # tests
93
+ nox -s tests
94
+
95
+ Professional Support
96
+ ----------------------------------------
97
+
98
+ * `Training <https://www.kitware.com/courses/trame/>`_: Learn how to confidently use trame from the expert developers at Kitware.
99
+ * `Support <https://www.kitware.com/trame/support/>`_: Our experts can assist your team as you build your web application and establish in-house expertise.
100
+ * `Custom Development <https://www.kitware.com/trame/support/>`_: Leverage Kitware’s 25+ years of experience to quickly build your web application.
@@ -0,0 +1,69 @@
1
+ trame-dataclass
2
+ ----------------------------------------
3
+
4
+ Dataclass for trame UI binding
5
+
6
+ License
7
+ ----------------------------------------
8
+
9
+ This library is OpenSource and follow the Apache Software License
10
+
11
+ Installation
12
+ ----------------------------------------
13
+
14
+ Install the application/library
15
+
16
+ .. code-block:: console
17
+
18
+ pip install trame-dataclass
19
+
20
+
21
+ Development setup
22
+ ----------------------------------------
23
+
24
+ We recommend using uv for setting up and managing a virtual environment for your development.
25
+
26
+ .. code-block:: console
27
+
28
+ # Create venv and install all dependencies
29
+ uv sync --all-extras --dev
30
+
31
+ # Activate environment
32
+ source .venv/bin/activate
33
+
34
+ # Install commit analysis
35
+ pre-commit install
36
+ pre-commit install --hook-type commit-msg
37
+
38
+ # Allow live code edit
39
+ uv pip install -e .
40
+
41
+
42
+ Build and install the Vue components
43
+
44
+ .. code-block:: console
45
+
46
+ cd vue-components
47
+ npm i
48
+ npm run build
49
+ cd -
50
+
51
+ For running tests and checks, you can run ``nox``.
52
+
53
+ .. code-block:: console
54
+
55
+ # run all
56
+ nox
57
+
58
+ # lint
59
+ nox -s lint
60
+
61
+ # tests
62
+ nox -s tests
63
+
64
+ Professional Support
65
+ ----------------------------------------
66
+
67
+ * `Training <https://www.kitware.com/courses/trame/>`_: Learn how to confidently use trame from the expert developers at Kitware.
68
+ * `Support <https://www.kitware.com/trame/support/>`_: Our experts can assist your team as you build your web application and establish in-house expertise.
69
+ * `Custom Development <https://www.kitware.com/trame/support/>`_: Leverage Kitware’s 25+ years of experience to quickly build your web application.
@@ -0,0 +1,93 @@
1
+ [project]
2
+ name = "trame-dataclass"
3
+ version = "1.0.0"
4
+ description = "Dataclass for trame UI binding"
5
+ authors = [{ name = "Kitware Inc." }]
6
+ dependencies = ["trame_client>=3.10"]
7
+ requires-python = ">=3.9"
8
+ readme = "README.rst"
9
+ license = { text = "Apache Software License" }
10
+ keywords = ["Python", "Interactive", "Web", "Application", "Framework"]
11
+ classifiers = [
12
+ "Development Status :: 4 - Beta",
13
+ "Environment :: Web Environment",
14
+ "License :: OSI Approved :: Apache Software License",
15
+ "Natural Language :: English",
16
+ "Operating System :: OS Independent",
17
+ "Programming Language :: Python :: 3 :: Only",
18
+ "Topic :: Software Development :: Libraries :: Application Frameworks",
19
+ "Topic :: Software Development :: Libraries :: Python Modules",
20
+ ]
21
+
22
+ [project.optional-dependencies]
23
+ app = ["pywebview"]
24
+ jupyter = ["jupyterlab"]
25
+ dev = [
26
+ "pre-commit",
27
+ "ruff",
28
+ "pytest >=6",
29
+ "pytest-asyncio",
30
+ "pytest-cov >=3",
31
+ "nox",
32
+ ]
33
+
34
+ [build-system]
35
+ requires = ["hatchling"]
36
+ build-backend = "hatchling.build"
37
+
38
+
39
+ [tool.hatch.build]
40
+ include = [
41
+ "/src/trame/**/*.py",
42
+ "/src/trame_dataclass/**/*.py",
43
+ "/src/trame_dataclass/**/*.js",
44
+ "/src/trame_dataclass/**/*.css",
45
+ ]
46
+
47
+ [tool.hatch.build.targets.wheel]
48
+ packages = ["src/trame", "src/trame_dataclass"]
49
+
50
+ [tool.ruff]
51
+
52
+ [tool.ruff.lint]
53
+ extend-select = [
54
+ "ARG", # flake8-unused-arguments
55
+ "B", # flake8-bugbear
56
+ "C4", # flake8-comprehensions
57
+ "EM", # flake8-errmsg
58
+ "EXE", # flake8-executable
59
+ "G", # flake8-logging-format
60
+ "I", # isort
61
+ "ICN", # flake8-import-conventions
62
+ "NPY", # NumPy specific rules
63
+ "PD", # pandas-vet
64
+ "PGH", # pygrep-hooks
65
+ "PIE", # flake8-pie
66
+ "PL", # pylint
67
+ "PT", # flake8-pytest-style
68
+ "PTH", # flake8-use-pathlib
69
+ "RET", # flake8-return
70
+ "RUF", # Ruff-specific
71
+ "SIM", # flake8-simplify
72
+ "T20", # flake8-print
73
+ "UP", # pyupgrade
74
+ "YTT", # flake8-2020
75
+ ]
76
+ ignore = [
77
+ "T201", # tmp during dev
78
+ "PLR09", # Too many <...>
79
+ "PLR2004", # Magic value used in comparison
80
+ "ISC001", # Conflicts with formatter
81
+ "SIM117", # nested with for UI in examples
82
+ ]
83
+ isort.required-imports = []
84
+
85
+ [tool.ruff.lint.per-file-ignores]
86
+ "tests/**" = ["T20"]
87
+ "noxfile.py" = ["T20"]
88
+ "src/**" = ["SIM117"]
89
+
90
+ [tool.semantic_release]
91
+ version_toml = ["pyproject.toml:project.version"]
92
+ version_variables = ["src/trame_dataclass/__init__.py:__version__"]
93
+ build_command = "pip install uv && uv build"
@@ -0,0 +1 @@
1
+ from trame_dataclass.module import * # noqa: F403
@@ -0,0 +1,7 @@
1
+ from trame_dataclass.widgets.dataclass import * # noqa: F403
2
+
3
+
4
+ def initialize(server):
5
+ from trame_dataclass import module
6
+
7
+ server.enable_module(module)
@@ -0,0 +1 @@
1
+ __version__ = "1.0.0"
@@ -0,0 +1,775 @@
1
+ import asyncio
2
+ import inspect
3
+ import os
4
+ import string
5
+ import types
6
+ import warnings
7
+ import weakref
8
+ from collections.abc import Awaitable
9
+ from dataclasses import dataclass
10
+ from enum import Enum, auto
11
+ from typing import Any, Callable, TypeVar
12
+
13
+ from trame_common.obj.component import TrameComponent
14
+
15
+ from trame_dataclass import module as dataclass_module
16
+
17
+ # -----------------------------------------------------------------------------
18
+ # internal field names
19
+ # -----------------------------------------------------------------------------
20
+ _FIELDS = "__trame_dataclass_fields__"
21
+
22
+ # -----------------------------------------------------------------------------
23
+ # Id generator
24
+ # -----------------------------------------------------------------------------
25
+ INSTANCES = weakref.WeakValueDictionary()
26
+ _INSTANCE_COUNT = 0
27
+ _INSTANCE_ID_CHARS = string.digits + string.ascii_letters
28
+
29
+
30
+ def _next_id():
31
+ global _INSTANCE_COUNT # noqa: PLW0603
32
+ _INSTANCE_COUNT += 1
33
+
34
+ result = []
35
+ value = _INSTANCE_COUNT
36
+ size = len(_INSTANCE_ID_CHARS)
37
+ while value > 0:
38
+ remainder = value % size
39
+ result.append(_INSTANCE_ID_CHARS[remainder])
40
+ value //= size
41
+
42
+ return "".join(result[::-1])
43
+
44
+
45
+ # -----------------------------------------------------------------------------
46
+ # Compatibles Types
47
+ # -----------------------------------------------------------------------------
48
+
49
+ _JSON_TYPES = frozenset(
50
+ {
51
+ # Common JSON Serializable types
52
+ types.NoneType,
53
+ bool,
54
+ int,
55
+ float,
56
+ str,
57
+ }
58
+ )
59
+
60
+ _COMPOSITE_TYPES = frozenset(
61
+ {
62
+ set,
63
+ list,
64
+ dict,
65
+ }
66
+ )
67
+
68
+ # -----------------------------------------------------------------------------
69
+ # Internal type definition
70
+ # -----------------------------------------------------------------------------
71
+
72
+ T = TypeVar("T")
73
+ SerializableCoreType = None | str | bool | int | float
74
+ SerializableType = (
75
+ SerializableCoreType | list[SerializableCoreType] | dict[str, SerializableCoreType]
76
+ )
77
+ Encoder = Callable[[T], SerializableType]
78
+ Decoder = Callable[[SerializableType], T]
79
+ WatcherCallback = Callable[[Any], None | Awaitable[None]]
80
+
81
+ # -----------------------------------------------------------------------------
82
+ # Custom Exception
83
+ # -----------------------------------------------------------------------------
84
+
85
+
86
+ class NonSerializableType(ValueError):
87
+ pass
88
+
89
+
90
+ class InvalidDefaultForType(ValueError):
91
+ pass
92
+
93
+
94
+ class NoServerLinked(ValueError):
95
+ pass
96
+
97
+
98
+ class WatcherExecution(Exception):
99
+ pass
100
+
101
+
102
+ # -----------------------------------------------------------------------------
103
+ # Internal classes
104
+ # -----------------------------------------------------------------------------
105
+
106
+
107
+ class ContainerFactory:
108
+ def __init__(self, cls):
109
+ self._cls = cls
110
+
111
+ def __call__(self, *args, **kwargs):
112
+ return self._cls(*args, **kwargs)
113
+
114
+
115
+ @dataclass
116
+ class Watcher:
117
+ id: int
118
+ args: tuple[str]
119
+ dependency: set[str]
120
+ callback: WatcherCallback
121
+ sync: bool
122
+
123
+ def trigger(
124
+ self,
125
+ obj,
126
+ dirty: set[str] | None = None,
127
+ sync: bool = False,
128
+ eager: bool = False,
129
+ ):
130
+ if self.sync != sync and not eager:
131
+ return
132
+
133
+ if dirty is None or self.dependency & dirty:
134
+ args = [getattr(obj, name) for name in self.args]
135
+ coroutine = self.callback(*args)
136
+ if inspect.isawaitable(coroutine):
137
+ bg_task = asyncio.create_task(coroutine)
138
+ bg_task.add_done_callback(handle_task_result)
139
+
140
+
141
+ def handle_task_result(task: asyncio.Task) -> None:
142
+ try:
143
+ task.result()
144
+ except asyncio.CancelledError:
145
+ pass # Task cancellation should not be logged as an error.
146
+ except Exception as e: # pylint: disable=broad-except
147
+ raise WatcherExecution() from e
148
+
149
+
150
+ def check_loop_status():
151
+ try:
152
+ asyncio.get_running_loop()
153
+ return True
154
+ except RuntimeError:
155
+ return False
156
+
157
+
158
+ # -----------------------------------------------------------------------------
159
+ # Method to add to trame_dataclass
160
+ # -----------------------------------------------------------------------------
161
+
162
+
163
+ def _create_methods(fields, server, client, sync, valid_keys):
164
+ methods_to_register = {}
165
+
166
+ def __init__(self, trame_server=None, **kwargs):
167
+ self.__id = _next_id()
168
+ self.__trame_server = trame_server
169
+
170
+ # Register all instances
171
+ INSTANCES[self.__id] = self
172
+
173
+ self._dirty_set = set()
174
+ self._sync = sync
175
+ self._watchers = []
176
+ self._next_watcher_id = 1
177
+ self._pending_task = None
178
+ self._flush_impl = None
179
+
180
+ if server:
181
+ self._server_state = {}
182
+
183
+ if client:
184
+ self._client_state = {}
185
+
186
+ # set default values
187
+ for f in fields:
188
+ f.setup_instance(self)
189
+
190
+ # initialize fields from kwargs
191
+ self.update(**kwargs)
192
+
193
+ # register to server
194
+ if self.server is not None:
195
+ self.server.enable_module(dataclass_module)
196
+ if self.server.running:
197
+ # register protocol directly
198
+ self._register_server()
199
+ else:
200
+ # wait for server to be ready
201
+ self.server.controller.on_server_ready.add(self._register_server)
202
+
203
+ def _register_server(self, **_):
204
+ self.server.protocol_call("trame.dataclass.register", self)
205
+
206
+ def register_flush_implementation(self, push_function):
207
+ self._flush_impl = push_function
208
+
209
+ def update(self, **kwargs):
210
+ for key in valid_keys & set(kwargs.keys()):
211
+ setattr(self, key, kwargs[key])
212
+
213
+ def __repr__(self):
214
+ max_size = max(len(f.name) for f in fields)
215
+ fields_info = [
216
+ f"{f.name:<{max_size}} [{f.mode} | enc({'custom' if f.encoder and f.decoder else 'json'}) | {_repr_type(f.type_annotation)}: {_repr_default(f.default)} ]: {_repr_value(getattr(self, f.name))}"
217
+ for f in fields
218
+ ]
219
+ return f"{self.__class__.__name__} ({self._id}) - {self._dirty_set if len(self._dirty_set) else 'Synched'}{os.linesep} - {f'{os.linesep} - '.join(fields_info)}"
220
+
221
+ def _on_dirty(self):
222
+ dirty_copy = set(self._dirty_set)
223
+
224
+ self._notify_watcher(dirty_copy, sync=True)
225
+ if self._pending_task is None and check_loop_status():
226
+ self._pending_task = asyncio.create_task(self._async_update(dirty_copy))
227
+ self._pending_task.add_done_callback(handle_task_result)
228
+
229
+ # only clear if you know that the dirty copy will be processed
230
+ # otherwise wait for completion to pickup the dirty left over.
231
+ self._dirty_set.clear()
232
+
233
+ if not check_loop_status():
234
+ # need to clear dirty if async is out of the picture
235
+ self._dirty_set.clear()
236
+
237
+ def _notify_watcher(self, dirty_set: set[str] | None = None, sync=False):
238
+ if dirty_set is None:
239
+ dirty_set = set(self._dirty_set)
240
+
241
+ for w in self._watchers:
242
+ w.trigger(self, dirty_set, sync=sync)
243
+
244
+ async def _async_update(self, dirty_set: set[str]):
245
+ self._notify_watcher(dirty_set, sync=False)
246
+ if sync:
247
+ self.flush(dirty_set)
248
+
249
+ self._pending_task = None
250
+
251
+ # reschedule ourself if remaining dirty
252
+ if self._dirty_set and check_loop_status():
253
+ dirty_set = set(self._dirty_set)
254
+ self._dirty_set.clear()
255
+
256
+ self._pending_task = asyncio.create_task(self._async_update(dirty_set))
257
+ self._pending_task.add_done_callback(handle_task_result)
258
+
259
+ def clear_watchers(self):
260
+ self._watchers.clear()
261
+
262
+ def clone(self):
263
+ other = self.__class__()
264
+ state = getattr(self, "_server_state", getattr(self, "_client_state", {}))
265
+ other.update(**state)
266
+ return other
267
+
268
+ methods_to_register["__init__"] = __init__
269
+ methods_to_register["update"] = update
270
+ methods_to_register["clone"] = clone
271
+ methods_to_register["__repr__"] = __repr__
272
+ methods_to_register["_on_dirty"] = _on_dirty
273
+ methods_to_register["_notify_watcher"] = _notify_watcher
274
+ methods_to_register["_async_update"] = _async_update
275
+ methods_to_register["_register_server"] = _register_server
276
+ methods_to_register["clear_watchers"] = clear_watchers
277
+ methods_to_register["register_flush_implementation"] = register_flush_implementation
278
+
279
+ # Optionally add flush method
280
+ if sync:
281
+
282
+ def flush(self, dirty_set: set[str] | None = None):
283
+ """Flush the data to the client."""
284
+ if self._flush_impl is None:
285
+ return
286
+
287
+ if dirty_set is None:
288
+ dirty_set = set(self._dirty_set)
289
+ self._dirty_set.clear()
290
+ else:
291
+ for name in dirty_set:
292
+ self._dirty_set.discard(name)
293
+
294
+ fields = getattr(self.__class__, _FIELDS)
295
+ for name in dirty_set:
296
+ _save_field(fields.get(name), self, self._client_state)
297
+
298
+ # Send data over the network
299
+ msg = {
300
+ "id": self._id,
301
+ "state": {k: self._client_state[k] for k in dirty_set},
302
+ }
303
+ self._flush_impl(msg)
304
+
305
+ methods_to_register["flush"] = flush
306
+
307
+ return methods_to_register
308
+
309
+
310
+ def _m_get_id(self):
311
+ return self.__id
312
+
313
+
314
+ def _m_get_server(self):
315
+ return self.__trame_server
316
+
317
+
318
+ def _m_set_server(self, v):
319
+ if self.__trame_server != v:
320
+ self.__trame_server = v
321
+ if v:
322
+ v.enable_module(dataclass_module)
323
+ self._register_server()
324
+
325
+
326
+ def _save_field(field, src, dst):
327
+ if field.encoder:
328
+ dst[field.name] = field.encoder(getattr(src, field.name))
329
+ else:
330
+ value = getattr(src, field.name)
331
+ if is_trame_dataclass(value):
332
+ value.flush()
333
+ value = f"_dataclass: {value._id}"
334
+ dst[field.name] = value
335
+
336
+
337
+ def _m_get_client_state(self):
338
+ # Make sure the client_state is fully filled
339
+ fields = getattr(self.__class__, _FIELDS).values()
340
+ dirty = set(self._dirty_set)
341
+ for field in fields:
342
+ if field.name in dirty or field.name not in self._client_state:
343
+ _save_field(field, self, self._client_state)
344
+
345
+ return self._client_state
346
+
347
+
348
+ def _m_watch(
349
+ self,
350
+ field_names: tuple[str],
351
+ callback_func: WatcherCallback,
352
+ sync: bool = False,
353
+ eager: bool = False,
354
+ ) -> Callable:
355
+ """Register a callback to be called when one or more fields change.
356
+
357
+ Args:
358
+ field_names (list[str]): Name(s) of the field(s) to watch.
359
+ callback_func (callable): Callback function to be called when the field(s) change.
360
+ sync (bool): Whether to execute the callback synchronously. By default this get triggered asynchronously.
361
+ eager (bool): Whether to execute the callback immediately after registration.
362
+
363
+ Returns:
364
+ callable: Unwatch function to unregister the callback.
365
+ """
366
+ watcher = Watcher(
367
+ self._next_watcher_id, field_names, set(field_names), callback_func, sync
368
+ )
369
+ self._next_watcher_id += 1
370
+ self._watchers.append(watcher)
371
+
372
+ def unwatch():
373
+ self._watchers.remove(watcher)
374
+
375
+ if eager:
376
+ watcher.trigger(self, eager=eager)
377
+
378
+ return unwatch
379
+
380
+
381
+ def _m_Provider(
382
+ self, name: str = "data", instance: str | None = None
383
+ ) -> TrameComponent:
384
+ """Register a data provider to be used by the client.
385
+
386
+ Args:
387
+ name (str): Name of the data variable that will be available within the nested scope.
388
+ instance (str): Id of the trame_dataclass instance to use for filling the data. This behave like any other Widget property, so you can make it dynamic to switch at runtime the delivered data.
389
+
390
+ Returns:
391
+ widget: instance of the widget to put within your UI definition."""
392
+ from trame_dataclass.widgets.dataclass import Provider
393
+
394
+ if instance is None:
395
+ instance = (f"'{self._id}'",)
396
+
397
+ return Provider(name=name, instance=instance)
398
+
399
+
400
+ # -----------------------------------------------------------------------------
401
+ # Representation helper functions
402
+ # -----------------------------------------------------------------------------
403
+
404
+
405
+ def _repr_type(annotation_type):
406
+ if isinstance(annotation_type, types.UnionType):
407
+ return f"({annotation_type})"
408
+
409
+ if is_trame_dataclass(annotation_type):
410
+ return annotation_type.__name__
411
+
412
+ if isinstance(annotation_type, type):
413
+ return annotation_type.__name__
414
+
415
+ return str(annotation_type)
416
+
417
+
418
+ def _repr_default(value):
419
+ if isinstance(value, ContainerFactory):
420
+ return "-"
421
+ return _repr_value(value)
422
+
423
+
424
+ def _repr_value(value):
425
+ if is_trame_dataclass(value):
426
+ return "\n ".join(str(value).split("\n"))
427
+ if isinstance(value, str):
428
+ return f'"{value}"'
429
+ return str(value)
430
+
431
+
432
+ # -----------------------------------------------------------------------------
433
+ # Type annotation analysis helper functions
434
+ # -----------------------------------------------------------------------------
435
+
436
+
437
+ def _type_compatibility(annotation_type):
438
+ if annotation_type in _JSON_TYPES:
439
+ return True
440
+
441
+ if isinstance(annotation_type, types.UnionType):
442
+ return all(map(_type_compatibility, annotation_type.__args__))
443
+
444
+ if is_trame_dataclass(annotation_type):
445
+ return True
446
+
447
+ if annotation_type in _COMPOSITE_TYPES:
448
+ warnings.warn("Composite type is not templated.", stacklevel=2)
449
+ return True
450
+
451
+ if (
452
+ hasattr(annotation_type, "__origin__")
453
+ and annotation_type.__origin__ in _COMPOSITE_TYPES
454
+ ):
455
+ return all(map(_type_compatibility, annotation_type.__args__))
456
+
457
+ return False
458
+
459
+
460
+ def _type_is_composite(annotation_type):
461
+ if annotation_type in _COMPOSITE_TYPES:
462
+ return True
463
+
464
+ return (
465
+ hasattr(annotation_type, "__origin__")
466
+ and annotation_type.__origin__ in _COMPOSITE_TYPES
467
+ )
468
+
469
+
470
+ def _type_can_be_none(annotation_type):
471
+ if isinstance(annotation_type, types.UnionType):
472
+ return types.NoneType in annotation_type.__args__
473
+
474
+ return False
475
+
476
+
477
+ def _type_is_dataclass(annotation_type):
478
+ if is_trame_dataclass(annotation_type):
479
+ return True
480
+
481
+ if isinstance(annotation_type, types.UnionType):
482
+ for t in annotation_type.__args__:
483
+ if is_trame_dataclass(t):
484
+ return True
485
+
486
+ return False
487
+
488
+
489
+ def _type_default(annotation_type):
490
+ if _type_can_be_none(annotation_type):
491
+ return None
492
+
493
+ if annotation_type is int:
494
+ return 0
495
+
496
+ if annotation_type is float:
497
+ return 0.0
498
+
499
+ if annotation_type is bool:
500
+ return False
501
+
502
+ if annotation_type is str:
503
+ return ""
504
+
505
+ if _type_is_composite(annotation_type):
506
+ container_type = (
507
+ annotation_type.__origin__
508
+ if hasattr(annotation_type, "__origin__")
509
+ else annotation_type
510
+ )
511
+ if container_type is list:
512
+ return ContainerFactory(list)
513
+ if container_type is set:
514
+ return ContainerFactory(set)
515
+ if container_type is dict:
516
+ return ContainerFactory(dict)
517
+ raise InvalidDefaultForType(annotation_type)
518
+
519
+ if isinstance(annotation_type, types.GenericAlias):
520
+ return _type_default(annotation_type.__origin__)
521
+
522
+ if is_trame_dataclass(annotation_type):
523
+ return ContainerFactory(annotation_type)
524
+
525
+ raise InvalidDefaultForType(annotation_type)
526
+
527
+
528
+ # -----------------------------------------------------------------------------
529
+ # Dataclass builder
530
+ # -----------------------------------------------------------------------------
531
+
532
+
533
+ def _process_class(cls):
534
+ cls_annotations = cls.__dict__.get("__annotations__", {})
535
+ cls_fields = []
536
+ for name, type in cls_annotations.items():
537
+ initial_value = cls.__dict__.get(name, None)
538
+ if initial_value is not None and isinstance(initial_value, Field):
539
+ initial_value.setup_annotation(name, type)
540
+ cls_fields.append(initial_value)
541
+ else:
542
+ if not _type_compatibility(type):
543
+ msg = f"{type} is not supported"
544
+ raise NonSerializableType(msg)
545
+
546
+ field = Field(default=initial_value)
547
+ field.setup_annotation(name, type)
548
+ cls_fields.append(field)
549
+
550
+ # add class metadata
551
+ setattr(cls, _FIELDS, {f.name: f for f in cls_fields})
552
+ for f in cls_fields:
553
+ f.setup_class(cls)
554
+
555
+ # Extract field meta summary
556
+ server = any(f.mode.has_server_state for f in cls_fields)
557
+ client = any(f.mode.has_client_state for f in cls_fields)
558
+ sync = any(f.mode.need_sync for f in cls_fields)
559
+ valid_keys = {f.name for f in cls_fields}
560
+
561
+ # add default getter properties
562
+ cls._id = property(_m_get_id)
563
+ cls.server = property(_m_get_server, _m_set_server)
564
+ if sync:
565
+ cls.client_state = property(_m_get_client_state)
566
+
567
+ # Add default methods
568
+ for name, fn in _create_methods(
569
+ cls_fields, server, client, sync, valid_keys
570
+ ).items():
571
+ setattr(cls, name, fn)
572
+ cls.watch = _m_watch
573
+ cls.Provider = _m_Provider
574
+
575
+ # return decorated class
576
+ return cls
577
+
578
+
579
+ # -----------------------------------------------------------------------------
580
+ # Generic encoder/decoder
581
+ # -----------------------------------------------------------------------------
582
+
583
+
584
+ def encode_dataclass_item(item):
585
+ if item is None:
586
+ return None
587
+ return item._id
588
+
589
+
590
+ def decode_dataclass_item(item):
591
+ # print("decode_dataclass_item", item)
592
+ if item is None:
593
+ return None
594
+ return get_instance(item)
595
+
596
+
597
+ def encode_dataclass_list(items):
598
+ return [item._id for item in items]
599
+
600
+
601
+ def decode_dataclass_list(items):
602
+ # print("decode_dataclass_list", items)
603
+ return list(map(get_instance, items))
604
+
605
+
606
+ def encode_dataclass_dict(data):
607
+ return {k: v._id for k, v in data.items()}
608
+
609
+
610
+ def decode_dataclass_dict(data):
611
+ # print("decode_dataclass_dict", data)
612
+ return {k: get_instance(v) for k, v in data.items()}
613
+
614
+
615
+ # -----------------------------------------------------------------------------
616
+ # Public API
617
+ # -----------------------------------------------------------------------------
618
+ __all__ = [
619
+ "Field",
620
+ "FieldMode",
621
+ "get_instance",
622
+ "is_trame_dataclass",
623
+ "trame_dataclass",
624
+ ]
625
+
626
+
627
+ def get_instance(instance_id: str):
628
+ # print(f"get_instance({instance_id})")
629
+ # print(" => ", INSTANCES[instance_id])
630
+ return INSTANCES[instance_id]
631
+
632
+
633
+ def trame_dataclass(cls=None, **_):
634
+ """Annotation for state based dataclass"""
635
+
636
+ def wrap(cls):
637
+ return _process_class(cls)
638
+
639
+ if cls is None:
640
+ return wrap
641
+
642
+ return wrap(cls)
643
+
644
+
645
+ def is_trame_dataclass(obj):
646
+ """Returns True if obj is a trame_dataclass or an instance of a
647
+ trame_dataclass."""
648
+ cls = (
649
+ obj
650
+ if isinstance(obj, type) and not isinstance(obj, types.GenericAlias)
651
+ else type(obj)
652
+ )
653
+ return hasattr(cls, _FIELDS)
654
+
655
+
656
+ class FieldMode(Enum):
657
+ CLIENT_ONLY = (False, False, True)
658
+ READ_ONLY = (True, False, True)
659
+ PUSH_ONLY = (False, True, True)
660
+ SERVER_ONLY = (True, True, False)
661
+ DEFAULT = (True, True, True)
662
+
663
+ def __init__(self, server_get, server_set, client):
664
+ self._value_ = auto()
665
+ self._get = server_get
666
+ self._set = server_set
667
+ self._client = client
668
+
669
+ @property
670
+ def has_get(self):
671
+ return self._get or self._set
672
+
673
+ @property
674
+ def has_set(self):
675
+ return self._set
676
+
677
+ @property
678
+ def has_server_state(self):
679
+ return self._get or self._set
680
+
681
+ @property
682
+ def has_client_state(self):
683
+ return self._client
684
+
685
+ @property
686
+ def need_sync(self):
687
+ return self.has_server_state and self.has_client_state
688
+
689
+
690
+ # -----------------------------------------------------------------------------
691
+
692
+
693
+ class Field:
694
+ def __init__(
695
+ self,
696
+ mode: FieldMode = FieldMode.DEFAULT,
697
+ default: Any = None,
698
+ encoder: Encoder | None = None,
699
+ decoder: Decoder | None = None,
700
+ ):
701
+ self.name = None
702
+ self.type_annotation = None
703
+ self.mode = mode
704
+ self.default = default
705
+ self.encoder = encoder
706
+ self.decoder = decoder
707
+ self.dataclass_container = False
708
+
709
+ def setup_annotation(self, name, type_annotation):
710
+ self.name = name
711
+ self.type_annotation = type_annotation
712
+ if self.default is None:
713
+ self.default = _type_default(type_annotation)
714
+
715
+ if _type_is_composite(type_annotation):
716
+ if hasattr(type_annotation, "__origin__"):
717
+ # properly typed
718
+ if type_annotation.__origin__ is list and is_trame_dataclass(
719
+ type_annotation.__args__[0]
720
+ ):
721
+ # print("Use custom list[dataclass] encoder/decoder")
722
+ assert self.encoder is None, (
723
+ "DataClass encoder get managed automatically. Should not override an existing one!"
724
+ )
725
+ self.encoder = encode_dataclass_list
726
+ self.decoder = decode_dataclass_list
727
+ self.dataclass_container = True
728
+ elif type_annotation.__origin__ is dict and is_trame_dataclass(
729
+ type_annotation.__args__[1]
730
+ ):
731
+ assert type_annotation.__args__[0] is str, (
732
+ "Dict with dataclass must use str as key"
733
+ )
734
+ assert self.encoder is None, (
735
+ "DataClass encoder get managed automatically. Should not override an existing one!"
736
+ )
737
+ # print("Use custom dict[str, dataclass] encoder/decoder")
738
+ self.encoder = encode_dataclass_dict
739
+ self.decoder = decode_dataclass_dict
740
+ self.dataclass_container = True
741
+ elif _type_is_dataclass(type_annotation):
742
+ assert self.encoder is None, (
743
+ "DataClass encoder get managed automatically. Should not override an existing one!"
744
+ )
745
+ self.encoder = encode_dataclass_item
746
+ self.decoder = decode_dataclass_item
747
+ self.dataclass_container = True
748
+
749
+ def setup_class(self, cls):
750
+ """Patch class with methods to add"""
751
+ name = self.name
752
+
753
+ def _set(self, value):
754
+ if name in self._server_state and self._server_state[name] == value:
755
+ return
756
+
757
+ self._dirty_set.add(name)
758
+ self._server_state[name] = value
759
+ self._on_dirty()
760
+
761
+ def _get(self):
762
+ return self._server_state[name]
763
+
764
+ if self.mode.has_get and self.mode.has_set:
765
+ setattr(cls, name, property(_get, _set))
766
+ elif self.mode.has_get:
767
+ setattr(cls, name, property(_get))
768
+
769
+ def setup_instance(self, obj):
770
+ # Assign value
771
+ value = self.default() if callable(self.default) else self.default
772
+ if self.mode.has_set:
773
+ setattr(obj, self.name, value)
774
+ elif self.mode.has_client_state:
775
+ obj._client_state[self.name] = value
@@ -0,0 +1,29 @@
1
+ from pathlib import Path
2
+
3
+ # Compute local path to serve
4
+ serve_path = str(Path(__file__).with_name("serve").resolve())
5
+
6
+ # Serve directory for JS/CSS files
7
+ serve = {"__trame_dataclass": serve_path}
8
+
9
+ # List of JS files to load (usually from the serve path above)
10
+ scripts = ["__trame_dataclass/trame_dataclass.umd.js"]
11
+
12
+ # List of CSS files to load (usually from the serve path above)
13
+ if (Path(serve_path) / "style.css").exists():
14
+ styles = ["__trame_dataclass/style.css"]
15
+
16
+ # List of Vue plugins to install/load
17
+ vue_use = ["trame_dataclass"]
18
+
19
+
20
+ # Optional if you want to execute custom initialization at module load
21
+ def setup(server, **_):
22
+ """Method called at initialization with possibly some custom keyword arguments"""
23
+ server.add_protocol_to_configure(configure_protocol)
24
+
25
+
26
+ def configure_protocol(protocol):
27
+ from trame_dataclass.module.protocol import TrameDataclassProtocol
28
+
29
+ protocol.registerLinkProtocol(TrameDataclassProtocol())
@@ -0,0 +1,94 @@
1
+ from wslink import register as export_rpc
2
+ from wslink.websocket import LinkProtocol
3
+
4
+ from trame_dataclass.core import get_instance, is_trame_dataclass
5
+
6
+
7
+ def compute_definition(trame_dataclass_class):
8
+ return {
9
+ "name": trame_dataclass_class.__name__,
10
+ "dataclass_containers": [
11
+ f.name
12
+ for f in trame_dataclass_class.__trame_dataclass_fields__.values()
13
+ if f.dataclass_container
14
+ ],
15
+ }
16
+
17
+
18
+ class TrameDataclassProtocol(LinkProtocol):
19
+ def __init__(self, *args, **kwargs):
20
+ super().__init__(*args, **kwargs)
21
+
22
+ self.class_definitions = {}
23
+ self.next_class_definition_id = 1
24
+
25
+ @export_rpc("trame.dataclass.register")
26
+ def register_instance(self, trame_dataclass):
27
+ if is_trame_dataclass(trame_dataclass):
28
+ self.register_definition(trame_dataclass.__class__)
29
+ trame_dataclass.register_flush_implementation(self.push_delta)
30
+
31
+ def register_definition(self, trame_dataclass_class):
32
+ if not is_trame_dataclass(trame_dataclass_class):
33
+ return None
34
+
35
+ if trame_dataclass_class in self.class_definitions:
36
+ return self.class_definitions[trame_dataclass_class]
37
+
38
+ definition_id = self.next_class_definition_id
39
+ self.next_class_definition_id += 1
40
+
41
+ definition = {
42
+ "id": definition_id,
43
+ **compute_definition(trame_dataclass_class),
44
+ }
45
+ self.class_definitions[trame_dataclass_class] = definition
46
+
47
+ return definition
48
+
49
+ @export_rpc("trame.dataclass.definition.get")
50
+ def get_definition(self, class_id):
51
+ for definition in self.class_definitions.values():
52
+ if definition["id"] == class_id:
53
+ return definition
54
+ return None
55
+
56
+ @export_rpc("trame.dataclass.state.get")
57
+ def get_state(self, instance_id):
58
+ """
59
+ {
60
+ id: instance_id,
61
+ definition: class_id,
62
+ state: {},
63
+ }
64
+ """
65
+ obj = get_instance(instance_id)
66
+ if obj is None:
67
+ return {
68
+ "id": instance_id,
69
+ "state": None,
70
+ }
71
+
72
+ return {
73
+ "id": instance_id,
74
+ "definition": self.class_definitions[obj.__class__]["id"],
75
+ "state": obj.client_state,
76
+ }
77
+
78
+ @export_rpc("trame.dataclass.state.update")
79
+ def update_state(self, msg):
80
+ # print("update_state", msg)
81
+ for dc_id, state in msg.items():
82
+ obj = get_instance(dc_id)
83
+ fields = obj.__class__.__trame_dataclass_fields__
84
+ if obj is not None:
85
+ for k, v in state.items():
86
+ field = fields.get(k)
87
+ if field.decoder:
88
+ setattr(obj, k, field.decoder(v))
89
+ else:
90
+ setattr(obj, k, v)
91
+
92
+ def push_delta(self, msg):
93
+ # print("publish", msg)
94
+ self.publish("trame.dataclass.publish", msg)
@@ -0,0 +1 @@
1
+ (function(r,c){typeof exports=="object"&&typeof module<"u"?c(exports,require("vue")):typeof define=="function"&&define.amd?define(["exports","vue"],c):(r=typeof globalThis<"u"?globalThis:r||self,c(r.trame_dataclass={},r.Vue))})(this,function(r,c){"use strict";function f(h,t,e){if(e.data._id!==h){e.data._id=h;const s=Object.keys(e.data);for(let i of s)delete e.data[i]}Object.assign(e.data,t.refs)}class d{constructor(){this.client=null,this.subscription=null,this.dataStates={},this.dataTypes={},this.typeDefinitions={},this.vueComponents={},this.internalReactiveObjects={},this.dataToVue={},this.pendingClientServerQueue=[],this.pendingFlushRequest=0}connect(t){this.client||!t||(this.client=t,this.subscription=t.getConnection().getSession().subscribe("trame.dataclass.publish",async([e])=>{const{id:s,state:i}=e;Object.assign(this.dataStates[s].server,i);for(const[n,a]of Object.entries(i))this.isDataClass(s,n)?await this.handleNestedDataClass(s,n,a):this.dataStates[s].refs[n].value=a}))}updateServer(t,e,s){this.pendingClientServerQueue.push([t,e,s]),this.flushToServer()}async flushToServer(){if(this.pendingFlushRequest)return;this.pendingFlushRequest++;const t={};let e=0;for(;this.pendingClientServerQueue.length;){const[s,i,n]=this.pendingClientServerQueue.shift();let a=n;if(n!==null&&this.isDataClass(s,i))if(Array.isArray(n))a=n.map(o=>o._id);else if(n._id)a=n._id;else{a={};for(const[o,v]in Object.entries(n))a[o]=v._id}JSON.stringify(this.dataStates[s].server[i])!==JSON.stringify(a)&&(t[s]||(t[s]={}),t[s][i]=a,e++)}if(e)try{await this.client.getConnection().getSession().call("trame.dataclass.state.update",[t])}catch(s){console.error("Network error when pushing client state",s)}this.pendingFlushRequest--,this.pendingClientServerQueue.length&&this.flushToServer()}isDataClass(t,e){return this.typeDefinitions[this.dataTypes[t]].dataclass_containers.includes(e)}async handleNestedDataClass(t,e,s){if(s===null)this.dataStates[t].refs[e].value=null;else if(Array.isArray(s)){const i=[];for(let n=0;n<s.length;n++){const a=s[n];let o=!1;this.internalReactiveObjects[a]||(this.internalReactiveObjects[a]=c.reactive({}),o=!0),i.push(this.internalReactiveObjects[a]),this.dataStates[a]||(o=!1,await this.fetchState(a)),o&&Object.assign(this.internalReactiveObjects[a],this.dataStates[a].refs)}this.dataStates[t].refs[e].value=i}else if(typeof s=="string"){const i=s;this.internalReactiveObjects[i]||(this.internalReactiveObjects[i]=c.reactive({})),this.dataStates[i]?Object.assign(this.internalReactiveObjects[i],this.dataStates[i].refs):await this.fetchState(i)}else{const i=c.reactive({});for(const[n,a]of Object.entries(s))this.internalReactiveObjects[a]||(this.internalReactiveObjects[a]=c.reactive({})),i[n]=this.internalReactiveObjects[a],this.dataStates[a]?Object.assign(this.internalReactiveObjects[a],this.dataStates[a].refs):await this.fetchState(a);this.dataStates[t].refs[e].value=i}}async fetchState(t){const e={_id:t},s=await this.client.getConnection().getSession().call("trame.dataclass.state.get",[t]);this.dataTypes[t]=s.definition,this.dataStates[t]={refs:e,server:s.state},this.typeDefinitions[s.definition]||await this.fetchDefinition(s.definition);for(const[i,n]of Object.entries(s.state))this.isDataClass(t,i)?(e[i]=c.ref(null),await this.handleNestedDataClass(t,i,n)):e[i]=c.ref(n),c.watch(()=>e[i].value,a=>this.updateServer(t,i,a));return this.dataToVue[t]&&this.dataToVue[t].forEach(i=>{f(t,this.dataStates[t],this.vueComponents[i])}),this.internalReactiveObjects[t]&&Object.assign(this.internalReactiveObjects[t],this.dataStates[t].refs),this.dataStates[t]}async fetchDefinition(t){const e=await this.client.getConnection().getSession().call("trame.dataclass.definition.get",[t]);this.typeDefinitions[t]=e}unlink(t,e){this.dataToVue[t]&&(this.dataToVue[t]=this.dataToVue[t].filter(s=>s!==e))}link(t,e){this.dataToVue[t]?this.dataToVue[t].push(e):this.dataToVue[t]=[e],this.dataStates[t]?f(t,this.dataStates[t],this.vueComponents[e]):this.fetchState(t)}connectVueComponent(t,e){var n;const s=(n=this.vueComponents[t])==null?void 0:n.id,i=e.id;this.unlink(s,t),this.vueComponents[t]=e,this.link(i,t)}disconnectVueComponent(t){var s;const e=(s=this.vueComponents[t])==null?void 0:s.id;delete this.vueComponents[t],this.unlink(e,t)}}const l=new d;let p=1;const u={TrameDataclass:{props:["instance"],setup(h){const t=c.inject("trame"),e=c.reactive({}),s={serverPush:!1},i=`vueDataClass${p++}`;return l.connect(t.client),c.watchEffect(()=>{l.connectVueComponent(i,{id:h.instance,data:e,guards:s})}),c.onBeforeUnmount(()=>{l.disconnectVueComponent(i)}),{data:e}},template:'<slot :dataclass="data"></slot>'}};function S(h){Object.keys(u).forEach(t=>{h.component(t,u[t])})}r.install=S,Object.defineProperty(r,Symbol.toStringTag,{value:"Module"})});
@@ -0,0 +1,26 @@
1
+ from trame_client.widgets.core import AbstractElement
2
+
3
+ from .. import module
4
+
5
+
6
+ class HtmlElement(AbstractElement):
7
+ def __init__(self, _elem_name, children=None, **kwargs):
8
+ super().__init__(_elem_name, children, **kwargs)
9
+ if self.server:
10
+ self.server.enable_module(module)
11
+
12
+
13
+ __all__ = [
14
+ "Provider",
15
+ ]
16
+
17
+
18
+ # Expose your vue component(s)
19
+ class Provider(HtmlElement):
20
+ def __init__(self, name, **kwargs):
21
+ super().__init__(
22
+ "trame-dataclass",
23
+ **kwargs,
24
+ )
25
+ self._attr_names += ["instance"]
26
+ self._attributes["slot"] = f'v-slot="{{ dataclass: {name} }}"'