Files
eco/tests/test_kernel_registry.py
gac bernina c7f653ba7c Log activity from terminal console and JupyterLab/Voila kernels too, merge multi-stream logs with separable filters
Previously only eco desktop's console had an activity log (via
eco.widgets.console_kernel's ZMQ-message interception, which only works
for a kernel with a real iopub channel a Qt widget attaches to) -- a
plain terminal `eco console` session and JupyterLab-native/Voila kernels
had none. eco.widgets.kernel_registry.install_shell_logger() closes that
gap using IPython's pre_run_cell/post_run_cell events instead, which work
identically for any real InteractiveShell. Wired into startup_inline.py
(terminal), eco_cli.py's generated kernel profile (JupyterLab-native), and
voila_app.ipynb/jupyterlab_app.ipynb (Voila / bare `eco jupyterlab`).
Narrower than console_kernel's approach by design: only input plus the
resulting value/error are captured, not raw stdout/stderr streaming.

Also proactively set use_jedi=False for the terminal's `eco console`
startup script, matching the reasoning already applied to desktop's
console (see console_kernel.build_console_widget's docstring) -- Jedi's
introspection was measured slower and less correct against eco's dynamic,
lazily-resolving namespace.

Every kernel log's entries are now tagged with which stream they came
from (TimelineEntry.session, a "kind:label · HHMMSS" label derived from
the log's own session_start record) so eco.logs's timeline keeps merging
every session by default but LogTimelineQt gained a second, AND-combined
"Stream:" filter row alongside the existing "Show:" kind row to separate
them back out on demand. Fixed a same-second label collision the first
version of the label had (two sessions of the same kind+label started
within the same second, e.g. under test, produced identical labels) by
falling back to a trailing id fragment from the log filename.

Cross-user session browsing (the log_dir parameter already supports it)
is deliberately deferred, not built this pass.
2026-08-22 09:30:28 +02:00

231 lines
8.5 KiB
Python

import json
import pytest
from eco.widgets import kernel_registry
@pytest.fixture(autouse=True)
def _isolated_registry(monkeypatch):
# kernel_registry keeps its list of live sessions at module scope --
# reset it around every test so tests don't see each other's sessions.
monkeypatch.setattr(kernel_registry, "_registry", [])
def _make_session(tmp_path, **kw):
return kernel_registry.KernelSession(log_dir=tmp_path, **kw)
def test_kernel_session_creates_log_file_with_session_start(tmp_path):
session = _make_session(tmp_path, kind="console", label="bernina", pid=1234)
assert session.log_path.exists()
lines = session.log_path.read_text().splitlines()
assert len(lines) == 1
entry = json.loads(lines[0])
assert entry["event"] == "session_start"
assert entry["kind"] == "console"
assert entry["label"] == "bernina"
assert entry["pid"] == 1234
def test_kernel_session_defaults_label_to_kind(tmp_path):
session = _make_session(tmp_path, kind="desktop")
assert session.label == "desktop"
def test_log_input_and_log_output_append_jsonl_entries(tmp_path):
session = _make_session(tmp_path, kind="desktop")
session.log_input("namespace.motor1.get_current_value()")
session.log_output("result", text="1.23")
session.log_output("stream", name="stdout", text="hello\n")
lines = [json.loads(l) for l in session.log_path.read_text().splitlines()]
assert lines[1] == {"t": lines[1]["t"], "event": "input", "code": "namespace.motor1.get_current_value()"}
assert lines[2]["event"] == "result"
assert lines[2]["text"] == "1.23"
assert lines[3]["event"] == "stream"
assert lines[3]["name"] == "stdout"
def test_log_widget_control_appends_jsonl_entry(tmp_path):
session = _make_session(tmp_path, kind="desktop")
session.log_widget_control("cam_west.widget()")
lines = [json.loads(l) for l in session.log_path.read_text().splitlines()]
assert lines[1] == {"t": lines[1]["t"], "event": "widget_control", "code": "cam_west.widget()"}
def test_tail_returns_last_n_entries_oldest_first(tmp_path):
session = _make_session(tmp_path, kind="desktop")
for i in range(5):
session.log_input(f"cmd {i}")
tail = session.tail(n=2)
assert [e.get("code") for e in tail] == ["cmd 3", "cmd 4"]
def test_tail_on_nonexistent_log_returns_empty(tmp_path):
session = _make_session(tmp_path, kind="desktop")
session.log_path.unlink()
assert session.tail() == []
# -- install_shell_logger ------------------------------------------------
#
# Works for ANY real IPython InteractiveShell (a plain terminal `eco
# console` session, a JupyterLab-native kernel, Voila's kernel -- see the
# function's own docstring for why this needs a different mechanism than
# eco.widgets.console_kernel's ZMQ-message-based one). A fake shell with
# just enough of the real API (get_ipython(), .events.register(name, cb))
# is all that's needed to verify the wiring without a real IPython session.
class _FakeEvents:
def __init__(self):
self.registered = {}
def register(self, name, callback):
self.registered.setdefault(name, []).append(callback)
def fire(self, name, *args):
for cb in self.registered.get(name, []):
cb(*args)
class _FakeShell:
def __init__(self):
self.events = _FakeEvents()
class _FakeCellInfo:
def __init__(self, raw_cell):
self.raw_cell = raw_cell
class _FakeExecutionResult:
def __init__(self, result=None, error_in_exec=None, error_before_exec=None):
self.result = result
self.error_in_exec = error_in_exec
self.error_before_exec = error_before_exec
def test_install_shell_logger_returns_none_with_no_ipython_session(monkeypatch):
monkeypatch.setattr("IPython.get_ipython", lambda: None)
assert kernel_registry.install_shell_logger("console") is None
def test_install_shell_logger_registers_pre_and_post_run_cell(monkeypatch, tmp_path):
shell = _FakeShell()
monkeypatch.setattr("IPython.get_ipython", lambda: shell)
session = kernel_registry.install_shell_logger("console", label="bernina", log_dir=tmp_path)
assert "pre_run_cell" in shell.events.registered
assert "post_run_cell" in shell.events.registered
assert shell._eco_shell_logger_session is session
def test_install_shell_logger_is_idempotent(monkeypatch, tmp_path):
"""Calling it again on the same shell (e.g. a startup script that runs
more than once) must not double-register hooks or create a second
session."""
shell = _FakeShell()
monkeypatch.setattr("IPython.get_ipython", lambda: shell)
first = kernel_registry.install_shell_logger("console", label="bernina", log_dir=tmp_path)
second = kernel_registry.install_shell_logger("console", label="bernina", log_dir=tmp_path)
assert first is second
assert len(shell.events.registered["pre_run_cell"]) == 1
assert len(shell.events.registered["post_run_cell"]) == 1
def test_install_shell_logger_logs_input_and_result(monkeypatch, tmp_path):
shell = _FakeShell()
monkeypatch.setattr("IPython.get_ipython", lambda: shell)
session = kernel_registry.install_shell_logger("console", label="bernina", log_dir=tmp_path)
shell.events.fire("pre_run_cell", _FakeCellInfo("2 + 2"))
shell.events.fire("post_run_cell", _FakeExecutionResult(result=4))
lines = [json.loads(l) for l in session.log_path.read_text().splitlines()]
assert lines[1] == {"t": lines[1]["t"], "event": "input", "code": "2 + 2"}
assert lines[2] == {"t": lines[2]["t"], "event": "result", "text": "4"}
def test_install_shell_logger_does_not_log_a_result_for_a_bare_statement(monkeypatch, tmp_path):
"""print(...)/assignments don't produce a result.result -- must not
log a spurious empty "result" entry for those."""
shell = _FakeShell()
monkeypatch.setattr("IPython.get_ipython", lambda: shell)
session = kernel_registry.install_shell_logger("console", label="bernina", log_dir=tmp_path)
shell.events.fire("pre_run_cell", _FakeCellInfo('print("hi")'))
shell.events.fire("post_run_cell", _FakeExecutionResult(result=None))
lines = [json.loads(l) for l in session.log_path.read_text().splitlines()]
assert [l["event"] for l in lines] == ["session_start", "input"]
def test_install_shell_logger_logs_errors(monkeypatch, tmp_path):
shell = _FakeShell()
monkeypatch.setattr("IPython.get_ipython", lambda: shell)
session = kernel_registry.install_shell_logger("console", label="bernina", log_dir=tmp_path)
shell.events.fire("pre_run_cell", _FakeCellInfo("1 / 0"))
shell.events.fire("post_run_cell", _FakeExecutionResult(error_in_exec=ZeroDivisionError("division by zero")))
lines = [json.loads(l) for l in session.log_path.read_text().splitlines()]
assert lines[2] == {
"t": lines[2]["t"], "event": "error", "ename": "ZeroDivisionError", "evalue": "division by zero",
}
def test_install_shell_logger_ignores_blank_cells(monkeypatch, tmp_path):
shell = _FakeShell()
monkeypatch.setattr("IPython.get_ipython", lambda: shell)
session = kernel_registry.install_shell_logger("console", label="bernina", log_dir=tmp_path)
shell.events.fire("pre_run_cell", _FakeCellInfo(" \n "))
lines = session.log_path.read_text().splitlines()
assert len(lines) == 1 # just session_start -- no spurious blank input logged
def test_register_and_unregister_control_all_sessions(tmp_path):
s1 = _make_session(tmp_path, kind="desktop")
s2 = _make_session(tmp_path, kind="console")
kernel_registry.register(s1)
kernel_registry.register(s2)
assert kernel_registry.all_sessions() == [s1, s2]
kernel_registry.unregister(s1)
assert kernel_registry.all_sessions() == [s2]
def test_sessions_summary_shape(tmp_path):
session = _make_session(tmp_path, kind="console", label="alignment", pid=99)
kernel_registry.register(session)
[summary] = kernel_registry.sessions_summary()
assert summary["id"] == session.id
assert summary["kind"] == "console"
assert summary["label"] == "alignment"
assert summary["pid"] == 99
assert summary["log_path"] == str(session.log_path)
def test_find_all_logs_lists_jsonl_files(tmp_path):
_make_session(tmp_path, kind="desktop")
_make_session(tmp_path, kind="console")
logs = kernel_registry.find_all_logs(log_dir=tmp_path)
assert len(logs) == 2
assert all(p.suffix == ".jsonl" for p in logs)
def test_find_all_logs_missing_dir_returns_empty(tmp_path):
missing = tmp_path / "does_not_exist"
assert kernel_registry.find_all_logs(log_dir=missing) == []