Files
eco/tests/test_console_kernel.py
T
gac berninaandClaude Sonnet 5 166a5ef44b Fix desktop console/launcher lag, add startup-script export, default to dark theme
Real bug found via manual testing ("even not closing, desktop is super
unresponsive and laggy... the qt console, when entering something takes
ages to update, 30s ish"): eco.utilities.config.Proxy (the lazy device
proxy) only special-cases __class__ to avoid resolving while unresolved --
__dir__ isn't one of lazy_object_proxy's protected operations, so it
forwards straight to __wrapped__, fully constructing the device (real
EPICS calls) the moment anything calls dir() on it. Confirmed for real:
dir() on a still-lazy complex device took 62.7s. Since eco desktop's
console now has ~180 bare device names in its namespace (many still lazy),
and any completion mechanism scanning that namespace calls dir() on
candidates, typing in the console could trigger this on nearly every
keystroke. Added Proxy.__dir__, mirroring __class__'s existing "stay shy
to introspection" pattern exactly. Also measured Jedi-based completion as
independently slower (5.8s vs 0.001s) and less correct (0 matches vs 63)
than the classic completer for this dynamic a namespace -- now disabled
for every eco console (build_console_widget, so it applies uniformly to
both in-process and subprocess kernels).

Second, separate perf issue in the Namespace launcher panel: its
live-refresh timer (every 2s) fully rebuilds the table, and the Required
column (added last session) used a real QCheckBox+QWidget+QHBoxLayout per
row -- constructing/destroying ~120 native Qt widgets twice a second at
bernina's scale is real, visible GUI-thread cost. Switched to checkable
QTableWidgetItems (blockSignals()'d during the rebuild so programmatic
setCheckState() calls don't themselves trigger spurious required_names()
writes) -- same functionality, far cheaper to rebuild.

New: "Save Startup Script..." in the desktop window's Workspace menu --
writes a standalone, executable .sh (paired with a .json workspace file,
same format as Save Workspace) that relaunches `eco desktop` with just
this session's open widgets reopened. Lazy loading means nothing else in
the namespace gets touched, so this is a fast, minimal per-task dashboard
instead of the full namespace. New --workspace PATH flag on both eco_cli's
desktop subcommand and eco.widgets.desktop_app's own CLI loads a workspace
on startup; desktop_app._main() now builds the window and loads it before
entering run()'s blocking loop, instead of auto-starting straight into
that block.

