composeui 0.1.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.
Files changed (97) hide show
  1. composeui/__init__.py +11 -0
  2. composeui/core/__init__.py +0 -0
  3. composeui/core/application.py +368 -0
  4. composeui/core/chartcolors.py +23 -0
  5. composeui/core/chartimage.py +336 -0
  6. composeui/core/dataframesource.py +307 -0
  7. composeui/core/jobs.py +426 -0
  8. composeui/core/model.py +66 -0
  9. composeui/core/registry.py +66 -0
  10. composeui/core/reload.py +476 -0
  11. composeui/core/renderer.py +193 -0
  12. composeui/core/signal.py +358 -0
  13. composeui/core/sqlitemodel.py +1183 -0
  14. composeui/core/study.py +121 -0
  15. composeui/core/translation.py +221 -0
  16. composeui/core/translationcheck.py +254 -0
  17. composeui/core/view.py +5508 -0
  18. composeui/images/composeui.svg +15 -0
  19. composeui/locales/fr.json +73 -0
  20. composeui/mypy.py +46 -0
  21. composeui/py.typed +0 -0
  22. composeui/qtcore/__init__.py +0 -0
  23. composeui/qtcore/maprenderer.py +115 -0
  24. composeui/qtcore/qtapplication.py +222 -0
  25. composeui/qtcore/qtrenderer.py +2862 -0
  26. composeui/qtcore/widgets/__init__.py +0 -0
  27. composeui/qtcore/widgets/map.qml +123 -0
  28. composeui/qtcore/widgets/qchart.py +393 -0
  29. composeui/qtcore/widgets/qdatefield.py +101 -0
  30. composeui/qtcore/widgets/qexpander.py +60 -0
  31. composeui/qtcore/widgets/qfilepathfield.py +34 -0
  32. composeui/qtcore/widgets/qfiltertable.py +394 -0
  33. composeui/qtcore/widgets/qformdialog.py +64 -0
  34. composeui/qtcore/widgets/qformlabel.py +53 -0
  35. composeui/qtcore/widgets/qmap.py +68 -0
  36. composeui/qtcore/widgets/qmarkicon.py +37 -0
  37. composeui/qtcore/widgets/qmetric.py +44 -0
  38. composeui/qtcore/widgets/qplaintext.py +29 -0
  39. composeui/qtcore/widgets/qradiogroup.py +57 -0
  40. composeui/qtcore/widgets/qsidebar.py +116 -0
  41. composeui/qtcore/widgets/qsourcetable.py +216 -0
  42. composeui/qtcore/widgets/qsplitter.py +69 -0
  43. composeui/qtcore/widgets/qtexttable.py +703 -0
  44. composeui/qtcore/widgets/qvaluecombobox.py +41 -0
  45. composeui/qtcore/widgets/qvalueslider.py +64 -0
  46. composeui/qtcore/widgets/qwindowrelease.py +32 -0
  47. composeui/textualcore/__init__.py +0 -0
  48. composeui/textualcore/textualapplication.py +121 -0
  49. composeui/textualcore/textualrenderer.py +2893 -0
  50. composeui/textualcore/widgets/__init__.py +0 -0
  51. composeui/textualcore/widgets/composebutton.py +17 -0
  52. composeui/textualcore/widgets/composechart.py +212 -0
  53. composeui/textualcore/widgets/composecheckbox.py +35 -0
  54. composeui/textualcore/widgets/composecollapsible.py +41 -0
  55. composeui/textualcore/widgets/composecombobox.py +54 -0
  56. composeui/textualcore/widgets/composedateinput.py +325 -0
  57. composeui/textualcore/widgets/composedialog.py +90 -0
  58. composeui/textualcore/widgets/composefilepathinput.py +85 -0
  59. composeui/textualcore/widgets/composefiltertable.py +367 -0
  60. composeui/textualcore/widgets/composeformdialog.py +86 -0
  61. composeui/textualcore/widgets/composeinput.py +50 -0
  62. composeui/textualcore/widgets/composelist.py +20 -0
  63. composeui/textualcore/widgets/composemarkdown.py +17 -0
  64. composeui/textualcore/widgets/composemetric.py +60 -0
  65. composeui/textualcore/widgets/composeradioset.py +54 -0
  66. composeui/textualcore/widgets/composesidebar.py +111 -0
  67. composeui/textualcore/widgets/composeslider.py +144 -0
  68. composeui/textualcore/widgets/composesourcetable.py +288 -0
  69. composeui/textualcore/widgets/composesplitter.py +211 -0
  70. composeui/textualcore/widgets/composetabbedcontent.py +35 -0
  71. composeui/textualcore/widgets/composetable.py +476 -0
  72. composeui/textualcore/widgets/composetoolbar.py +62 -0
  73. composeui/textualcore/widgets/composetree.py +139 -0
  74. composeui/webcore/__init__.py +0 -0
  75. composeui/webcore/headers.py +57 -0
  76. composeui/webcore/icons.py +58 -0
  77. composeui/webcore/limits.py +55 -0
  78. composeui/webcore/origin.py +63 -0
  79. composeui/webcore/page.py +146 -0
  80. composeui/webcore/routes.py +303 -0
  81. composeui/webcore/session.py +436 -0
  82. composeui/webcore/static/app.css +2 -0
  83. composeui/webcore/static/cally.js +1312 -0
  84. composeui/webcore/static/daisyui.LICENSE +21 -0
  85. composeui/webcore/static/deckgl.LICENSE +19 -0
  86. composeui/webcore/static/deckgl.min.js +6423 -0
  87. composeui/webcore/static/htmx.min.js +1 -0
  88. composeui/webcore/static/page.js +1175 -0
  89. composeui/webcore/static/uPlot.LICENSE +21 -0
  90. composeui/webcore/static/uPlot.iife.min.js +2 -0
  91. composeui/webcore/webapplication.py +495 -0
  92. composeui/webcore/webrenderer.py +5113 -0
  93. composeui-0.1.0.dist-info/METADATA +222 -0
  94. composeui-0.1.0.dist-info/RECORD +97 -0
  95. composeui-0.1.0.dist-info/WHEEL +5 -0
  96. composeui-0.1.0.dist-info/licenses/LICENSE +9 -0
  97. composeui-0.1.0.dist-info/top_level.txt +1 -0
