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.
- composeui/__init__.py +11 -0
- composeui/core/__init__.py +0 -0
- composeui/core/application.py +368 -0
- composeui/core/chartcolors.py +23 -0
- composeui/core/chartimage.py +336 -0
- composeui/core/dataframesource.py +307 -0
- composeui/core/jobs.py +426 -0
- composeui/core/model.py +66 -0
- composeui/core/registry.py +66 -0
- composeui/core/reload.py +476 -0
- composeui/core/renderer.py +193 -0
- composeui/core/signal.py +358 -0
- composeui/core/sqlitemodel.py +1183 -0
- composeui/core/study.py +121 -0
- composeui/core/translation.py +221 -0
- composeui/core/translationcheck.py +254 -0
- composeui/core/view.py +5508 -0
- composeui/images/composeui.svg +15 -0
- composeui/locales/fr.json +73 -0
- composeui/mypy.py +46 -0
- composeui/py.typed +0 -0
- composeui/qtcore/__init__.py +0 -0
- composeui/qtcore/maprenderer.py +115 -0
- composeui/qtcore/qtapplication.py +222 -0
- composeui/qtcore/qtrenderer.py +2862 -0
- composeui/qtcore/widgets/__init__.py +0 -0
- composeui/qtcore/widgets/map.qml +123 -0
- composeui/qtcore/widgets/qchart.py +393 -0
- composeui/qtcore/widgets/qdatefield.py +101 -0
- composeui/qtcore/widgets/qexpander.py +60 -0
- composeui/qtcore/widgets/qfilepathfield.py +34 -0
- composeui/qtcore/widgets/qfiltertable.py +394 -0
- composeui/qtcore/widgets/qformdialog.py +64 -0
- composeui/qtcore/widgets/qformlabel.py +53 -0
- composeui/qtcore/widgets/qmap.py +68 -0
- composeui/qtcore/widgets/qmarkicon.py +37 -0
- composeui/qtcore/widgets/qmetric.py +44 -0
- composeui/qtcore/widgets/qplaintext.py +29 -0
- composeui/qtcore/widgets/qradiogroup.py +57 -0
- composeui/qtcore/widgets/qsidebar.py +116 -0
- composeui/qtcore/widgets/qsourcetable.py +216 -0
- composeui/qtcore/widgets/qsplitter.py +69 -0
- composeui/qtcore/widgets/qtexttable.py +703 -0
- composeui/qtcore/widgets/qvaluecombobox.py +41 -0
- composeui/qtcore/widgets/qvalueslider.py +64 -0
- composeui/qtcore/widgets/qwindowrelease.py +32 -0
- composeui/textualcore/__init__.py +0 -0
- composeui/textualcore/textualapplication.py +121 -0
- composeui/textualcore/textualrenderer.py +2893 -0
- composeui/textualcore/widgets/__init__.py +0 -0
- composeui/textualcore/widgets/composebutton.py +17 -0
- composeui/textualcore/widgets/composechart.py +212 -0
- composeui/textualcore/widgets/composecheckbox.py +35 -0
- composeui/textualcore/widgets/composecollapsible.py +41 -0
- composeui/textualcore/widgets/composecombobox.py +54 -0
- composeui/textualcore/widgets/composedateinput.py +325 -0
- composeui/textualcore/widgets/composedialog.py +90 -0
- composeui/textualcore/widgets/composefilepathinput.py +85 -0
- composeui/textualcore/widgets/composefiltertable.py +367 -0
- composeui/textualcore/widgets/composeformdialog.py +86 -0
- composeui/textualcore/widgets/composeinput.py +50 -0
- composeui/textualcore/widgets/composelist.py +20 -0
- composeui/textualcore/widgets/composemarkdown.py +17 -0
- composeui/textualcore/widgets/composemetric.py +60 -0
- composeui/textualcore/widgets/composeradioset.py +54 -0
- composeui/textualcore/widgets/composesidebar.py +111 -0
- composeui/textualcore/widgets/composeslider.py +144 -0
- composeui/textualcore/widgets/composesourcetable.py +288 -0
- composeui/textualcore/widgets/composesplitter.py +211 -0
- composeui/textualcore/widgets/composetabbedcontent.py +35 -0
- composeui/textualcore/widgets/composetable.py +476 -0
- composeui/textualcore/widgets/composetoolbar.py +62 -0
- composeui/textualcore/widgets/composetree.py +139 -0
- composeui/webcore/__init__.py +0 -0
- composeui/webcore/headers.py +57 -0
- composeui/webcore/icons.py +58 -0
- composeui/webcore/limits.py +55 -0
- composeui/webcore/origin.py +63 -0
- composeui/webcore/page.py +146 -0
- composeui/webcore/routes.py +303 -0
- composeui/webcore/session.py +436 -0
- composeui/webcore/static/app.css +2 -0
- composeui/webcore/static/cally.js +1312 -0
- composeui/webcore/static/daisyui.LICENSE +21 -0
- composeui/webcore/static/deckgl.LICENSE +19 -0
- composeui/webcore/static/deckgl.min.js +6423 -0
- composeui/webcore/static/htmx.min.js +1 -0
- composeui/webcore/static/page.js +1175 -0
- composeui/webcore/static/uPlot.LICENSE +21 -0
- composeui/webcore/static/uPlot.iife.min.js +2 -0
- composeui/webcore/webapplication.py +495 -0
- composeui/webcore/webrenderer.py +5113 -0
- composeui-0.1.0.dist-info/METADATA +222 -0
- composeui-0.1.0.dist-info/RECORD +97 -0
- composeui-0.1.0.dist-info/WHEEL +5 -0
- composeui-0.1.0.dist-info/licenses/LICENSE +9 -0
- 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
|
composeui/core/model.py
ADDED
|
@@ -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]
|