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
@@ -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)
@@ -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()})