Also: the Namespace launcher's lazy (not actively loading) entries no
longer show a spinner-adjacent hourglass -- gray text alone marks
"not built yet"; the animated spinner is now reserved for "actually
initializing right now". And the desktop window's Material dark theme
(qt-material's dark_teal.xml) is now the default -- it already existed as
an opt-in --theme dark, but wasn's what a first `eco desktop` showed; added
'none' as an explicit choice for anyone who wants native OS style back,
and qt-material to eco[gui]'s extras so pip installs get the real theme
too, not just the built-in approximation.

All confirmed via scripted offscreen reproductions of each bug before
writing its fix (not just after), plus the full test suite (336/338 --
the 2 failures are pre-existing in test_config_lazy_init.py, confirmed
via git stash, unrelated to any of this).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 22:17:40 +02:00

205 lines
7.3 KiB
Python

import pytest
pytest.importorskip("qtpy")
from qtpy import QtWidgets
from eco.widgets import console_kernel, kernel_registry
@pytest.fixture(scope="module")
def qapp():
return QtWidgets.QApplication.instance() or QtWidgets.QApplication([])
@pytest.fixture(autouse=True)
def _isolated_registry(monkeypatch, tmp_path):
monkeypatch.setattr(kernel_registry, "_registry", [])
monkeypatch.setattr(kernel_registry, "DEFAULT_LOG_DIR", tmp_path)
# -- _subprocess_env -----------------------------------------------------------
#
# Regression coverage for a real bug: a subprocess kernel spawned with a
# plain os.environ-inherited env couldn't `import eco.widgets`, even
# though the calling process (same repo checkout) had no trouble with it.
# Root cause: the calling terminal's sys.path had the repo root on it via
# IPython's %run (how eco's own startup script makes itself importable in
# the first place), not via PYTHONPATH -- a live sys.path mutation like
# that never propagates to a child process through plain environment
# inheritance. Confirmed for real against a live spawned kernel (not just
# unit-tested) while building this fix.
def test_subprocess_env_prepends_current_sys_path_to_pythonpath(monkeypatch):
import os as _os
import sys as _sys
monkeypatch.setattr(_sys, "path", ["/fake/repo/root", "/usr/lib/python3.12"])
monkeypatch.setenv("PYTHONPATH", "/existing/path")
env = console_kernel._subprocess_env()
expected_prefix = _os.pathsep.join(["/fake/repo/root", "/usr/lib/python3.12"])
assert env["PYTHONPATH"] == expected_prefix + _os.pathsep + "/existing/path"
def test_subprocess_env_without_existing_pythonpath(monkeypatch):
import sys as _sys
monkeypatch.setattr(_sys, "path", ["/fake/repo/root"])
monkeypatch.delenv("PYTHONPATH", raising=False)
env = console_kernel._subprocess_env()
assert env["PYTHONPATH"] == "/fake/repo/root"
def test_subprocess_env_preserves_other_environment_variables(monkeypatch):
monkeypatch.setenv("SOME_OTHER_VAR", "keep-me")
env = console_kernel._subprocess_env()
assert env["SOME_OTHER_VAR"] == "keep-me"
# -- can_use_inprocess_kernel -------------------------------------------------
def test_can_use_inprocess_kernel_true_with_no_running_ipython(monkeypatch):
monkeypatch.setattr("IPython.get_ipython", lambda: None)
assert console_kernel.can_use_inprocess_kernel() is True
def test_can_use_inprocess_kernel_false_with_a_running_ipython_shell(monkeypatch):
monkeypatch.setattr("IPython.get_ipython", lambda: object())
assert console_kernel.can_use_inprocess_kernel() is False
# -- pure message-field extraction --------------------------------------------
def test_extract_result_fields():
msg = {"content": {"data": {"text/plain": "42", "text/html": "<b>42</b>"}}}
assert console_kernel.extract_result_fields(msg) == {"text": "42"}
def test_extract_result_fields_missing_data():
assert console_kernel.extract_result_fields({"content": {}}) == {"text": None}
def test_extract_stream_fields():
msg = {"content": {"name": "stdout", "text": "hello\n"}}
assert console_kernel.extract_stream_fields(msg) == {"name": "stdout", "text": "hello\n"}
def test_extract_error_fields():
msg = {"content": {"ename": "ValueError", "evalue": "bad", "traceback": ["..."]}}
assert console_kernel.extract_error_fields(msg) == {"ename": "ValueError", "evalue": "bad"}
# -- in-process kernel (real) --------------------------------------------------
def test_build_inprocess_kernel_shares_a_given_namespace_dict(qapp, tmp_path):
shared = {"foo": 1}
km, kc, session = console_kernel.build_inprocess_kernel(
kind="desktop", label="bernina", shared_user_ns=shared
)
try:
assert km.kernel.shell.user_ns is shared
assert session.kind == "desktop"
assert session.label == "bernina"
assert session in kernel_registry.all_sessions()
finally:
console_kernel.stop_kernel(km, kc, session)
assert session not in kernel_registry.all_sessions()
def test_build_inprocess_kernel_pushes_vars_into_a_fresh_namespace(qapp):
km, kc, session = console_kernel.build_inprocess_kernel(
kind="desktop", label="bernina", push_vars={"namespace": "the-namespace-object"}
)
try:
assert km.kernel.shell.user_ns["namespace"] == "the-namespace-object"
finally:
console_kernel.stop_kernel(km, kc, session)
# -- subprocess kernel (real) --------------------------------------------------
def test_build_subprocess_kernel_starts_a_real_kernel_process(qapp):
km, kc, session = console_kernel.build_subprocess_kernel(kind="console", label="test")
try:
assert km.has_kernel
assert session.pid is not None
assert session.connection_file is not None
assert session in kernel_registry.all_sessions()
finally:
console_kernel.stop_kernel(km, kc, session)
assert session not in kernel_registry.all_sessions()
# -- build_console_widget ------------------------------------------------------
#
# Uses a fake in place of console_kernel.LoggingJupyterWidget -- see
# tests/test_desktop_app.py's _FakeConsoleWidget docstring for why a real
# RichJupyterWidget subclass isn't constructed directly in these tests.
class _FakeSession:
def __init__(self):
self.logged = []
def log_input(self, code):
self.logged.append(code)
def log_event(self, *a, **kw):
pass
class _FakeConsoleWidget:
def __init__(self, session=None):
self.session = session
self.kernel_manager = None
self.kernel_client = None
self.banner = ""
self.executed = []
def execute(self, code, hidden=False):
self.executed.append(code)
if self.session is not None and not hidden:
self.session.log_input(code)
def test_build_console_widget_wires_manager_client_and_banner(monkeypatch, qapp):
monkeypatch.setattr(console_kernel, "LoggingJupyterWidget", _FakeConsoleWidget)
session = _FakeSession()
console = console_kernel.build_console_widget(
"manager", "client", session, banner="hello"
)
assert console.kernel_manager == "manager"
assert console.kernel_client == "client"
assert console.banner == "hello"
assert console.session is session
# every console gets Jedi disabled (hidden, not logged -- see
# build_console_widget's docstring for why: it can fully resolve a
# lazy device proxy just from being a completion candidate)
assert console.executed == ["get_ipython().Completer.use_jedi = False"]
assert session.logged == []
def test_build_console_widget_runs_startup_code_through_the_widget_not_the_client(monkeypatch, qapp):
monkeypatch.setattr(console_kernel, "LoggingJupyterWidget", _FakeConsoleWidget)
session = _FakeSession()
console = console_kernel.build_console_widget(
"manager", "client", session, startup_code="namespace = 1"
)
assert console.executed == ["get_ipython().Completer.use_jedi = False", "namespace = 1"]
# logged via the widget's own execute path, same as anything else typed
# into the console -- not by build_subprocess_kernel itself. The
# hidden jedi-disable call isn't logged (see the test above).
assert session.logged == ["namespace = 1"]
def test_stop_kernel_is_a_no_op_with_all_nones():
console_kernel.stop_kernel(None, None, None) # must not raise