Add a Log Viewer button to eco's JupyterLab and Voila dashboards
Native menu bars are frontend-only in JupyterLab (same conclusion reached earlier this session for auto-opening a Console tab -- no server-side hook exists), so eco desktop's Tools -> Log Viewer menu action has no direct equivalent there. A button on the dashboard is the closest achievable analogue, wired through eco.logs's existing prefer= mechanism so all three front-ends share one code path instead of three parallel ones: - eco.logs._open_timeline gained a "sidecar" prefer option: renders the timeline's HTML into a new eco.widgets.jupyter_sidecar.open_html_in_ sidecar() panel -- a real, separate dockable Sidecar, matching desktop's menu action opening its own window. - eco.widgets.jupyter_sidecar.NamespaceDashboard (the JupyterLab counterpart to EcoDesktopApp) gained a "Log Viewer" button above the Namespace launcher, calling eco.logs.widget(prefer="sidecar"); reopening replaces (closes) the previous panel rather than piling up, and it's torn down in .close() alongside everything else. - eco.widgets.widget_tray.make_namespace_dashboard (Voila) gained the same button, calling eco.logs.widget(prefer="jupyter") into a dedicated ipywidgets.Output() -- Voila has no Sidecar shell to dock into, so this renders inline instead, same reasoning as its existing WidgetTray. Verified via scripted construction against fake namespaces (button present, opens, reopening replaces, torn down on close) and the full test suite (347/349 -- the 2 failures are pre-existing in test_config_lazy_init.py, unrelated). Along the way, found and worked around a real pre-existing test-isolation hazard: IPython's InteractiveShell.instance() is a process-wide singleton, and an earlier, unrelated test building a real ipykernel-backed shell leaves a stale one behind that later display()/Output.clear_output() calls in the same pytest session crash against -- not something this change caused, but the first tests to actually exercise that code path, so the new tests short-circuit the specific stale calls rather than depending on that global state. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c434511aba
commit
59b8d30b53
5 files changed
+267
-5
No files matched your search
+10
-2
@@ -200,8 +200,16 @@ def _in_jupyter():
|
||||
|
||||
|
||||
def _open_timeline(entries, title, subtitle, prefer="auto", on_open=None):
|
||||
if prefer not in ("auto", "qt", "html", "jupyter", "webapp"):
|
||||
raise ValueError(f"prefer must be one of auto/qt/html/jupyter/webapp, got {prefer!r}")
|
||||
if prefer not in ("auto", "qt", "html", "jupyter", "webapp", "sidecar"):
|
||||
raise ValueError(
|
||||
f"prefer must be one of auto/qt/html/jupyter/webapp/sidecar, got {prefer!r}"
|
||||
)
|
||||
|
||||
if prefer == "sidecar":
|
||||
from eco.widgets.jupyter_sidecar import open_html_in_sidecar
|
||||
from eco.widgets.log_timeline_html import render_html
|
||||
|
||||
return open_html_in_sidecar(render_html(entries, title=title, subtitle=subtitle), title=title)
|
||||
|
||||
if prefer in ("html", "jupyter") or (prefer == "auto" and _in_jupyter()):
|
||||
from eco.widgets.log_timeline_html import show
|
||||
|
||||
@@ -61,6 +61,29 @@ def open_in_sidecar(obj, title=None, anchor="split-right"):
|
||||
return sc
|
||||
|
||||
|
||||
def open_html_in_sidecar(html, title=None, anchor="split-right"):
|
||||
"""Display raw HTML in a JupyterLab Sidecar panel -- what
|
||||
eco.logs.widget(prefer="sidecar") uses for the log timeline (see
|
||||
eco/logs.py), also usable directly for any other pre-rendered HTML.
|
||||
Same "keep the return value referenced" rule as open_in_sidecar."""
|
||||
try:
|
||||
from sidecar import Sidecar
|
||||
except ImportError as exc:
|
||||
raise RuntimeError(
|
||||
"eco.widgets.jupyter_sidecar needs the 'sidecar' package "
|
||||
"(pip install sidecar) and a JupyterLab session -- it does not "
|
||||
"work in classic Notebook or Voila (see eco.widgets.widget_tray "
|
||||
"for those instead)."
|
||||
) from exc
|
||||
|
||||
from IPython.display import HTML, display
|
||||
|
||||
sc = Sidecar(title=str(title or "eco"), anchor=anchor)
|
||||
with sc:
|
||||
display(HTML(html))
|
||||
return sc
|
||||
|
||||
|
||||
class NamespaceDashboard:
|
||||
"""Return value of open_namespace_dashboard() -- the JupyterLab
|
||||
counterpart to eco.widgets.desktop_app.EcoDesktopApp: a Namespace
|
||||
@@ -73,6 +96,7 @@ class NamespaceDashboard:
|
||||
call .close() to tear the whole dashboard down at once."""
|
||||
|
||||
def __init__(self, namespace, anchor_launcher, anchor_widgets):
|
||||
import ipywidgets as widgets
|
||||
from IPython.display import display
|
||||
|
||||
from eco.widgets.widget_tray import NamespaceLauncherWidget
|
||||
@@ -80,11 +104,15 @@ class NamespaceDashboard:
|
||||
|
||||
self._anchor_widgets = anchor_widgets
|
||||
self._opened = {} # name -> Sidecar
|
||||
self._log_viewer = None # Sidecar -- see _open_log_viewer
|
||||
|
||||
log_btn = widgets.Button(description="Log Viewer", layout=widgets.Layout(width="auto"))
|
||||
log_btn.on_click(lambda _b: self._open_log_viewer())
|
||||
|
||||
self.launcher = NamespaceLauncherWidget(namespace, on_open=self._on_open)
|
||||
self.launcher_sidecar = Sidecar(title="Namespace", anchor=anchor_launcher)
|
||||
with self.launcher_sidecar:
|
||||
display(self.launcher)
|
||||
display(widgets.VBox([log_btn, self.launcher]))
|
||||
|
||||
def _on_open(self, obj, label):
|
||||
existing = self._opened.get(label)
|
||||
@@ -92,6 +120,20 @@ class NamespaceDashboard:
|
||||
return # already open -- Sidecar has no "bring to front"; leave it be
|
||||
self._opened[label] = open_in_sidecar(obj, title=label, anchor=self._anchor_widgets)
|
||||
|
||||
def _open_log_viewer(self):
|
||||
"""eco.logs.widget(prefer="sidecar") -- the JupyterLab counterpart
|
||||
to eco desktop's Tools -> Log Viewer menu action. Reopening
|
||||
replaces (closes) the previous one rather than piling up."""
|
||||
if self._log_viewer is not None:
|
||||
try:
|
||||
self._log_viewer.close()
|
||||
except Exception:
|
||||
logger.exception("closing the previous log viewer failed")
|
||||
self._log_viewer = None
|
||||
import eco.logs
|
||||
|
||||
self._log_viewer = eco.logs.widget(prefer="sidecar")
|
||||
|
||||
def close(self):
|
||||
"""Close every Sidecar panel this dashboard opened, including the
|
||||
launcher itself."""
|
||||
@@ -101,6 +143,12 @@ class NamespaceDashboard:
|
||||
except Exception:
|
||||
logger.exception("closing a sidecar panel failed")
|
||||
self._opened.clear()
|
||||
if self._log_viewer is not None:
|
||||
try:
|
||||
self._log_viewer.close()
|
||||
except Exception:
|
||||
logger.exception("closing the log viewer failed")
|
||||
self._log_viewer = None
|
||||
try:
|
||||
self.launcher_sidecar.close()
|
||||
except Exception:
|
||||
|
||||
@@ -500,5 +500,22 @@ def make_namespace_dashboard(namespace: Any, mode: str = "panels",
|
||||
lambda ch: tray.set_mode(ch["new"]) if ch["name"] == "value" else None, "value"
|
||||
)
|
||||
|
||||
header = widgets.VBox([launcher, status, layout_toggle])
|
||||
# eco.logs.widget(prefer="jupyter") -- the Voila counterpart to eco
|
||||
# desktop's Tools -> Log Viewer menu action / JupyterLab's Log Viewer
|
||||
# button (see eco.widgets.jupyter_sidecar.NamespaceDashboard). Routed
|
||||
# into a dedicated Output so clicking again replaces the previous
|
||||
# render instead of stacking another copy below it.
|
||||
log_output = widgets.Output()
|
||||
log_btn = widgets.Button(description="Log Viewer", layout=widgets.Layout(width="auto"))
|
||||
|
||||
def _open_log_viewer(_b):
|
||||
import eco.logs
|
||||
|
||||
log_output.clear_output()
|
||||
with log_output:
|
||||
eco.logs.widget(prefer="jupyter")
|
||||
|
||||
log_btn.on_click(_open_log_viewer)
|
||||
|
||||
header = widgets.VBox([launcher, status, widgets.HBox([layout_toggle, log_btn]), log_output])
|
||||
return widgets.VBox([header, tray.box])
|
||||
@@ -2,7 +2,13 @@ import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
from eco.widgets.jupyter_sidecar import load_workspace, open_in_sidecar, save_workspace
|
||||
from eco.widgets.jupyter_sidecar import (
|
||||
load_workspace,
|
||||
open_html_in_sidecar,
|
||||
open_in_sidecar,
|
||||
open_namespace_dashboard,
|
||||
save_workspace,
|
||||
)
|
||||
|
||||
|
||||
def test_open_in_sidecar_missing_dependency_message(monkeypatch):
|
||||
@@ -66,3 +72,126 @@ def test_load_workspace_runs_import(tmp_path, monkeypatch):
|
||||
|
||||
assert result == path
|
||||
assert calls == [["jupyter", "lab", "workspaces", "import", str(path)]]
|
||||
|
||||
|
||||
def test_open_html_in_sidecar_missing_dependency_message(monkeypatch):
|
||||
import builtins
|
||||
|
||||
real_import = builtins.__import__
|
||||
|
||||
def fake_import(name, *args, **kwargs):
|
||||
if name == "sidecar":
|
||||
raise ImportError("no module named sidecar")
|
||||
return real_import(name, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(builtins, "__import__", fake_import)
|
||||
|
||||
with pytest.raises(RuntimeError, match="pip install sidecar"):
|
||||
open_html_in_sidecar("<b>hi</b>", title="test")
|
||||
|
||||
|
||||
class _FakeLogItem:
|
||||
def widget(self):
|
||||
import ipywidgets as widgets
|
||||
|
||||
return widgets.HTML("fake widget")
|
||||
|
||||
|
||||
class _FakeLogNamespace:
|
||||
def __init__(self):
|
||||
self.initialized_names = {"cam_west"}
|
||||
self.lazy_names = set()
|
||||
self.failed_names = set()
|
||||
self._items = {"cam_west": _FakeLogItem()}
|
||||
self._required = set()
|
||||
|
||||
def resolve_item(self, name):
|
||||
return self._items.get(name)
|
||||
|
||||
def required_names(self, value=None):
|
||||
if value is None:
|
||||
return sorted(self._required)
|
||||
self._required = set(value)
|
||||
|
||||
|
||||
def test_namespace_dashboard_log_viewer_opens_via_eco_logs_sidecar(monkeypatch):
|
||||
"""The JupyterLab counterpart to eco desktop's Tools -> Log Viewer menu
|
||||
action: clicking the dashboard's Log Viewer button must go through
|
||||
eco.logs.widget(prefer="sidecar") (not some parallel, duplicated
|
||||
rendering path) so it stays consistent with every other logs.widget()
|
||||
caller."""
|
||||
import eco.logs
|
||||
|
||||
calls = []
|
||||
fake_sidecar = object()
|
||||
monkeypatch.setattr(eco.logs, "widget", lambda prefer: calls.append(prefer) or fake_sidecar)
|
||||
# A real ipykernel-backed InteractiveShell.instance() left behind by an
|
||||
# earlier, unrelated test in this same pytest process (a real,
|
||||
# process-wide IPython singleton) crashes IPython.display.display()'s
|
||||
# attempt to actually send display-data through it -- irrelevant to
|
||||
# what this test verifies (that opening the dashboard's log viewer
|
||||
# goes through eco.logs.widget), so short-circuit it.
|
||||
monkeypatch.setattr("IPython.display.display", lambda *a, **kw: None)
|
||||
|
||||
dashboard = open_namespace_dashboard(_FakeLogNamespace())
|
||||
try:
|
||||
dashboard._open_log_viewer()
|
||||
assert calls == ["sidecar"]
|
||||
assert dashboard._log_viewer is fake_sidecar
|
||||
finally:
|
||||
dashboard.close()
|
||||
|
||||
|
||||
def test_namespace_dashboard_log_viewer_reopen_replaces_not_piles_up(monkeypatch):
|
||||
import eco.logs
|
||||
|
||||
class _FakeSidecarPanel:
|
||||
def __init__(self):
|
||||
self.close_calls = 0
|
||||
|
||||
def close(self):
|
||||
self.close_calls += 1
|
||||
|
||||
made = []
|
||||
|
||||
def fake_widget(prefer):
|
||||
panel = _FakeSidecarPanel()
|
||||
made.append(panel)
|
||||
return panel
|
||||
|
||||
monkeypatch.setattr(eco.logs, "widget", fake_widget)
|
||||
monkeypatch.setattr("IPython.display.display", lambda *a, **kw: None) # see the test above
|
||||
|
||||
dashboard = open_namespace_dashboard(_FakeLogNamespace())
|
||||
try:
|
||||
dashboard._open_log_viewer()
|
||||
first = dashboard._log_viewer
|
||||
dashboard._open_log_viewer()
|
||||
assert dashboard._log_viewer is made[1]
|
||||
assert dashboard._log_viewer is not first
|
||||
assert first.close_calls == 1
|
||||
finally:
|
||||
dashboard.close()
|
||||
|
||||
|
||||
def test_namespace_dashboard_close_tears_down_log_viewer_too(monkeypatch):
|
||||
import eco.logs
|
||||
|
||||
class _FakeSidecarPanel:
|
||||
def __init__(self):
|
||||
self.close_calls = 0
|
||||
|
||||
def close(self):
|
||||
self.close_calls += 1
|
||||
|
||||
monkeypatch.setattr(eco.logs, "widget", lambda prefer: _FakeSidecarPanel())
|
||||
monkeypatch.setattr("IPython.display.display", lambda *a, **kw: None) # see the first test above
|
||||
|
||||
dashboard = open_namespace_dashboard(_FakeLogNamespace())
|
||||
dashboard._open_log_viewer()
|
||||
panel = dashboard._log_viewer
|
||||
|
||||
dashboard.close()
|
||||
|
||||
assert panel.close_calls == 1
|
||||
assert dashboard._log_viewer is None
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Focused coverage for eco.widgets.widget_tray's Log Viewer button (see
|
||||
eco.widgets.jupyter_sidecar's equivalent for the JupyterLab counterpart) --
|
||||
the rest of make_namespace_dashboard/NamespaceLauncherWidget is exercised
|
||||
via scripted manual testing (constructing them needs a real ipywidgets
|
||||
display context to be fully meaningful; see this session's own testing
|
||||
notes), not a dedicated suite here yet.
|
||||
"""
|
||||
from eco.widgets.widget_tray import make_namespace_dashboard
|
||||
|
||||
|
||||
class _FakeItem:
|
||||
def widget(self):
|
||||
import ipywidgets as widgets
|
||||
|
||||
return widgets.HTML("fake widget")
|
||||
|
||||
|
||||
class _FakeNamespace:
|
||||
def __init__(self):
|
||||
self.initialized_names = {"cam_west"}
|
||||
self.lazy_names = set()
|
||||
self.failed_names = set()
|
||||
self._items = {"cam_west": _FakeItem()}
|
||||
self._required = set()
|
||||
|
||||
def resolve_item(self, name):
|
||||
return self._items.get(name)
|
||||
|
||||
def required_names(self, value=None):
|
||||
if value is None:
|
||||
return sorted(self._required)
|
||||
self._required = set(value)
|
||||
|
||||
|
||||
def test_log_viewer_button_calls_eco_logs_widget_prefer_jupyter(monkeypatch):
|
||||
"""Must go through eco.logs.widget(prefer="jupyter") -- not a parallel,
|
||||
duplicated HTML-rendering path -- so it stays consistent with every
|
||||
other logs.widget() caller (eco desktop's Tools menu,
|
||||
eco.widgets.jupyter_sidecar's JupyterLab button)."""
|
||||
import eco.logs
|
||||
|
||||
calls = []
|
||||
monkeypatch.setattr(eco.logs, "widget", lambda prefer: calls.append(prefer))
|
||||
|
||||
dash = make_namespace_dashboard(_FakeNamespace())
|
||||
header, _tray_box = dash.children
|
||||
_launcher, _status, btn_row, log_output = header.children
|
||||
_layout_toggle, log_btn = btn_row.children
|
||||
# A real ipykernel-backed InteractiveShell.instance() left behind by an
|
||||
# earlier, unrelated test in this same pytest process (a real,
|
||||
# process-wide IPython singleton) crashes Output.clear_output()'s
|
||||
# attempt to actually send a clear through it -- irrelevant to what
|
||||
# this test verifies (that clicking the button calls eco.logs.widget),
|
||||
# so short-circuit it.
|
||||
monkeypatch.setattr(log_output, "clear_output", lambda *a, **kw: None)
|
||||
|
||||
assert log_btn.description == "Log Viewer"
|
||||
log_btn.click()
|
||||
|
||||
assert calls == ["jupyter"]
|
||||
Reference in new issue
Block a user