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.
Files changed (57) hide show
  1. de_shell/__init__.py +25 -0
  2. de_shell/actions/__init__.py +0 -0
  3. de_shell/actions/context.py +62 -0
  4. de_shell/actions/figure_registry.py +53 -0
  5. de_shell/actions/lifecycle.py +295 -0
  6. de_shell/actions/registry.py +141 -0
  7. de_shell/actions/wizard.py +115 -0
  8. de_shell/app.py +170 -0
  9. de_shell/compute.py +103 -0
  10. de_shell/debug_flags.py +69 -0
  11. de_shell/ipc.py +236 -0
  12. de_shell/js/__init__.py +38 -0
  13. de_shell/js/__main__.py +4 -0
  14. de_shell/js/main/backendProcess.test.ts +70 -0
  15. de_shell/js/main/backendProcess.ts +330 -0
  16. de_shell/js/main/config.ts +53 -0
  17. de_shell/js/main/dialogs.ts +62 -0
  18. de_shell/js/main/envProgress.ts +126 -0
  19. de_shell/js/main/errorReport.ts +261 -0
  20. de_shell/js/main/index.ts +57 -0
  21. de_shell/js/main/problemLog.ts +53 -0
  22. de_shell/js/main/pythonEnv.test.ts +125 -0
  23. de_shell/js/main/pythonEnv.ts +442 -0
  24. de_shell/js/main/sentryEnvelope.test.ts +94 -0
  25. de_shell/js/main/sentryEnvelope.ts +100 -0
  26. de_shell/js/main/updater.ts +322 -0
  27. de_shell/js/main/updaterErrors.test.ts +111 -0
  28. de_shell/js/main/updaterErrors.ts +65 -0
  29. de_shell/js/main/window.ts +141 -0
  30. de_shell/js/package.json +5 -0
  31. de_shell/js/preload/index.ts +130 -0
  32. de_shell/js/renderer/FigureFrame.tsx +88 -0
  33. de_shell/js/renderer/figureBridge.react.ts +58 -0
  34. de_shell/js/renderer/figureBridge.test.ts +184 -0
  35. de_shell/js/renderer/figureBridge.ts +169 -0
  36. de_shell/js/renderer/index.ts +34 -0
  37. de_shell/js/renderer/protocol.ts +164 -0
  38. de_shell/js/renderer/shellState.test.ts +193 -0
  39. de_shell/js/renderer/shellState.ts +310 -0
  40. de_shell/js/testing/harness.cjs +244 -0
  41. de_shell/js/testing/harness.test.cjs +73 -0
  42. de_shell/log_stream.py +185 -0
  43. de_shell/plotting/__init__.py +0 -0
  44. de_shell/plotting/colormaps.py +27 -0
  45. de_shell/plotting/figure.py +601 -0
  46. de_shell/plotting/selectors/__init__.py +0 -0
  47. de_shell/plotting/selectors/utils.py +29 -0
  48. de_shell/plotting/stream.py +172 -0
  49. de_shell/process_guard.py +190 -0
  50. de_shell/session.py +211 -0
  51. de_shell/testing/__init__.py +0 -0
  52. de_shell/timing.py +28 -0
  53. de_shell-0.2.0.dist-info/METADATA +196 -0
  54. de_shell-0.2.0.dist-info/RECORD +57 -0
  55. de_shell-0.2.0.dist-info/WHEEL +5 -0
  56. de_shell-0.2.0.dist-info/licenses/LICENSE +21 -0
  57. 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)