de-shell 0.2.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.
- de_shell/__init__.py +25 -0
- de_shell/actions/__init__.py +0 -0
- de_shell/actions/context.py +62 -0
- de_shell/actions/figure_registry.py +53 -0
- de_shell/actions/lifecycle.py +295 -0
- de_shell/actions/registry.py +141 -0
- de_shell/actions/wizard.py +115 -0
- de_shell/app.py +170 -0
- de_shell/compute.py +103 -0
- de_shell/debug_flags.py +69 -0
- de_shell/ipc.py +236 -0
- de_shell/js/__init__.py +38 -0
- de_shell/js/__main__.py +4 -0
- de_shell/js/main/backendProcess.test.ts +70 -0
- de_shell/js/main/backendProcess.ts +330 -0
- de_shell/js/main/config.ts +53 -0
- de_shell/js/main/dialogs.ts +62 -0
- de_shell/js/main/envProgress.ts +126 -0
- de_shell/js/main/errorReport.ts +261 -0
- de_shell/js/main/index.ts +57 -0
- de_shell/js/main/problemLog.ts +53 -0
- de_shell/js/main/pythonEnv.test.ts +125 -0
- de_shell/js/main/pythonEnv.ts +442 -0
- de_shell/js/main/sentryEnvelope.test.ts +94 -0
- de_shell/js/main/sentryEnvelope.ts +100 -0
- de_shell/js/main/updater.ts +322 -0
- de_shell/js/main/updaterErrors.test.ts +111 -0
- de_shell/js/main/updaterErrors.ts +65 -0
- de_shell/js/main/window.ts +141 -0
- de_shell/js/package.json +5 -0
- de_shell/js/preload/index.ts +130 -0
- de_shell/js/renderer/FigureFrame.tsx +88 -0
- de_shell/js/renderer/figureBridge.react.ts +58 -0
- de_shell/js/renderer/figureBridge.test.ts +184 -0
- de_shell/js/renderer/figureBridge.ts +169 -0
- de_shell/js/renderer/index.ts +34 -0
- de_shell/js/renderer/protocol.ts +164 -0
- de_shell/js/renderer/shellState.test.ts +193 -0
- de_shell/js/renderer/shellState.ts +310 -0
- de_shell/js/testing/harness.cjs +244 -0
- de_shell/js/testing/harness.test.cjs +73 -0
- de_shell/log_stream.py +185 -0
- de_shell/plotting/__init__.py +0 -0
- de_shell/plotting/colormaps.py +27 -0
- de_shell/plotting/figure.py +601 -0
- de_shell/plotting/selectors/__init__.py +0 -0
- de_shell/plotting/selectors/utils.py +29 -0
- de_shell/plotting/stream.py +172 -0
- de_shell/process_guard.py +190 -0
- de_shell/session.py +211 -0
- de_shell/testing/__init__.py +0 -0
- de_shell/timing.py +28 -0
- de_shell-0.2.0.dist-info/METADATA +196 -0
- de_shell-0.2.0.dist-info/RECORD +57 -0
- de_shell-0.2.0.dist-info/WHEEL +5 -0
- de_shell-0.2.0.dist-info/licenses/LICENSE +21 -0
- de_shell-0.2.0.dist-info/top_level.txt +1 -0
de_shell/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""
|
|
2
|
+
de_shell — the Python half of the DE desktop-app shell.
|
|
3
|
+
|
|
4
|
+
The substrate three applications share: SpyDE (offline EM analysis),
|
|
5
|
+
de-groundcrew (live camera/hardware control) and de-autopilot (automated
|
|
6
|
+
acquisition). It answers "how do I be a desktop app with a Python brain and
|
|
7
|
+
pictures in it?" — the asyncio stdin/stdout loop, the PLOTAPP IPC protocol, log
|
|
8
|
+
streaming, the window/figure registry, the action + staged-wizard framework, and
|
|
9
|
+
the anyplotlib plotting wrapper.
|
|
10
|
+
|
|
11
|
+
It answers nothing about what the data IS. No HyperSpy, no Dask, no
|
|
12
|
+
RosettaSciIO, no pyxem — de-groundcrew and de-autopilot are live, in-memory
|
|
13
|
+
applications and must not acquire those dependencies transitively. That
|
|
14
|
+
constraint is what fixes the boundary, and `tests/test_boundary.py` enforces
|
|
15
|
+
it in a clean subprocess: anything that answers "what is the data and what do
|
|
16
|
+
you do to it?" (the array-cache tiering, the signal tree, the navigator read
|
|
17
|
+
path, the distributed compute branch, every action handler) stays in the app.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
#: The package version — the one place it is written. pyproject.toml reads it
|
|
22
|
+
#: (dynamic version) and the release workflow refuses a tag that disagrees.
|
|
23
|
+
__version__ = "0.2.0"
|
|
24
|
+
|
|
25
|
+
__all__ = ["ipc", "log_stream", "process_guard", "debug_flags", "compute"]
|
|
File without changes
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""
|
|
2
|
+
context.py — ActionContext: the adapter passed to action functions.
|
|
3
|
+
|
|
4
|
+
An action function is handed one of these instead of reaching for the UI. It
|
|
5
|
+
carries the clicked plot, the parameter values the frontend's panel collected
|
|
6
|
+
(forwarded as kwargs), and a per-plot scratch dict for state that must outlive a
|
|
7
|
+
single invocation (an FFT window, a toggle group, a widget the action added).
|
|
8
|
+
|
|
9
|
+
Everything it touches is duck-typed — ``plot.plot_window``, ``plot.session`` —
|
|
10
|
+
so it does not care what kind of plot or session an app has.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ActionContext:
|
|
18
|
+
"""The attribute surface an action function is written against."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, plot, params: dict[str, Any] | None = None,
|
|
21
|
+
action_name: str = ""):
|
|
22
|
+
self.plot = plot
|
|
23
|
+
self.params = params or {}
|
|
24
|
+
self.action_name = action_name
|
|
25
|
+
|
|
26
|
+
# Per-plot persistent action state (FFT windows, toggle groups, …).
|
|
27
|
+
# Stored ON THE PLOT so it survives across action invocations — an
|
|
28
|
+
# ActionContext is built fresh for each one.
|
|
29
|
+
if not hasattr(plot, "_action_widgets"):
|
|
30
|
+
plot._action_widgets = {}
|
|
31
|
+
self.action_widgets = plot._action_widgets
|
|
32
|
+
|
|
33
|
+
# ── Plot / session access ─────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def plot_window(self):
|
|
37
|
+
return self.plot.plot_window
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def session(self):
|
|
41
|
+
return self.plot.session
|
|
42
|
+
|
|
43
|
+
# ── Stateful action registration ──────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
def register_action_plot_item(self, action_name: str, item, key: str) -> None:
|
|
46
|
+
slot = self.action_widgets.setdefault(action_name, {})
|
|
47
|
+
slot.setdefault("plot_items", {})[key] = item
|
|
48
|
+
|
|
49
|
+
def register_action_plot_window(self, action_name: str, plot_window, key: str) -> None:
|
|
50
|
+
slot = self.action_widgets.setdefault(action_name, {})
|
|
51
|
+
slot.setdefault("plot_windows", {})[key] = plot_window
|
|
52
|
+
|
|
53
|
+
def add_action_widget(self, action_name: str, widget=None, layout=None) -> None:
|
|
54
|
+
slot = self.action_widgets.setdefault(action_name, {})
|
|
55
|
+
slot["widget"] = widget
|
|
56
|
+
slot["layout"] = layout
|
|
57
|
+
|
|
58
|
+
def actions(self) -> list:
|
|
59
|
+
"""The toolbar lives in the frontend, so there are no host-side action
|
|
60
|
+
objects to return. Kept because action code written against the old Qt
|
|
61
|
+
toolbar still calls it."""
|
|
62
|
+
return []
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""
|
|
2
|
+
figure_registry.py — per-window keep-alive for bare anyplotlib figures.
|
|
3
|
+
|
|
4
|
+
Result windows that are NOT registered ``Plot``s emit raw ``figure`` messages
|
|
5
|
+
whose Python-side figure objects must be kept referenced, or their widget
|
|
6
|
+
callbacks are garbage-collected while the window is still open. Historically
|
|
7
|
+
each module kept its own append-only ``_ALIVE`` list, which leaked every figure
|
|
8
|
+
for the process lifetime.
|
|
9
|
+
|
|
10
|
+
This registry keys the references by ``window_id`` and is evicted from the
|
|
11
|
+
session's ``_forget_window``, so a figure lives exactly as long as its window.
|
|
12
|
+
|
|
13
|
+
Apps hang their own per-window state off the same eviction via
|
|
14
|
+
:func:`register_evictor`, rather than this module reaching into them — which is
|
|
15
|
+
what it used to do (a hardcoded import of SpyDE's ``actions.views``).
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from typing import Any, Callable
|
|
20
|
+
|
|
21
|
+
_FIGS: dict[int, list[Any]] = {}
|
|
22
|
+
|
|
23
|
+
#: App callbacks run when a window is forgotten. See `register_evictor`.
|
|
24
|
+
_EVICTORS: list[Callable[[int], None]] = []
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def register_evictor(fn: Callable[[int], None]) -> None:
|
|
28
|
+
"""Register ``fn(window_id)`` to run whenever a window is forgotten.
|
|
29
|
+
|
|
30
|
+
For app state keyed by window id that must die with the window — SpyDE's
|
|
31
|
+
per-window chip-view arrays, for instance. Registering the same function
|
|
32
|
+
twice is a no-op, so a module can call this at import without guarding.
|
|
33
|
+
"""
|
|
34
|
+
if fn not in _EVICTORS:
|
|
35
|
+
_EVICTORS.append(fn)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def keep_alive(window_id: int, fig: Any) -> None:
|
|
39
|
+
"""Keep *fig* referenced until *window_id*'s window is forgotten."""
|
|
40
|
+
_FIGS.setdefault(int(window_id), []).append(fig)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def forget_window(window_id: int) -> None:
|
|
44
|
+
"""Drop every reference held for *window_id*, and run the app's evictors."""
|
|
45
|
+
wid = int(window_id)
|
|
46
|
+
_FIGS.pop(wid, None)
|
|
47
|
+
for fn in _EVICTORS:
|
|
48
|
+
try:
|
|
49
|
+
fn(wid)
|
|
50
|
+
except Exception:
|
|
51
|
+
# Teardown must not fail: this runs while a window is going away,
|
|
52
|
+
# and one app's bookkeeping error should not strand the rest.
|
|
53
|
+
pass
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
"""
|
|
2
|
+
lifecycle.py — the shared basis set for interactive actions.
|
|
3
|
+
|
|
4
|
+
Every heavy action in every shell app repeats the same wiring: run the compute
|
|
5
|
+
on a daemon thread and marshal the UI apply back to the asyncio main thread,
|
|
6
|
+
guard against superseded runs (React StrictMode double-mount, rapid re-tune),
|
|
7
|
+
swap a controller/overlay for a newer one, and narrate progress. These helpers
|
|
8
|
+
are the single implementation of those idioms.
|
|
9
|
+
|
|
10
|
+
What is here is only the part that knows nothing about the data. SpyDE's
|
|
11
|
+
`spyde/actions/lifecycle.py` re-exports all of it alongside its own
|
|
12
|
+
domain lifecycle (the find-vectors attach gap, painting a tree's signal plots,
|
|
13
|
+
the progressive shared-memory fill), so an action keeps importing one module and
|
|
14
|
+
does not have to know which half a given helper came from.
|
|
15
|
+
|
|
16
|
+
THREADING CONTRACT: UI/figure updates happen on the asyncio main thread only.
|
|
17
|
+
Workers marshal via ``session._dispatch_to_main``; ``de_shell.ipc.emit*`` is safe
|
|
18
|
+
from any thread.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import logging
|
|
23
|
+
import threading
|
|
24
|
+
import time
|
|
25
|
+
from typing import Any, Callable
|
|
26
|
+
|
|
27
|
+
log = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ── worker-thread marshal ─────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
#: Guards the per-session in-flight tally below. One lock for the process: the
|
|
33
|
+
#: critical section is an integer add.
|
|
34
|
+
_WORKER_COUNT_LOCK = threading.Lock()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _count_worker(session, delta: int) -> None:
|
|
38
|
+
with _WORKER_COUNT_LOCK:
|
|
39
|
+
try:
|
|
40
|
+
session._inflight_workers = getattr(
|
|
41
|
+
session, "_inflight_workers", 0) + delta
|
|
42
|
+
except Exception: # a bare stub session that refuses attributes
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def inflight_workers(session) -> int:
|
|
47
|
+
"""How many :func:`run_on_worker` jobs are still running for *session*.
|
|
48
|
+
|
|
49
|
+
Background work started here was otherwise invisible: it is a bare daemon
|
|
50
|
+
thread with no pool and no registry, so nothing could answer "is anything
|
|
51
|
+
still running?". Anyone needing to know had to pick some VISIBLE side
|
|
52
|
+
effect of the work and wait for that instead — a proxy that is right until
|
|
53
|
+
the day the value it watches is set somewhere else too, or set before the
|
|
54
|
+
work is really done.
|
|
55
|
+
|
|
56
|
+
Zero means every job has finished AND handed its ``on_done`` to the
|
|
57
|
+
dispatcher; it does NOT mean those callbacks have run. Pair it with a
|
|
58
|
+
round-trip through the loop for that.
|
|
59
|
+
"""
|
|
60
|
+
with _WORKER_COUNT_LOCK:
|
|
61
|
+
return int(getattr(session, "_inflight_workers", 0) or 0)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def run_on_worker(session, work: Callable[[], Any], *, name: str,
|
|
65
|
+
on_done: Callable[[Any], None] | None = None,
|
|
66
|
+
on_error: Callable[[Exception], None] | None = None) -> None:
|
|
67
|
+
"""Run ``work()`` on a daemon thread and marshal ``on_done(result)`` back
|
|
68
|
+
onto the asyncio main thread via ``session._dispatch_to_main``.
|
|
69
|
+
|
|
70
|
+
``on_error(exc)`` runs on the worker thread (it typically just
|
|
71
|
+
``emit_error``\\s, which is thread-safe). When *session* can't marshal
|
|
72
|
+
(``None`` or a bare test stub without ``_dispatch_to_main``) everything
|
|
73
|
+
runs inline synchronously, so handler tests see the result immediately.
|
|
74
|
+
"""
|
|
75
|
+
dispatch = getattr(session, "_dispatch_to_main", None)
|
|
76
|
+
if dispatch is None:
|
|
77
|
+
try:
|
|
78
|
+
result = work()
|
|
79
|
+
except Exception as e:
|
|
80
|
+
log.exception("%s failed", name)
|
|
81
|
+
if on_error is not None:
|
|
82
|
+
on_error(e)
|
|
83
|
+
return
|
|
84
|
+
if on_done is not None:
|
|
85
|
+
on_done(result)
|
|
86
|
+
return
|
|
87
|
+
|
|
88
|
+
def _worker():
|
|
89
|
+
try:
|
|
90
|
+
try:
|
|
91
|
+
result = work()
|
|
92
|
+
except Exception as e:
|
|
93
|
+
log.exception("%s failed", name)
|
|
94
|
+
if on_error is not None:
|
|
95
|
+
on_error(e)
|
|
96
|
+
return
|
|
97
|
+
if on_done is not None:
|
|
98
|
+
dispatch(lambda: on_done(result))
|
|
99
|
+
finally:
|
|
100
|
+
# AFTER the dispatch, deliberately: a caller that sees the count
|
|
101
|
+
# reach zero has to be able to conclude the completion callback is
|
|
102
|
+
# at least QUEUED. Decrementing first would let it conclude that
|
|
103
|
+
# while `on_done` is still to be handed to the loop.
|
|
104
|
+
_count_worker(session, -1)
|
|
105
|
+
|
|
106
|
+
_count_worker(session, +1)
|
|
107
|
+
threading.Thread(target=_worker, daemon=True, name=name).start()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# ── cancellation (a superseded compute is stopped, not ignored) ───────────────
|
|
111
|
+
|
|
112
|
+
class ComputeHandle:
|
|
113
|
+
"""The cancellation handle for one dispatched compute.
|
|
114
|
+
|
|
115
|
+
``flag`` is the ``[False]`` stop token the work polls; ``future`` is the
|
|
116
|
+
future it runs as, when there is one. Constructing a handle registers both
|
|
117
|
+
on the signal tree, so closing the tree stops the compute.
|
|
118
|
+
|
|
119
|
+
A superseded or abandoned compute must be CANCELLED, not left running so its
|
|
120
|
+
result can be discarded. The generation guard is not a substitute: it drops
|
|
121
|
+
the result on arrival while the pass keeps reading the dataset, and a pass
|
|
122
|
+
over the dataset is the most expensive thing the app does.
|
|
123
|
+
|
|
124
|
+
A compute with no interruption point — one library call over an array
|
|
125
|
+
already in memory — can still take a handle. The flag then stops it before
|
|
126
|
+
it starts and drops a late result, which is all that is available.
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
__slots__ = ("flag", "future", "_tree")
|
|
130
|
+
|
|
131
|
+
def __init__(self, tree, future=None):
|
|
132
|
+
self.flag: list = [False]
|
|
133
|
+
self.future = future
|
|
134
|
+
self._tree = tree
|
|
135
|
+
register = getattr(tree, "register_cancel", None)
|
|
136
|
+
if register is not None:
|
|
137
|
+
register(flag=self.flag, future=future)
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def stopped(self) -> bool:
|
|
141
|
+
return bool(self.flag[0])
|
|
142
|
+
|
|
143
|
+
def attach(self, future) -> None:
|
|
144
|
+
"""Adopt a future created after the handle, registering it too."""
|
|
145
|
+
self.future = future
|
|
146
|
+
register = getattr(self._tree, "register_cancel", None)
|
|
147
|
+
if register is not None and future is not None:
|
|
148
|
+
register(future=future)
|
|
149
|
+
|
|
150
|
+
def cancel(self) -> None:
|
|
151
|
+
"""Stop the compute and drop it from the tree's registry."""
|
|
152
|
+
self.flag[0] = True
|
|
153
|
+
if self.future is not None:
|
|
154
|
+
try:
|
|
155
|
+
if not self.future.done():
|
|
156
|
+
self.future.cancel()
|
|
157
|
+
except Exception as e:
|
|
158
|
+
log.debug("cancelling a superseded compute failed: %s", e)
|
|
159
|
+
self._unregister()
|
|
160
|
+
|
|
161
|
+
def retire(self) -> None:
|
|
162
|
+
"""Drop a finished compute's registration, without marking it stopped.
|
|
163
|
+
|
|
164
|
+
Required, or the registry gains an entry per run and a long-lived tree
|
|
165
|
+
accumulates one for every interaction.
|
|
166
|
+
"""
|
|
167
|
+
self._unregister()
|
|
168
|
+
|
|
169
|
+
def _unregister(self) -> None:
|
|
170
|
+
unregister = getattr(self._tree, "unregister_cancel", None)
|
|
171
|
+
if unregister is None:
|
|
172
|
+
return
|
|
173
|
+
try:
|
|
174
|
+
unregister(flag=self.flag, future=self.future)
|
|
175
|
+
except Exception as e: # pragma: no cover
|
|
176
|
+
log.debug("unregistering a compute failed: %s", e)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def supersede(prior: "ComputeHandle | None", tree, future=None) -> ComputeHandle:
|
|
180
|
+
"""Cancel *prior* and return the handle for the compute replacing it."""
|
|
181
|
+
if prior is not None:
|
|
182
|
+
prior.cancel()
|
|
183
|
+
return ComputeHandle(tree, future)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# ── generation guard (latest-wins / StrictMode double-mount) ──────────────────
|
|
187
|
+
|
|
188
|
+
def bump_generation(owner, key: str) -> int:
|
|
189
|
+
"""Bump and return ``owner.<key>`` (an int generation counter).
|
|
190
|
+
|
|
191
|
+
The run/stop generation contract: a wizard's *open* handler bumps its
|
|
192
|
+
``_<key>_run_gen`` synchronously BEFORE spawning any worker, and every
|
|
193
|
+
deferred build checks ``is_current`` on arrival; the *close* handler bumps
|
|
194
|
+
unconditionally FIRST, cancelling any in-flight open. This closes the React
|
|
195
|
+
StrictMode mount→cleanup→remount race (open, close, open fired synchronously
|
|
196
|
+
before any worker lands) that otherwise builds two live controllers. Also
|
|
197
|
+
used per-controller for latest-wins recomputes.
|
|
198
|
+
"""
|
|
199
|
+
gen = int(getattr(owner, key, 0)) + 1
|
|
200
|
+
setattr(owner, key, gen)
|
|
201
|
+
return gen
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def is_current(owner, key: str, gen: int) -> bool:
|
|
205
|
+
"""True if *gen* is still ``owner.<key>``'s current generation."""
|
|
206
|
+
return getattr(owner, key, None) == gen
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
# ── controller / overlay replacement ──────────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
def replace_tree_attr(owner, attr: str, factory: Callable[[], Any] | None):
|
|
212
|
+
"""Replace ``owner.<attr>`` (an overlay/controller) with ``factory()``,
|
|
213
|
+
removing the prior one first so re-running an action never stacks markers.
|
|
214
|
+
Pass ``factory=None`` to just remove. Returns the new value (None on a
|
|
215
|
+
failed attach — logged, not raised)."""
|
|
216
|
+
old = getattr(owner, attr, None)
|
|
217
|
+
if old is not None and hasattr(old, "remove"):
|
|
218
|
+
try:
|
|
219
|
+
old.remove()
|
|
220
|
+
except Exception as e:
|
|
221
|
+
log.debug("removing prior %s failed: %s", attr, e)
|
|
222
|
+
setattr(owner, attr, None)
|
|
223
|
+
if factory is None:
|
|
224
|
+
return None
|
|
225
|
+
try:
|
|
226
|
+
new = factory()
|
|
227
|
+
except Exception as e:
|
|
228
|
+
log.debug("attaching %s failed: %s", attr, e)
|
|
229
|
+
new = None
|
|
230
|
+
setattr(owner, attr, new)
|
|
231
|
+
return new
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
# ── progress narration ────────────────────────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
def progress_emitter(prefix: str, *, min_interval: float = 0.5) -> Callable[[int, int], None]:
|
|
237
|
+
"""A throttled ``progress(done, total)`` callback that emits
|
|
238
|
+
``"{prefix} {pct}%"`` status lines (always emits the 100% line)."""
|
|
239
|
+
from de_shell.ipc import emit_status
|
|
240
|
+
last = [0.0]
|
|
241
|
+
|
|
242
|
+
def progress(done, total):
|
|
243
|
+
if not total:
|
|
244
|
+
return
|
|
245
|
+
now = time.monotonic()
|
|
246
|
+
if done < total and now - last[0] < min_interval:
|
|
247
|
+
return
|
|
248
|
+
last[0] = now
|
|
249
|
+
emit_status(f"{prefix} {int(100 * done / total)}%")
|
|
250
|
+
|
|
251
|
+
return progress
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
# ── per-window "Calculating…" overlay ─────────────────────────────────────────
|
|
255
|
+
|
|
256
|
+
class window_computing:
|
|
257
|
+
"""Context manager: emit ``window_computing`` start/stop around a long
|
|
258
|
+
compute that paints into ``window_id`` — drives the renderer's floating
|
|
259
|
+
translucent "Calculating…" chip centered on that plot window.
|
|
260
|
+
|
|
261
|
+
ALWAYS emits the matching stop, even on exception — the ``__exit__`` runs
|
|
262
|
+
unconditionally, so a cancelled or failed compute cannot leave the overlay
|
|
263
|
+
stuck. ``window_id=None`` is a silent no-op both ways (mirrors
|
|
264
|
+
``emit_window_computing``'s own guard) so call sites don't need to
|
|
265
|
+
special-case an unattached plot.
|
|
266
|
+
|
|
267
|
+
Usage::
|
|
268
|
+
|
|
269
|
+
with window_computing(nav_plot.window_id):
|
|
270
|
+
...long fill...
|
|
271
|
+
|
|
272
|
+
or, when the start/stop don't naturally bracket a single call (e.g. a
|
|
273
|
+
background thread that outlives this function), call ``.start()`` /
|
|
274
|
+
``.stop()`` directly and put ``.stop()`` in the thread's own
|
|
275
|
+
``try/finally``.
|
|
276
|
+
"""
|
|
277
|
+
|
|
278
|
+
def __init__(self, window_id: int | None):
|
|
279
|
+
self.window_id = window_id
|
|
280
|
+
|
|
281
|
+
def start(self) -> None:
|
|
282
|
+
from de_shell.ipc import emit_window_computing
|
|
283
|
+
emit_window_computing(self.window_id, True)
|
|
284
|
+
|
|
285
|
+
def stop(self) -> None:
|
|
286
|
+
from de_shell.ipc import emit_window_computing
|
|
287
|
+
emit_window_computing(self.window_id, False)
|
|
288
|
+
|
|
289
|
+
def __enter__(self) -> "window_computing":
|
|
290
|
+
self.start()
|
|
291
|
+
return self
|
|
292
|
+
|
|
293
|
+
def __exit__(self, exc_type, exc, tb) -> bool:
|
|
294
|
+
self.stop()
|
|
295
|
+
return False
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""
|
|
2
|
+
registry.py — the staged-action registry and the window-controller protocol.
|
|
3
|
+
|
|
4
|
+
A shell app has exactly TWO dispatch paths for renderer→backend actions; do not
|
|
5
|
+
invent a third:
|
|
6
|
+
|
|
7
|
+
1. **Toolbar actions** — declared in the app's toolbar config, resolved and
|
|
8
|
+
invoked by the session with an :class:`~de_shell.actions.context.ActionContext`.
|
|
9
|
+
2. **Staged actions** — the wizard/caret protocol: an action name maps to a
|
|
10
|
+
``"module.function"`` dotted path with the uniform ``fn(session, plot,
|
|
11
|
+
payload)`` signature. Modules are imported LAZILY, so heavy dependencies load
|
|
12
|
+
on first use rather than at startup — which is most of why the table is
|
|
13
|
+
dotted strings instead of function references.
|
|
14
|
+
|
|
15
|
+
Staged-action NAMING CONVENTION (``<key>`` is the wizard's short prefix):
|
|
16
|
+
|
|
17
|
+
<key>_open wizard mounted → start live preview / controller
|
|
18
|
+
<key>_close wizard unmounted → tear everything down
|
|
19
|
+
<key>_tune debounced live re-tune of preview params
|
|
20
|
+
<key>_set_<param> discrete parameter change
|
|
21
|
+
<key>_run heavy compute stage (may open a result tree)
|
|
22
|
+
<key>_commit snapshot the live result into a new result tree
|
|
23
|
+
|
|
24
|
+
Wizard-specific extra stages are allowed but must keep the ``<key>_`` prefix.
|
|
25
|
+
|
|
26
|
+
**The tables live in the app, the mechanism lives here.** An app calls
|
|
27
|
+
:func:`register_staged` (or :func:`register_staged_table`) at import to populate
|
|
28
|
+
the registry, and :func:`register_wizard_schema` to say where each wizard's
|
|
29
|
+
parameter schema is declared. The shell has no business knowing that
|
|
30
|
+
``fit_open`` exists.
|
|
31
|
+
|
|
32
|
+
WindowController protocol
|
|
33
|
+
-------------------------
|
|
34
|
+
Windows that are NOT registered plots (bare ``figure`` emits) must register a
|
|
35
|
+
*controller* with ``session.register_window_controller(window_id, controller)``
|
|
36
|
+
so dispatch and teardown can reach them. A controller is duck-typed:
|
|
37
|
+
|
|
38
|
+
window_id: int # the window it drives
|
|
39
|
+
close() -> None # full teardown; called by the session's
|
|
40
|
+
# _forget_window when the window goes
|
|
41
|
+
# away for ANY reason
|
|
42
|
+
handle_action(name, payload) -> bool # optional: consume an action aimed
|
|
43
|
+
# at this window; return True if
|
|
44
|
+
# handled
|
|
45
|
+
|
|
46
|
+
:class:`de_shell.actions.wizard.WizardController` provides a base implementation.
|
|
47
|
+
"""
|
|
48
|
+
from __future__ import annotations
|
|
49
|
+
|
|
50
|
+
import importlib
|
|
51
|
+
from typing import Callable
|
|
52
|
+
|
|
53
|
+
#: action name → "module.function". Populated by the app.
|
|
54
|
+
STAGED_HANDLERS: dict[str, str] = {}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def register_staged(name: str, dotted_path: str) -> None:
|
|
58
|
+
"""Register a staged action (``fn(session, plot, payload)``) by dotted path."""
|
|
59
|
+
STAGED_HANDLERS[name] = dotted_path
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def register_staged_table(table: dict[str, str]) -> None:
|
|
63
|
+
"""Register a whole table at once. Later registrations win, so an app can
|
|
64
|
+
override a default."""
|
|
65
|
+
STAGED_HANDLERS.update(table)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def resolve_staged(name: str) -> Callable | None:
|
|
69
|
+
"""Lazily import and return the handler for a staged action name."""
|
|
70
|
+
dotted = STAGED_HANDLERS.get(name)
|
|
71
|
+
if dotted is None:
|
|
72
|
+
return None
|
|
73
|
+
mod, fn = dotted.rsplit(".", 1)
|
|
74
|
+
return getattr(importlib.import_module(mod), fn)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
78
|
+
# Wizard parameter schemas — the single host-agnostic lookup
|
|
79
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
80
|
+
#
|
|
81
|
+
# Every wizard declares its parameter schema in one place — a `parameters`
|
|
82
|
+
# classattr on its WizardController, or a module-level dict for controller-less
|
|
83
|
+
# wizards — in the same spec as the toolbar config's `parameters:`. This table
|
|
84
|
+
# maps a wizard key to wherever its schema lives, so ANY host (an Electron
|
|
85
|
+
# panel, a notebook form generator, a doc generator) resolves them uniformly.
|
|
86
|
+
|
|
87
|
+
#: wizard key → (module, attribute), or (`YAML_SCHEMA`, toolbar action title).
|
|
88
|
+
_WIZARD_SCHEMAS: dict[str, tuple[str, str]] = {}
|
|
89
|
+
|
|
90
|
+
#: Sentinel module name meaning "resolve this from the app's toolbar config".
|
|
91
|
+
YAML_SCHEMA = "__yaml__"
|
|
92
|
+
|
|
93
|
+
#: Set by `set_yaml_schema_resolver`. Takes a toolbar action title, returns its
|
|
94
|
+
#: `parameters` dict.
|
|
95
|
+
_yaml_resolver: Callable[[str], dict] | None = None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def register_wizard_schema(key: str, module: str, attr: str) -> None:
|
|
99
|
+
"""Declare where wizard *key*'s parameter schema lives.
|
|
100
|
+
|
|
101
|
+
``module=YAML_SCHEMA`` means "look *attr* up as a toolbar action title via
|
|
102
|
+
the registered YAML resolver".
|
|
103
|
+
"""
|
|
104
|
+
_WIZARD_SCHEMAS[key] = (module, attr)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def register_wizard_schemas(table: dict[str, tuple[str, str]]) -> None:
|
|
108
|
+
"""Register a whole schema table at once."""
|
|
109
|
+
_WIZARD_SCHEMAS.update(table)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def set_yaml_schema_resolver(fn: Callable[[str], dict]) -> None:
|
|
113
|
+
"""Install the app's toolbar-config lookup for ``YAML_SCHEMA`` entries."""
|
|
114
|
+
global _yaml_resolver
|
|
115
|
+
_yaml_resolver = fn
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def wizard_parameters(key: str) -> dict:
|
|
119
|
+
"""Return wizard ``key``'s declared parameter schema (a copy).
|
|
120
|
+
|
|
121
|
+
The uniform entry point for rendering a wizard's controls in ANY host —
|
|
122
|
+
same spec as the toolbar config's ``parameters:`` (type/name/default/min/
|
|
123
|
+
max/step/choices/tab/extensions). Raises ``KeyError`` for unknown keys.
|
|
124
|
+
"""
|
|
125
|
+
module, attr = _WIZARD_SCHEMAS[key]
|
|
126
|
+
if module == YAML_SCHEMA:
|
|
127
|
+
if _yaml_resolver is None:
|
|
128
|
+
raise RuntimeError(
|
|
129
|
+
f"wizard {key!r} declares a toolbar-config schema, but no "
|
|
130
|
+
"resolver is installed — call set_yaml_schema_resolver() at "
|
|
131
|
+
"app startup."
|
|
132
|
+
)
|
|
133
|
+
return _yaml_resolver(attr)
|
|
134
|
+
obj = getattr(importlib.import_module(module), attr)
|
|
135
|
+
schema = obj if isinstance(obj, dict) else getattr(obj, "parameters", {})
|
|
136
|
+
return dict(schema)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def wizard_keys() -> list[str]:
|
|
140
|
+
"""All wizard keys with a declared schema."""
|
|
141
|
+
return list(_WIZARD_SCHEMAS)
|