diff --git a/eco/logs.py b/eco/logs.py index 881b06a..efee8e5 100644 --- a/eco/logs.py +++ b/eco/logs.py @@ -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 diff --git a/eco/widgets/jupyter_sidecar.py b/eco/widgets/jupyter_sidecar.py index 146e0ae..c38b204 100644 --- a/eco/widgets/jupyter_sidecar.py +++ b/eco/widgets/jupyter_sidecar.py @@ -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: diff --git a/eco/widgets/widget_tray.py b/eco/widgets/widget_tray.py index 322f891..f9538fb 100644 --- a/eco/widgets/widget_tray.py +++ b/eco/widgets/widget_tray.py @@ -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]) diff --git a/tests/test_jupyter_sidecar.py b/tests/test_jupyter_sidecar.py index 136fe67..4007224 100644 --- a/tests/test_jupyter_sidecar.py +++ b/tests/test_jupyter_sidecar.py @@ -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("hi", 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 diff --git a/tests/test_widget_tray.py b/tests/test_widget_tray.py new file mode 100644 index 0000000..70cb7ea --- /dev/null +++ b/tests/test_widget_tray.py @@ -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"]