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
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""
|
|
2
|
+
wizard.py — WizardController, the base class for staged-wizard actions.
|
|
3
|
+
|
|
4
|
+
A *wizard* is the staged action shape (see the shell's actions/registry.py): the
|
|
5
|
+
renderer caret mounts → ``<key>_open`` starts a live preview / controller;
|
|
6
|
+
parameter edits stream in (``<key>_tune`` / ``<key>_set_<param>``); a heavy
|
|
7
|
+
stage runs (``<key>_run``); an optional Commit snapshots the live result into
|
|
8
|
+
a new SignalTree (``<key>_commit`` → :meth:`commit` →
|
|
9
|
+
the app's commit helper); unmount → ``<key>_close`` tears
|
|
10
|
+
everything down.
|
|
11
|
+
|
|
12
|
+
The controller owns the wizard's state (library / overlay / field / windows)
|
|
13
|
+
instead of a bare dict on the tree, and provides the lifecycle plumbing every
|
|
14
|
+
wizard needs:
|
|
15
|
+
|
|
16
|
+
* the run/stop **generation guard** (:meth:`guard` / :meth:`still` /
|
|
17
|
+
:meth:`cancel_inflight`) — see ``lifecycle.bump_generation`` for the React
|
|
18
|
+
StrictMode contract (open, close, open fired synchronously before any
|
|
19
|
+
worker lands must leave exactly ONE live controller);
|
|
20
|
+
* **window registration** (:meth:`own_window`) so bare-figure windows the
|
|
21
|
+
wizard opens are reachable by dispatch and torn down by
|
|
22
|
+
``Session._forget_window`` (which calls :meth:`close`);
|
|
23
|
+
* the **worker marshal** (:meth:`run_on_worker`) bound to the session;
|
|
24
|
+
* **overlay replacement** (:meth:`replace_overlay`).
|
|
25
|
+
|
|
26
|
+
Subclasses override :meth:`remove` (full teardown — MUST be idempotent; guard
|
|
27
|
+
with ``self._closed``) and optionally :meth:`commit`.
|
|
28
|
+
"""
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import logging
|
|
32
|
+
from typing import Any, Callable
|
|
33
|
+
|
|
34
|
+
from de_shell.actions.lifecycle import (
|
|
35
|
+
bump_generation, is_current, replace_tree_attr, run_on_worker as _run_on_worker,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
log = logging.getLogger(__name__)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class WizardController:
|
|
42
|
+
#: the wizard's short prefix — "strain" → ``tree._strain_run_gen`` and the
|
|
43
|
+
#: ``strain_*`` staged-action names.
|
|
44
|
+
key: str = ""
|
|
45
|
+
|
|
46
|
+
#: Declared parameter schema — REQUIRED for every wizard. The same dict
|
|
47
|
+
#: spec as ``toolbars.yaml parameters:`` / ``Action.parameters`` (``type``
|
|
48
|
+
#: int/float/bool/enum/file, ``name``, ``default``, ``min``/``max``/
|
|
49
|
+
#: ``step``, ``choices``, ``tab``, ``extensions``), so any host — the
|
|
50
|
+
#: Electron caret or an auto-generated notebook form — can render the
|
|
51
|
+
#: wizard's controls from one source of truth. Resolved host-agnostically
|
|
52
|
+
#: via ``registry.wizard_parameters(key)``; completeness is enforced by
|
|
53
|
+
#: ``test_wizard_schemas.py``.
|
|
54
|
+
parameters: dict = {}
|
|
55
|
+
|
|
56
|
+
def __init__(self, session, tree):
|
|
57
|
+
self.session = session
|
|
58
|
+
self.tree = tree
|
|
59
|
+
self._closed = False
|
|
60
|
+
|
|
61
|
+
# ── generation guard ──────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def _gen_key(self) -> str:
|
|
65
|
+
return f"_{self.key}_run_gen"
|
|
66
|
+
|
|
67
|
+
def guard(self) -> int:
|
|
68
|
+
"""Open a new run generation (call synchronously in the open handler,
|
|
69
|
+
BEFORE spawning any worker). Deferred builds check :meth:`still`."""
|
|
70
|
+
return bump_generation(self.tree, self._gen_key)
|
|
71
|
+
|
|
72
|
+
def still(self, gen: int) -> bool:
|
|
73
|
+
"""True if *gen* is still the current run generation."""
|
|
74
|
+
return is_current(self.tree, self._gen_key, gen)
|
|
75
|
+
|
|
76
|
+
def cancel_inflight(self) -> None:
|
|
77
|
+
"""Invalidate any in-flight open (call FIRST in the close handler)."""
|
|
78
|
+
bump_generation(self.tree, self._gen_key)
|
|
79
|
+
|
|
80
|
+
# ── plumbing ──────────────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
def own_window(self, window_id) -> None:
|
|
83
|
+
"""Register this controller for a bare-figure window it opened."""
|
|
84
|
+
if window_id is None or self.session is None:
|
|
85
|
+
return
|
|
86
|
+
reg = getattr(self.session, "register_window_controller", None)
|
|
87
|
+
if reg is not None:
|
|
88
|
+
reg(int(window_id), self)
|
|
89
|
+
|
|
90
|
+
def run_on_worker(self, work: Callable[[], Any], *, name: str | None = None,
|
|
91
|
+
on_done=None, on_error=None) -> None:
|
|
92
|
+
_run_on_worker(self.session, work, name=name or f"{self.key}-worker",
|
|
93
|
+
on_done=on_done, on_error=on_error)
|
|
94
|
+
|
|
95
|
+
def replace_overlay(self, attr: str, factory):
|
|
96
|
+
"""Swap ``tree.<attr>`` for a fresh overlay, removing the prior one."""
|
|
97
|
+
return replace_tree_attr(self.tree, attr, factory)
|
|
98
|
+
|
|
99
|
+
# ── lifecycle hooks ───────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
def close(self) -> None:
|
|
102
|
+
"""WindowController protocol — ``Session._forget_window`` calls this
|
|
103
|
+
when an owned window goes away for any reason. Default: full teardown."""
|
|
104
|
+
self.remove()
|
|
105
|
+
|
|
106
|
+
def remove(self) -> None:
|
|
107
|
+
"""Full teardown of everything the wizard added. MUST be idempotent
|
|
108
|
+
(guard with ``self._closed``)."""
|
|
109
|
+
raise NotImplementedError
|
|
110
|
+
|
|
111
|
+
def commit(self):
|
|
112
|
+
"""Snapshot the live result into a new SignalTree (the ``<key>_commit``
|
|
113
|
+
stage) — implement with the app's commit helper.
|
|
114
|
+
Returns the new tree."""
|
|
115
|
+
raise NotImplementedError(f"{type(self).__name__} has no commit stage")
|
de_shell/app.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""
|
|
2
|
+
app.py — the asyncio backend loop, minus anything app-specific.
|
|
3
|
+
|
|
4
|
+
Reads JSON messages from stdin (Electron writes them), routes each to the
|
|
5
|
+
session, and lets the session push replies through ``de_shell.ipc.emit``. This
|
|
6
|
+
is the process's main thread: everything that touches a figure has to be
|
|
7
|
+
marshalled back onto it (``SessionBase._dispatch_to_main``).
|
|
8
|
+
|
|
9
|
+
Usage from an app's ``__main__``::
|
|
10
|
+
|
|
11
|
+
from de_shell.app import run
|
|
12
|
+
run(build_session=lambda: MySession(), app_packages=("de_groundcrew",))
|
|
13
|
+
|
|
14
|
+
Message routing is fixed for the three the shell owns — ``action``,
|
|
15
|
+
``figure_event``, ``resize``, plus ``quit`` and the ``tick`` no-op (which
|
|
16
|
+
arrives both as its own type and as an action named ``tick``) — and open
|
|
17
|
+
for everything else: pass ``on_message`` to handle app-specific envelopes (SpyDE
|
|
18
|
+
routes its flat ``{"command": "console_*"}`` messages that way).
|
|
19
|
+
|
|
20
|
+
The `tick` no-op matters more than it looks. Windows throttles timer delivery to
|
|
21
|
+
a hidden child process so hard that its waits can freeze until process I/O
|
|
22
|
+
arrives, so Electron writes a 0.5 Hz tick purely to keep this process
|
|
23
|
+
schedulable. Dropping it as "an unknown message" would log a warning twice a
|
|
24
|
+
second forever.
|
|
25
|
+
"""
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import asyncio
|
|
29
|
+
import logging
|
|
30
|
+
import os
|
|
31
|
+
import sys
|
|
32
|
+
from typing import Callable, Iterable
|
|
33
|
+
|
|
34
|
+
from de_shell import ipc, log_stream, process_guard
|
|
35
|
+
|
|
36
|
+
log = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _dispatch_figure_event(msg: dict) -> None:
|
|
40
|
+
"""Forward a frontend interaction event to the anyplotlib figure."""
|
|
41
|
+
fig_id = msg.get("fig_id")
|
|
42
|
+
event_json = msg.get("event_json")
|
|
43
|
+
if fig_id is None or event_json is None:
|
|
44
|
+
return
|
|
45
|
+
import anyplotlib._electron as _ael
|
|
46
|
+
_ael.dispatch_event(fig_id, event_json)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _resize_figure(msg: dict) -> None:
|
|
50
|
+
"""Apply a window resize to the anyplotlib figure layout."""
|
|
51
|
+
fig_id = msg.get("fig_id")
|
|
52
|
+
if fig_id is None:
|
|
53
|
+
return
|
|
54
|
+
import anyplotlib._electron as _ael
|
|
55
|
+
w, h = int(msg.get("width", 600)), int(msg.get("height", 400))
|
|
56
|
+
log.debug("resize figure %s -> %dx%d", fig_id, w, h)
|
|
57
|
+
_ael.resize_figure(fig_id, w, h)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _install_logging(app_packages: Iterable[str], log_level_env: str) -> None:
|
|
61
|
+
"""Stream logs to the frontend panel, and tee to stderr when asked.
|
|
62
|
+
|
|
63
|
+
The tee is what makes a backend failure visible to Playwright: ``emit`` goes
|
|
64
|
+
down the PLOTAPP stdout channel, which the Electron main process consumes,
|
|
65
|
+
so without this a backend that dies mid-test dies silently.
|
|
66
|
+
"""
|
|
67
|
+
level = os.environ.get(log_level_env)
|
|
68
|
+
if level:
|
|
69
|
+
handler = logging.StreamHandler(sys.stderr)
|
|
70
|
+
handler.setLevel(getattr(logging, level.upper(), logging.INFO))
|
|
71
|
+
handler.setFormatter(
|
|
72
|
+
logging.Formatter("%(asctime)s %(name)s %(levelname)s %(message)s"))
|
|
73
|
+
logging.getLogger().addHandler(handler)
|
|
74
|
+
logging.getLogger().setLevel(getattr(logging, level.upper(), logging.INFO))
|
|
75
|
+
log_stream.install(level=level or "INFO")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
async def _main(
|
|
79
|
+
build_session: Callable[[], object],
|
|
80
|
+
app_packages: Iterable[str],
|
|
81
|
+
log_level_env: str,
|
|
82
|
+
on_message: Callable[[object, dict], bool] | None,
|
|
83
|
+
on_ready: Callable[[object], None] | None,
|
|
84
|
+
) -> None:
|
|
85
|
+
ipc.redirect_stray_stdout()
|
|
86
|
+
|
|
87
|
+
# FIRST: real timer interrupts. Windows throttles timers for this hidden
|
|
88
|
+
# Electron child, freezing every timer-driven wait in the process (poll
|
|
89
|
+
# loops, Event.wait) until I/O arrives — the "it only finishes when you
|
|
90
|
+
# click" bug. See process_guard.unthrottle_windows_timers.
|
|
91
|
+
try:
|
|
92
|
+
process_guard.unthrottle_windows_timers()
|
|
93
|
+
except Exception as e:
|
|
94
|
+
log.warning("timer unthrottle failed: %s", e)
|
|
95
|
+
# Guarantee any worker subprocesses die with this process: a Windows
|
|
96
|
+
# kill-on-close Job Object makes the OS reap the whole tree however we
|
|
97
|
+
# exit. Best-effort no-op off Windows.
|
|
98
|
+
try:
|
|
99
|
+
process_guard.install_kill_on_close()
|
|
100
|
+
except Exception as e:
|
|
101
|
+
log.debug("process guard install failed: %s", e)
|
|
102
|
+
|
|
103
|
+
_install_logging(app_packages, log_level_env)
|
|
104
|
+
|
|
105
|
+
session = build_session()
|
|
106
|
+
|
|
107
|
+
if on_ready is not None:
|
|
108
|
+
on_ready(session)
|
|
109
|
+
|
|
110
|
+
ipc.emit({"type": "ready"})
|
|
111
|
+
|
|
112
|
+
loop = asyncio.get_event_loop()
|
|
113
|
+
# Let background workers marshal their result-applies onto this thread.
|
|
114
|
+
session.set_main_loop(loop)
|
|
115
|
+
|
|
116
|
+
async for msg in ipc.read_messages(loop):
|
|
117
|
+
msg_type = msg.get("type")
|
|
118
|
+
try:
|
|
119
|
+
if msg_type == "action":
|
|
120
|
+
# The keepalive arrives as an ACTION named tick (that is how
|
|
121
|
+
# @de/shell-main sends it), so it must be swallowed here, not
|
|
122
|
+
# left to every app's action table to remember.
|
|
123
|
+
if msg.get("action") != "tick":
|
|
124
|
+
session.dispatch_action(msg)
|
|
125
|
+
elif msg_type == "figure_event":
|
|
126
|
+
_dispatch_figure_event(msg)
|
|
127
|
+
elif msg_type == "resize":
|
|
128
|
+
_resize_figure(msg)
|
|
129
|
+
elif msg_type == "tick":
|
|
130
|
+
pass # see the module docstring — deliberately silent
|
|
131
|
+
elif msg_type == "quit":
|
|
132
|
+
break
|
|
133
|
+
elif on_message is not None and on_message(session, msg):
|
|
134
|
+
pass # the app claimed it
|
|
135
|
+
else:
|
|
136
|
+
log.warning("[backend] unknown message type: %s", msg_type)
|
|
137
|
+
except Exception as e:
|
|
138
|
+
log.exception("handling %s failed", msg_type)
|
|
139
|
+
ipc.emit_error(str(e))
|
|
140
|
+
|
|
141
|
+
session.shutdown()
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def run(
|
|
145
|
+
build_session: Callable[[], object],
|
|
146
|
+
*,
|
|
147
|
+
app_packages: Iterable[str] = (),
|
|
148
|
+
log_level_env: str = "DE_LOG_LEVEL",
|
|
149
|
+
on_message: Callable[[object, dict], bool] | None = None,
|
|
150
|
+
on_ready: Callable[[object], None] | None = None,
|
|
151
|
+
) -> None:
|
|
152
|
+
"""Run the backend loop until the frontend says quit.
|
|
153
|
+
|
|
154
|
+
Parameters
|
|
155
|
+
----------
|
|
156
|
+
build_session
|
|
157
|
+
Builds the app's Session. Called once, on the main thread, before the
|
|
158
|
+
loop starts.
|
|
159
|
+
app_packages
|
|
160
|
+
The app's own top-level package names, for log routing.
|
|
161
|
+
log_level_env
|
|
162
|
+
Environment variable holding the initial log level. Per-app so two
|
|
163
|
+
installed apps can be made verbose independently.
|
|
164
|
+
on_message
|
|
165
|
+
Handles a message the shell does not know. Return True if claimed.
|
|
166
|
+
on_ready
|
|
167
|
+
Runs after the session is built and before ``ready`` is emitted —
|
|
168
|
+
the place for prewarming and starting background services.
|
|
169
|
+
"""
|
|
170
|
+
asyncio.run(_main(build_session, app_packages, log_level_env, on_message, on_ready))
|
de_shell/compute.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""
|
|
2
|
+
compute.py — the dask-free half of the compute abstraction.
|
|
3
|
+
|
|
4
|
+
Every shell app needs to run work off the asyncio main thread and marshal the
|
|
5
|
+
result back. Only SpyDE needs a distributed cluster behind that. So the shell
|
|
6
|
+
owns ``ThreadCompute`` — a ``concurrent.futures``-backed submitter — and SpyDE's
|
|
7
|
+
``ComputeBackend`` subclasses it to add the ``dask.distributed`` branch.
|
|
8
|
+
|
|
9
|
+
The split is load-bearing, not cosmetic: importing this module must never pull in
|
|
10
|
+
dask, because de-groundcrew and de-autopilot are live in-memory apps that do not
|
|
11
|
+
install it.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import concurrent.futures
|
|
16
|
+
import logging
|
|
17
|
+
import threading
|
|
18
|
+
from typing import Callable
|
|
19
|
+
|
|
20
|
+
log = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SyncFuture:
|
|
24
|
+
"""Immediately-resolved future for already-computed results."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, result):
|
|
27
|
+
self._result = result
|
|
28
|
+
|
|
29
|
+
def done(self) -> bool:
|
|
30
|
+
return True
|
|
31
|
+
|
|
32
|
+
def result(self, timeout=None):
|
|
33
|
+
return self._result
|
|
34
|
+
|
|
35
|
+
def cancel(self):
|
|
36
|
+
return False
|
|
37
|
+
|
|
38
|
+
def add_done_callback(self, fn: Callable) -> None:
|
|
39
|
+
fn(self)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ThreadCompute:
|
|
43
|
+
"""Submit callables to a thread pool, returning ``concurrent.futures.Future``.
|
|
44
|
+
|
|
45
|
+
Parameters
|
|
46
|
+
----------
|
|
47
|
+
executor
|
|
48
|
+
The general-purpose pool. When ``None``, subclasses are expected to
|
|
49
|
+
provide their own routing (SpyDE's distributed mode does) — the base
|
|
50
|
+
class then only offers the dedicated interactive-read pool.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(self, executor: concurrent.futures.ThreadPoolExecutor | None = None):
|
|
54
|
+
self._executor = executor
|
|
55
|
+
self._lock = threading.Lock()
|
|
56
|
+
# Dedicated LOCAL pool for interactive frame reads. Created lazily so a
|
|
57
|
+
# plain threaded app (which already has _executor) never pays for it.
|
|
58
|
+
self._nav_executor: concurrent.futures.ThreadPoolExecutor | None = None
|
|
59
|
+
|
|
60
|
+
def _nav_pool(self) -> concurrent.futures.ThreadPoolExecutor:
|
|
61
|
+
"""The local pool interactive reads run on, built on first use.
|
|
62
|
+
|
|
63
|
+
ONE worker, deliberately. ``fut.cancel()`` only takes effect on a QUEUED
|
|
64
|
+
future — an already-running one runs to completion. With N>1 workers,
|
|
65
|
+
several superseded reads run concurrently and complete in arbitrary
|
|
66
|
+
order, so an OLDER frame can land after a newer one and the display jumps
|
|
67
|
+
backwards while you drag. One worker makes the reads serial, so the only
|
|
68
|
+
ordering hazard left is a single in-flight read, which the caller's
|
|
69
|
+
identity check already discards.
|
|
70
|
+
"""
|
|
71
|
+
with self._lock:
|
|
72
|
+
if self._nav_executor is None:
|
|
73
|
+
self._nav_executor = concurrent.futures.ThreadPoolExecutor(
|
|
74
|
+
max_workers=1, thread_name_prefix="nav-read")
|
|
75
|
+
return self._nav_executor
|
|
76
|
+
|
|
77
|
+
def shutdown_nav_pool(self) -> None:
|
|
78
|
+
"""Release the local interactive-read pool (Session.shutdown)."""
|
|
79
|
+
with self._lock:
|
|
80
|
+
pool, self._nav_executor = self._nav_executor, None
|
|
81
|
+
if pool is not None:
|
|
82
|
+
pool.shutdown(wait=False, cancel_futures=True)
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def executor(self):
|
|
86
|
+
"""Underlying ThreadPoolExecutor, or None."""
|
|
87
|
+
return self._executor
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def is_distributed(self) -> bool:
|
|
91
|
+
return False
|
|
92
|
+
|
|
93
|
+
def submit(self, fn: Callable, *args, **kwargs) -> concurrent.futures.Future:
|
|
94
|
+
"""Submit a callable, return a concurrent.futures.Future."""
|
|
95
|
+
if self._executor is None:
|
|
96
|
+
raise RuntimeError("ThreadCompute has no executor; provide one or override submit()")
|
|
97
|
+
return self._executor.submit(fn, *args, **kwargs)
|
|
98
|
+
|
|
99
|
+
def submit_nav_read(self, fn) -> concurrent.futures.Future:
|
|
100
|
+
"""Run ``fn`` (a no-arg callable returning an ndarray) on the LOCAL
|
|
101
|
+
interactive-read pool — cancellable, serial, never remote."""
|
|
102
|
+
pool = self._executor if self._executor is not None else self._nav_pool()
|
|
103
|
+
return pool.submit(fn)
|
de_shell/debug_flags.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""
|
|
2
|
+
debug_flags.py — runtime-toggleable diagnostic switches.
|
|
3
|
+
|
|
4
|
+
These are cheap boolean flags that gate opt-in instrumentation (e.g. the per-frame
|
|
5
|
+
navigator update profile). Each can be seeded from an env var at import (so it can
|
|
6
|
+
be on from process start) AND toggled live from the UI via the ``set_debug_flag``
|
|
7
|
+
action — no restart needed. Keeping them in ONE module means a single source of
|
|
8
|
+
truth that both the read side (``update_functions``) and the paint side (``plot``)
|
|
9
|
+
read through ``nav_profile_on()``.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
import os
|
|
15
|
+
|
|
16
|
+
log = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
# Per-frame navigator update profile: one INFO line per move with the stage
|
|
19
|
+
# breakdown (read / dtype / prefetch / lod / levels / transport). Seed from the
|
|
20
|
+
# env so it can be on from startup; toggle live with set_debug_flag("nav_profile").
|
|
21
|
+
_nav_profile: bool = os.environ.get("SPYDE_NAV_PROFILE") == "1"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def nav_profile_on() -> bool:
|
|
25
|
+
"""True when per-frame update profiling is active. Read this each frame (it's
|
|
26
|
+
a cheap module-global lookup) so a live toggle takes effect immediately."""
|
|
27
|
+
return _nav_profile
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def set_flag(name: str, value: bool) -> bool:
|
|
31
|
+
"""Set a debug flag by name. Returns the new value. Unknown names are ignored
|
|
32
|
+
(return False) so a stale UI can't crash the backend."""
|
|
33
|
+
global _nav_profile
|
|
34
|
+
if name in ("nav_profile", "profile"):
|
|
35
|
+
_nav_profile = bool(value)
|
|
36
|
+
log.info("[debug] nav_profile = %s (per-frame update timing %s)",
|
|
37
|
+
_nav_profile, "ON" if _nav_profile else "off")
|
|
38
|
+
return _nav_profile
|
|
39
|
+
log.debug("[debug] unknown debug flag %r ignored", name)
|
|
40
|
+
return False
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def get_flags() -> dict:
|
|
44
|
+
"""Current flag states — for the UI to reflect the toggle on connect."""
|
|
45
|
+
return {"nav_profile": _nav_profile}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def set_debug_flag(session, plot, payload) -> None:
|
|
49
|
+
"""Staged action: the UI's debug toggle → set a flag + echo the new state.
|
|
50
|
+
|
|
51
|
+
Payload: ``{"name": "nav_profile", "value": true|false}``. Echoes
|
|
52
|
+
``{"type": "debug_flags", ...}`` so the button can reflect the state, and (when
|
|
53
|
+
turning nav profiling ON) makes sure INFO records reach the Log panel so the
|
|
54
|
+
profile lines are actually visible without touching the level dropdown."""
|
|
55
|
+
name = payload.get("name", "nav_profile")
|
|
56
|
+
value = bool(payload.get("value", False))
|
|
57
|
+
set_flag(name, value)
|
|
58
|
+
if name in ("nav_profile", "profile") and value:
|
|
59
|
+
# The [NAV/PAINT-PROFILE] lines log at INFO; ensure the handler forwards
|
|
60
|
+
# INFO (the default is INFO, but a user may have raised it to WARNING).
|
|
61
|
+
try:
|
|
62
|
+
from de_shell.log_stream import set_level
|
|
63
|
+
import logging
|
|
64
|
+
if logging.getLogger().getEffectiveLevel() > logging.INFO:
|
|
65
|
+
set_level("INFO")
|
|
66
|
+
except Exception as e:
|
|
67
|
+
log.debug("raising log level to INFO for profiling failed: %s", e)
|
|
68
|
+
from de_shell.ipc import emit
|
|
69
|
+
emit({"type": "debug_flags", **get_flags()})
|