composeui/core/jobs.py ADDED
@@ -0,0 +1,426 @@
1
+ """Work run in threads, its results given back on the main thread (see `Jobs`)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import functools
6
+ import logging
7
+ import queue
8
+ import threading
9
+ import time
10
+ import weakref
11
+ from typing import TYPE_CHECKING, Any, Callable, Generic
12
+
13
+ from typing_extensions import Concatenate, ParamSpec, TypeVar, TypeVarTuple, Unpack
14
+
15
+ from composeui.core.model import RefusedError
16
+ from composeui.core.signal import Signal, SignalNoArgs
17
+
18
+ if TYPE_CHECKING:
19
+ from composeui.core.model import Model
20
+ from composeui.core.view import MainView
21
+
22
+ __all__ = ["CalledInJobError", "Job", "Jobs", "Task"]
23
+
24
+ LOGGER = logging.getLogger("COMPOSEUI")
25
+
26
+ R = TypeVar("R")
27
+ ProgressT = TypeVar("ProgressT", default=None)
28
+ P = ParamSpec("P")
29
+ Ts = TypeVarTuple("Ts")
30
+
31
+ #: the job a thread runs, in a job's thread (see `Jobs.start`)
32
+ _CURRENT = threading.local()
33
+
34
+
35
+ class CalledInJobError(RuntimeError):
36
+ """A job's function tried to start or change jobs instead of using a main-thread callback."""
37
+
38
+
39
+ def current_job() -> Job[Any, Any] | None:
40
+ """Get the job whose function runs on this thread; None on any other thread."""
41
+ job: Job[Any, Any] | None = getattr(_CURRENT, "job", None)
42
+ return job
43
+
44
+
45
+ def _check_main_thread(method: str) -> None:
46
+ """Raise `CalledInJobError` in a job's thread: `Jobs` is only changed on the main one."""
47
+ if current_job() is not None:
48
+ msg = (
49
+ f"{method} must be called on the main thread, not by a job's function: call it"
50
+ " from a callback of the job's finished signal instead"
51
+ )
52
+ raise CalledInJobError(msg)
53
+
54
+
55
+ class Job(Generic[R, ProgressT]):
56
+ """A function running in a thread of its own, started by `Jobs.start`.
57
+
58
+ The function gets its job first: it calls `sleep`, or reads `is_canceled`, to stop
59
+ early once canceled. Its result, or what it raised, is emitted on the main thread.
60
+ Programming errors instead escape the worker after dispatching cleanup; see `Jobs`.
61
+ """
62
+
63
+ def __init__(self, hand_over: Callable[[Job[Any, Any], Callable[[], None]], None]) -> None:
64
+ # `Jobs._hand_over`, giving a callback to the main thread
65
+ self._hand_over = hand_over
66
+ #: the function's return value, on the main thread
67
+ self.finished = Signal[R]()
68
+ #: a non-programming exception, on the main thread (logged too; see `Jobs`)
69
+ self.failed = Signal[Exception]()
70
+ #: progress values reported by the worker, on the main thread
71
+ self.progress = Signal[ProgressT]()
72
+ self._canceled = threading.Event()
73
+
74
+ @property
75
+ def is_canceled(self) -> bool:
76
+ return self._canceled.is_set()
77
+
78
+ def cancel(self) -> None:
79
+ """Ask the function to stop; it still finishes, as soon as it notices."""
80
+ self._canceled.set()
81
+
82
+ def sleep(self, seconds: float) -> bool:
83
+ """Wait `seconds`, in the job's thread; False if it was canceled meanwhile."""
84
+ return not self._canceled.wait(seconds)
85
+
86
+ def call_on_main_thread(self, callback: Callable[[], None]) -> None:
87
+ """Call `callback` on the main thread, e.g. to show how far the function has gone.
88
+
89
+ Called in order, before the job's outcome is emitted; not at all once the job is
90
+ dropped (New, Open, closing the app). Without a toolkit, `Jobs.wait` calls it.
91
+ """
92
+ self._hand_over(self, callback)
93
+
94
+ def report_progress(self, value: ProgressT) -> None:
95
+ """Emit `progress` on the main thread, in order and before the job's outcome.
96
+
97
+ Pass a count, percentage, message, or another application-defined value.
98
+ Dropped jobs do not emit queued progress, as with `call_on_main_thread`.
99
+ """
100
+ self.call_on_main_thread(functools.partial(self.progress.emit, value))
101
+
102
+
103
+ class Task(Generic[R, P, ProgressT]):
104
+ """A reusable worker with read-only loading state, built by `Jobs.task`.
105
+
106
+ `changed` emits on the main thread when a run starts, reports progress, finishes,
107
+ fails, or is dropped. After it, `finished` emits a run's result, and `failed` its
108
+ exception, as a `Job`'s do. Their callbacks can ask for ``main_view`` and ``model``.
109
+ Starting an already running task does nothing. Dropping the owner's jobs resets
110
+ the task and discards queued progress and outcomes, as with ordinary jobs.
111
+ """
112
+
113
+ def __init__(self, jobs: Jobs, work: Callable[Concatenate[Job[R, ProgressT], P], R]) -> None:
114
+ self._jobs = jobs
115
+ self._work = work
116
+ self.changed = SignalNoArgs()
117
+ #: the result of a run that succeeded, on the main thread
118
+ self.finished = Signal[R]()
119
+ #: the exception of a run that failed, on the main thread
120
+ self.failed = Signal[Exception]()
121
+ self._job: Job[R, ProgressT] | None = None
122
+ self._reset()
123
+
124
+ @property
125
+ def is_running(self) -> bool:
126
+ """Get whether this task is waiting for its current run's outcome."""
127
+ return self._job is not None
128
+
129
+ @property
130
+ def progress(self) -> ProgressT | None:
131
+ """Get the last progress value, or None before the run reports one."""
132
+ return self._progress
133
+
134
+ @property
135
+ def result(self) -> R | None:
136
+ """Get the successful result, or None before success; see `has_result`."""
137
+ return self._result
138
+
139
+ @property
140
+ def error(self) -> Exception | None:
141
+ """Get the run's exception, or None before failure."""
142
+ return self._error
143
+
144
+ @property
145
+ def has_result(self) -> bool:
146
+ """Get whether the last run succeeded, including a result of None."""
147
+ return self._has_result
148
+
149
+ def start(self, *args: P.args, **kwargs: P.kwargs) -> bool:
150
+ """Start the worker with fresh state; return False if it is already running."""
151
+ _check_main_thread("Task.start")
152
+ if self.is_running:
153
+ return False
154
+ self._reset()
155
+ job = self._jobs._new_job(self._work) # noqa: SLF001 (shared job lifecycle)
156
+ self._job = job
157
+ job.progress.connect(self._report_progress)
158
+ job.finished.connect(self._finish)
159
+ job.failed.connect(self._fail)
160
+ self._jobs._start_job(job, self._work, args, kwargs) # noqa: SLF001 (shared job lifecycle)
161
+ self.changed.emit()
162
+ return True
163
+
164
+ def cancel(self) -> None:
165
+ """Ask the current run to stop; it still emits its outcome, as with `Job.cancel`."""
166
+ job = self._job
167
+ if job is not None:
168
+ job.cancel()
169
+
170
+ def _reset(self) -> None:
171
+ self._job = None
172
+ self._progress: ProgressT | None = None
173
+ self._result: R | None = None
174
+ self._error: Exception | None = None
175
+ self._has_result = False
176
+
177
+ def _report_progress(self, value: ProgressT) -> None:
178
+ self._progress = value
179
+ self.changed.emit()
180
+
181
+ def _finish(self, result: R) -> None:
182
+ self._job = None
183
+ self._result = result
184
+ self._has_result = True
185
+ self.changed.emit()
186
+ self.finished.emit(result)
187
+
188
+ def _fail(self, error: Exception) -> None:
189
+ self._job = None
190
+ self._error = error
191
+ self.changed.emit()
192
+ self.failed.emit(error)
193
+
194
+
195
+ class Jobs:
196
+ """The jobs of a main view (`MainView.jobs`): functions run in threads.
197
+
198
+ A job's function runs in a thread, so that the app stays responsive. It must not touch
199
+ a view, because a toolkit's widgets belong to the main thread. It returns its result
200
+ instead, and its job's `finished` emits the result on the main thread. There,
201
+ callbacks update the views. Like the callbacks of any signal, they get the `main_view`
202
+ and `model` they ask for. `Job.report_progress` emits the job's `progress` signal on
203
+ the main thread. `Job.call_on_main_thread` hands it an arbitrary callback instead.
204
+ A `SqliteModel` hands it the `changed` signals of the job's writes.
205
+
206
+ `task` configures a reusable worker with progress, result and error state. Its
207
+ ``changed`` signal shows the loading state, and its ``finished`` and ``failed``
208
+ signals the outcome of each run.
209
+
210
+ Call `start`, `cancel` and `drop` on the main thread, e.g. from a callback. Never call
211
+ them from a job's function: the jobs aren't locked, as the main thread alone changes
212
+ them. `Job.cancel` is the exception: any thread can call it. Called from a job's
213
+ function, they raise `CalledInJobError`. To start another job, return what it needs
214
+ instead, and start it from the callback that gets it.
215
+
216
+ Uncaught `CalledInJobError`, `AssertionError`, `AttributeError`, `NameError`,
217
+ `NotImplementedError` and `TypeError` escape the worker to Python's thread exception
218
+ handler. Cleanup is dispatched to the main thread, without emitting `failed` or
219
+ setting a task's `error`. The affected task returns to its idle state.
220
+
221
+ New, Open and closing the app cancel the jobs running, and drop their results: the
222
+ results belong to the study replaced.
223
+ """
224
+
225
+ def __init__(self, main_view: MainView) -> None:
226
+ self._main_view = weakref.ref(main_view)
227
+ self._model: weakref.ReferenceType[Model] | None = None
228
+ #: True as the first job starts, False once the last one is over
229
+ self.running_changed = Signal[bool]()
230
+ self.running_changed.set_main_view(main_view)
231
+ # the jobs started and not over yet: only changed on the main thread
232
+ self._running: set[Job[Any, Any]] = set()
233
+ self._tasks: list[Task[Any, Any, Any]] = []
234
+ # the results handed back while the main view isn't rendered, for `wait`
235
+ self._results: queue.SimpleQueue[Callable[[], None]] = queue.SimpleQueue()
236
+
237
+ @property
238
+ def is_running(self) -> bool:
239
+ return len(self._running) > 0
240
+
241
+ def set_model(self, model: Model) -> None:
242
+ """Give `model` to the callbacks asking for it (the app does)."""
243
+ self._model = weakref.ref(model)
244
+ self.running_changed.set_model(model)
245
+ for task in self._tasks:
246
+ for signal in (task.changed, task.finished, task.failed):
247
+ signal.set_model(model)
248
+
249
+ def task(self, work: Callable[Concatenate[Job[R, ProgressT], P], R]) -> Task[R, P, ProgressT]:
250
+ """Build a reusable task for `work`, without starting it; call on the main thread."""
251
+ _check_main_thread("Jobs.task")
252
+ task = Task(self, work)
253
+ name = getattr(work, "__name__", "task")
254
+ self._bind_signal(task.changed, f"{name}.changed")
255
+ self._bind_signal(task.finished, f"{name}.finished")
256
+ self._bind_signal(task.failed, f"{name}.failed")
257
+ self._tasks.append(task)
258
+ return task
259
+
260
+ def start(
261
+ self,
262
+ work: Callable[Concatenate[Job[R, ProgressT], P], R],
263
+ *args: P.args,
264
+ **kwargs: P.kwargs,
265
+ ) -> Job[R, ProgressT]:
266
+ """Run ``work(job, *args, **kwargs)`` in a thread; call it on the main thread.
267
+
268
+ Connect to the job's signals right away: its outcome is emitted on the main
269
+ thread, so no sooner than the code calling `start` is done. Raises
270
+ `CalledInJobError` in a job's thread.
271
+ """
272
+ _check_main_thread("Jobs.start")
273
+ job = self._new_job(work)
274
+ self._start_job(job, work, args, kwargs)
275
+ return job
276
+
277
+ def _bind_signal(self, signal: Signal[Unpack[Ts]], name: str) -> None:
278
+ signal.set_name(name)
279
+ main_view = self._main_view()
280
+ model = None if self._model is None else self._model()
281
+ # Jobs and tasks are created while their main view lives.
282
+ if main_view is not None: # pragma: no branch
283
+ signal.set_main_view(main_view)
284
+ if model is not None:
285
+ signal.set_model(model)
286
+
287
+ def _new_job(self, work: Callable[Concatenate[Job[R, ProgressT], P], R]) -> Job[R, ProgressT]:
288
+ job: Job[R, ProgressT] = Job(self._hand_over)
289
+ name = getattr(work, "__name__", "job")
290
+ self._bind_signal(job.finished, f"{name}.finished")
291
+ self._bind_signal(job.failed, f"{name}.failed")
292
+ self._bind_signal(job.progress, f"{name}.progress")
293
+ return job
294
+
295
+ def _start_job(
296
+ self,
297
+ job: Job[R, ProgressT],
298
+ work: Callable[..., R],
299
+ args: tuple[Any, ...],
300
+ kwargs: dict[str, Any],
301
+ ) -> None:
302
+ self._running.add(job)
303
+ if len(self._running) == 1:
304
+ self.running_changed.emit(True) # noqa: FBT003
305
+ # a daemon: closing the app doesn't wait for it
306
+ thread = threading.Thread(target=self._run, args=(job, work, args, kwargs), daemon=True)
307
+ thread.start()
308
+
309
+ def cancel(self) -> None:
310
+ """Cancel every job running; each still finishes, as soon as it notices.
311
+
312
+ Raises `CalledInJobError` in a job's thread: a job cancels itself with `Job.cancel`.
313
+ """
314
+ _check_main_thread("Jobs.cancel")
315
+ for job in self._running:
316
+ job.cancel()
317
+
318
+ def drop(self) -> None:
319
+ """Cancel every job running and forget it: its result won't be emitted.
320
+
321
+ Reset all tasks, including completed ones, before emitting their ``changed``.
322
+ Raises `CalledInJobError` in a job's thread.
323
+ """
324
+ _check_main_thread("Jobs.drop")
325
+ self.cancel()
326
+ was_running = self.is_running
327
+ self._running.clear()
328
+ tasks = tuple(self._tasks)
329
+ for task in tasks:
330
+ task._reset() # noqa: SLF001 (reset all tasks before notifying their callbacks)
331
+ if was_running:
332
+ self.running_changed.emit(False) # noqa: FBT003
333
+ for task in tasks:
334
+ task.changed.emit()
335
+
336
+ def wait(self, timeout: float = 10.0) -> None:
337
+ """Wait for every job to be over, and emit the results here: for a main view not rendered.
338
+
339
+ Use it for a main view that no toolkit shows, e.g. in a test. Its jobs' results wait
340
+ for this call, which emits them on its own thread, as a toolkit would on its main one.
341
+ Raises `TimeoutError` if jobs are still running after `timeout` seconds.
342
+ """
343
+ main_view = self._main_view()
344
+ if main_view is not None and main_view.renderer is not None:
345
+ msg = "Jobs.wait is for a main view no toolkit shows: its event loop emits them"
346
+ raise RuntimeError(msg)
347
+ deadline = time.monotonic() + timeout
348
+ while self.is_running:
349
+ remaining = deadline - time.monotonic()
350
+ try:
351
+ emit = self._results.get(timeout=max(remaining, 0))
352
+ except queue.Empty:
353
+ msg = f"{len(self._running)} jobs still running after {timeout} s"
354
+ raise TimeoutError(msg) from None
355
+ emit()
356
+
357
+ def _run(
358
+ self,
359
+ job: Job[R, ProgressT],
360
+ work: Callable[..., R],
361
+ args: tuple[Any, ...],
362
+ kwargs: dict[str, Any],
363
+ ) -> None:
364
+ """Run `work` in the job's thread, and hand its outcome to the main thread."""
365
+ _CURRENT.job = job # for the thread's life: a thread of its own per job
366
+ try:
367
+ result = work(job, *args, **kwargs)
368
+ except (
369
+ CalledInJobError,
370
+ AssertionError,
371
+ AttributeError,
372
+ NameError,
373
+ NotImplementedError,
374
+ TypeError,
375
+ ):
376
+ self._on_main_thread(functools.partial(self._abort, job))
377
+ raise
378
+ except Exception as error: # the app's code: whatever it raises, the job is over
379
+ name = getattr(work, "__name__", "job")
380
+ if isinstance(error, RefusedError):
381
+ LOGGER.warning("The job %s was refused: %s", name, error)
382
+ else:
383
+ LOGGER.exception("The job %s failed", name)
384
+ self._on_main_thread(functools.partial(self._end, job, job.failed, error))
385
+ else:
386
+ self._on_main_thread(functools.partial(self._end, job, job.finished, result))
387
+
388
+ def _on_main_thread(self, callback: Callable[[], None]) -> None:
389
+ main_view = self._main_view()
390
+ if main_view is None:
391
+ return # the app is gone
392
+ if main_view.renderer is None:
393
+ self._results.put(callback)
394
+ else:
395
+ main_view.renderer.call_on_main_thread(callback)
396
+
397
+ def _hand_over(self, job: Job[Any, Any], callback: Callable[[], None]) -> None:
398
+ self._on_main_thread(functools.partial(self._call, job, callback))
399
+
400
+ def _call(self, job: Job[Any, Any], callback: Callable[[], None]) -> None:
401
+ """Call a job's `callback`, on the main thread; unless the job was dropped."""
402
+ if job in self._running:
403
+ callback()
404
+
405
+ def _end(self, job: Job[Any, Any], signal: Signal[Any], outcome: object) -> None:
406
+ """Emit the outcome of a job, on the main thread; unless it was dropped."""
407
+ if job not in self._running:
408
+ return
409
+ self._running.discard(job)
410
+ signal.emit(outcome)
411
+ if not self.is_running:
412
+ self.running_changed.emit(False) # noqa: FBT003
413
+
414
+ def _abort(self, job: Job[Any, Any]) -> None:
415
+ """Clear a programming error's runtime state without emitting a business failure."""
416
+ if job not in self._running:
417
+ return
418
+ self._running.discard(job)
419
+ try:
420
+ for task in self._tasks:
421
+ if task._job is job: # noqa: SLF001 (shared job lifecycle)
422
+ task._reset() # noqa: SLF001 (shared job lifecycle)
423
+ task.changed.emit()
424
+ finally:
425
+ if not self.is_running:
426
+ self.running_changed.emit(False) # noqa: FBT003
@@ -0,0 +1,66 @@
1
+ """The model: the app's data beside its views, saved and opened with them in a study."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any, ClassVar
6
+
7
+ from composeui.core.signal import Signal
8
+
9
+ if TYPE_CHECKING:
10
+ from pathlib import Path
11
+
12
+ from typing_extensions import Unpack
13
+
14
+ __all__ = ["InvalidStateError", "Model", "RefusedError"]
15
+
16
+
17
+ class InvalidStateError(ValueError):
18
+ """A saved study that can't be opened; the views and the model are left unchanged."""
19
+
20
+
21
+ class RefusedError(Exception):
22
+ """A change the model refused, changing nothing.
23
+
24
+ Its message tells the user why, as written in the code (`View.refusing` translates it),
25
+ or translated already by a view's `tr`.
26
+ """
27
+
28
+
29
+ class Model:
30
+ """The app's data beside its views, given to the callbacks asking for `model`.
31
+
32
+ A model with data of its own names its file in `data_name`. The study is then saved as
33
+ an archive of the views' state and that file (see `composeui.core.study`). New, Open
34
+ and Save reach the model through the methods below. Without a `data_name`, Save and
35
+ Open don't call `save_data` and `open_data`, and the study stays the views' JSON.
36
+ """
37
+
38
+ #: the model's file inside a saved study; None for a model with nothing to save
39
+ data_name: ClassVar[str | None] = None
40
+
41
+ @property
42
+ def signals(self) -> list[Signal[Unpack[tuple[Any, ...]]]]:
43
+ """Get the model's signals: its attributes that are one, unless a subclass adds some.
44
+
45
+ The app gives them the main view and the model, as it does the views' own signals. A
46
+ callback that asks for `main_view` or `model` by name gets it.
47
+ """
48
+ return [value for value in vars(self).values() if isinstance(value, Signal)]
49
+
50
+ def new(self) -> None:
51
+ """Start the data of a new study."""
52
+
53
+ def save_data(self, path: Path) -> None:
54
+ """Write the data into the file `path`."""
55
+
56
+ def open_data(self, path: Path) -> None:
57
+ """Replace the data with the file `path`'s.
58
+
59
+ Raises `InvalidStateError`, leaving the data unchanged, for a file that doesn't fit.
60
+ """
61
+
62
+ def loaded(self) -> None:
63
+ """React once New or Open has put both the views and the data in place."""
64
+
65
+ def close(self) -> None:
66
+ """Release what the data holds (files, connections); the app is done with it."""
@@ -0,0 +1,66 @@
1
+ """Map abstract renderer keys to each backend's concrete renderer classes.
2
+
3
+ Adding a backend means adding one `RendererType` member here, with its extra, one
4
+ ``RENDERERS`` mapping in the backend's renderers module, and
5
+ ``APPLICATION``/``MODEL_APPLICATION`` in its application module.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import enum
11
+ import importlib
12
+ from typing import TYPE_CHECKING, Any, NamedTuple
13
+
14
+ if TYPE_CHECKING:
15
+ from composeui.core.renderer import Renderer
16
+
17
+ __all__ = ["Backend", "RendererType", "create_renderer"]
18
+
19
+
20
+ class Backend(NamedTuple):
21
+ """A backend's modules, by dotted path: none is imported until the backend is used."""
22
+
23
+ #: exposes the ``RENDERERS`` mapping
24
+ renderers: str
25
+ #: exposes ``APPLICATION`` and ``MODEL_APPLICATION``, the backend's application classes
26
+ applications: str
27
+ #: the package's extra that installs what the backend imports, e.g. ``"web"``
28
+ extra: str
29
+ #: the top-level modules the extra installs: `run` names those missing
30
+ packages: tuple[str, ...]
31
+
32
+
33
+ class RendererType(enum.Enum):
34
+ """A renderer backend, identified by its modules."""
35
+
36
+ QT = Backend(
37
+ "composeui.qtcore.qtrenderer",
38
+ "composeui.qtcore.qtapplication",
39
+ "pyside6",
40
+ ("PySide6", "pyqtgraph", "qtpy"),
41
+ )
42
+ TEXTUAL = Backend(
43
+ "composeui.textualcore.textualrenderer",
44
+ "composeui.textualcore.textualapplication",
45
+ "tui",
46
+ ("textual", "textual_fspicker", "textual_plotext"),
47
+ )
48
+ WEB = Backend(
49
+ "composeui.webcore.webrenderer",
50
+ "composeui.webcore.webapplication",
51
+ "web",
52
+ ("itsdangerous", "markdown_it", "markupsafe", "python_multipart", "starlette", "uvicorn"),
53
+ )
54
+
55
+
56
+ def create_renderer(renderer_type: RendererType, key: str, view: Any) -> Renderer[Any, Any] | None:
57
+ """Create the renderer registered under `key` by `renderer_type`'s backend.
58
+
59
+ Returns None when the backend doesn't register `key`, meaning it doesn't
60
+ support that widget: the view is then not rendered.
61
+ """
62
+ module = importlib.import_module(renderer_type.value.renderers)
63
+ factory = module.RENDERERS.get(key)
64
+ if factory is None:
65
+ return None
66
+ return factory(view) # type: ignore[no-any-return]