diff --git a/pyproject.toml b/pyproject.toml index 380a1ea..9fab6b9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,7 @@ test = ["pytest", "pytest-cov", "django", "streamlit", "copier", "jinja2-time", "maturin", "uv", "briefcase", "textual"] qt = ["pyqt>5,<6", "pyqtwebengin>5,<6"] textual = ["textual>=0.80"] -ipywidget = ["anywidget>=0.9", "ipywidgets>=8", "ipython"] +ipywidget = ["anywidget>=0.9"] [project.scripts] projspec = "projspec.__main__:main" diff --git a/src/projspec/library.py b/src/projspec/library.py index 0b3f313..f248d56 100644 --- a/src/projspec/library.py +++ b/src/projspec/library.py @@ -101,10 +101,10 @@ def filter(self, filters: list[tuple[str, str]]) -> dict[str, Project]: return {k: v for k, v in self.entries.items() if _match(v, filters)} # ------------------------------------------------------------------ - # Rich display / ipywidget + # Rich display / widget # ------------------------------------------------------------------ - def ipywidget(self): - """Return an interactive Jupyter widget for this library. + def widget(self): + """Return an interactive widget for this library. The widget mirrors the two-pane UI used by the VSCode extension, the Qt app and the PyCharm plugin: a filterable project list on @@ -113,8 +113,13 @@ def ipywidget(self): actions (rescan, create spec, remove from library, …) and per- artifact Make buttons. - Requires the optional ``anywidget`` and ``ipywidgets`` packages; - install them via ``pip install projspec[ipywidget]``. + Built on :mod:`anywidget` — no :mod:`ipywidgets` dependency is + required, so the widget runs under marimo as well as classic + Jupyter / JupyterLab. Install via ``pip install projspec[ipywidget]``. + + In marimo, return the widget from a cell to display it. In + Jupyter, you can also just evaluate a :class:`ProjectLibrary` + directly (``_ipython_display_`` is called automatically). Only a single widget per notebook is supported - see the :mod:`projspec.webui.ipywidget` module docstring. @@ -123,21 +128,30 @@ def ipywidget(self): return make_widget(self) + def ipywidget(self): + """Deprecated alias for :meth:`widget`.""" + return self.widget() + def _ipython_display_(self): """Auto-display as the interactive widget when possible. - Falls back to a plain ``repr`` when ``anywidget`` / - ``ipywidgets`` is not available - Jupyter will then use the - normal text representation. + Falls back to a plain ``repr`` when ``anywidget`` is not + available — Jupyter will then use the normal text representation. + + Not called by marimo; marimo users should return the widget from + a cell (e.g. ``library.widget()``). """ try: - widget = self.ipywidget() + widget = self.widget() except ImportError: # No optional deps; let Jupyter fall back to repr(). print(repr(self)) return - from IPython.display import display - + try: + from IPython.display import display + except ImportError: # pragma: no cover - IPython not installed + print(repr(self)) + return display(widget) diff --git a/src/projspec/proj/base.py b/src/projspec/proj/base.py index 511fcb1..ef55ca8 100644 --- a/src/projspec/proj/base.py +++ b/src/projspec/proj/base.py @@ -635,9 +635,8 @@ def to_dict(self, compact=True) -> dict: def _ipython_display_(self): """Auto-display as the interactive widget when possible. - Falls back to a plain ``repr`` when ``anywidget`` / - ``ipywidgets`` is not available - Jupyter will then use the - normal text representation. + Falls back to a plain ``repr`` when ``anywidget`` is not + available - Jupyter will then use the normal text representation. """ from projspec.library import ProjectLibrary diff --git a/src/projspec/webui/ipywidget.py b/src/projspec/webui/ipywidget.py index 580c7f4..5133eb3 100644 --- a/src/projspec/webui/ipywidget.py +++ b/src/projspec/webui/ipywidget.py @@ -1,10 +1,14 @@ -"""Jupyter ``ipywidget`` representation of :class:`ProjectLibrary`. +"""Jupyter / marimo widget representation of :class:`ProjectLibrary`. This module owns the *host* side of the shared webui transport for the -Jupyter Notebook / JupyterLab / VSCode-notebook / Colab environments. It -builds an :mod:`anywidget` ``AnyWidget`` that loads the shared HTML, CSS -and JS from :mod:`projspec.webui` and drives it from Python with the same -command vocabulary as the VSCode extension and the Qt app. +Jupyter Notebook / JupyterLab / VSCode-notebook / Colab / marimo +environments. It builds an :mod:`anywidget` ``AnyWidget`` that loads the +shared HTML, CSS and JS from :mod:`projspec.webui` and drives it from +Python with the same command vocabulary as the VSCode extension and the +Qt app. + +Only :mod:`anywidget` is required — :mod:`ipywidgets` is **not** needed, +which means the widget runs under marimo as well as classic Jupyter. Current limitations ------------------- @@ -120,6 +124,12 @@ try { el.removeChild(root); } catch {} }; } + +// AFM spec requires a default export; the named `render` export is +// deprecated in anywidget ≥ 0.9.13 and not recognised by some hosts +// (e.g. marimo). Export both so the module satisfies current validators +// while remaining backward-compatible with older anywidget runtimes. +export default { render }; """ @@ -179,13 +189,12 @@ def _build_widget(library: "ProjectLibrary"): """ try: import anywidget - import traitlets except ImportError as exc: # pragma: no cover - optional dep raise ImportError( "The ipywidget representation of ProjectLibrary requires the " - "'anywidget' and 'ipywidgets' packages. Install them with " + "'anywidget' package. Install it with " "``pip install projspec[ipywidget]`` or " - "``pip install anywidget ipywidgets``." + "``pip install anywidget``." ) from exc class ProjectLibraryWidget(anywidget.AnyWidget): @@ -198,10 +207,10 @@ class ProjectLibraryWidget(anywidget.AnyWidget): """ _esm = _build_esm() - # CSS is embedded in the ESM; traitlets.Unicode default is fine. + # CSS is embedded in the ESM; anywidget ignores an empty _css. _css = "" - # No traitlets-level state: the widget exchanges messages via + # No traitlets state: the widget exchanges messages via # ``send`` / ``msg:custom`` instead of syncing a model attribute. def __init__(self, library_obj: "ProjectLibrary", **kwargs: Any): @@ -709,7 +718,7 @@ def walk(cls: type) -> None: def make_widget(library: "ProjectLibrary"): """Return an anywidget-backed DOMWidget for ``library``. - Public entry point used by :meth:`ProjectLibrary.ipywidget`. + Public entry point used by :meth:`ProjectLibrary.widget`. """ return _build_widget(library) diff --git a/tests/test_ipywidget_helpers.py b/tests/test_ipywidget_helpers.py index ce84131..3967db2 100644 --- a/tests/test_ipywidget_helpers.py +++ b/tests/test_ipywidget_helpers.py @@ -1,6 +1,6 @@ """Tests for module-level helpers in projspec.webui.ipywidget. -These functions have no dependency on anywidget, ipywidgets, or a live +These functions have no dependency on anywidget or a live Jupyter kernel, so they run in any environment. Widget-construction tests that *do* require anywidget remain in test_webui.py. @@ -334,7 +334,7 @@ def widget_and_lib(tmp_path): proj_url = "file://" + proj_path lib.entries[proj_url] = projspec.Project(proj_path, walk=False) - widget = lib.ipywidget() + widget = lib.widget() widget.send = lambda c, buffers=None: None widget._toast = lambda m: None return widget, lib, proj_url @@ -486,7 +486,7 @@ def test_resolve_entry_path_remote_keeps_protocol(self, tmp_path): key = proj.fs.unstrip_protocol(proj.url) lib.entries[key] = proj - widget = lib.ipywidget() + widget = lib.widget() widget.send = lambda c, buffers=None: None widget._toast = lambda m: None @@ -543,7 +543,7 @@ def test_resolve_entry_path_old_library_keeps_protocol(self, tmp_path): # sanity: the reconstructed entry's fs is (wrongly) local here assert lib.entries["memory:///ipw_old"].is_local() - widget = lib.ipywidget() + widget = lib.widget() widget.send = lambda c, buffers=None: None widget._toast = lambda m: None diff --git a/tests/test_webui.py b/tests/test_webui.py index bb11b2d..31722c8 100644 --- a/tests/test_webui.py +++ b/tests/test_webui.py @@ -2,7 +2,7 @@ These tests don't require a browser or a real Jupyter kernel: we just check that the static resources are self-consistent and that the -``ProjectLibrary.ipywidget`` plumbing degrades gracefully when the +``ProjectLibrary.widget`` plumbing degrades gracefully when the optional ``anywidget`` dependency is unavailable. """ @@ -86,14 +86,14 @@ def test_panel_css_and_js_are_strings(): def test_ipywidget_degrades_when_anywidget_missing(monkeypatch, tmp_path): - """Without anywidget / ipywidgets, .ipywidget() raises ImportError + """Without anywidget, .widget() raises ImportError and _ipython_display_ falls back to printing the repr.""" try: import anywidget # noqa: F401 except ImportError: lib = ProjectLibrary(str(tmp_path / "lib.json"), auto_save=False) with pytest.raises(ImportError): - lib.ipywidget() + lib.widget() def test_ipywidget_esm_builds(): @@ -116,13 +116,12 @@ def test_ipywidget_esm_builds(): def test_ipywidget_construction_if_available(tmp_path): - """If anywidget is available, ProjectLibrary.ipywidget() returns a + """If anywidget is available, ProjectLibrary.widget() returns a widget with a non-empty _esm.""" anywidget = pytest.importorskip("anywidget") - pytest.importorskip("ipywidgets") lib = ProjectLibrary(str(tmp_path / "lib.json"), auto_save=False) - widget = lib.ipywidget() + widget = lib.widget() assert isinstance(widget, anywidget.AnyWidget) assert widget._esm and "export function render" in widget._esm @@ -136,7 +135,6 @@ def test_ipywidget_handlers_respond(tmp_path): report as 'the button doesn't work'. """ pytest.importorskip("anywidget") - pytest.importorskip("ipywidgets") from projspec import Project lib_file = tmp_path / "lib.json" @@ -148,7 +146,7 @@ def test_ipywidget_handlers_respond(tmp_path): proj_url = "file://" + proj_path lib.entries[proj_url] = Project(proj_path, walk=False) - widget = lib.ipywidget() + widget = lib.widget() outbox: list[dict] = [] toasts: list[str] = [] widget.send = lambda content, buffers=None: outbox.append(content) @@ -257,7 +255,6 @@ def test_make_cwd_uses_project_path_not_library_key(tmp_path, monkeypatch): import fsspec pytest.importorskip("anywidget") - pytest.importorskip("ipywidgets") from projspec.artifact.process import Process from projspec.proj.base import Project, ProjectSpec from projspec.utils import AttrDict, is_installed @@ -297,7 +294,7 @@ def test_make_cwd_uses_project_path_not_library_key(tmp_path, monkeypatch): # Kernel cwd is deliberately somewhere else. monkeypatch.chdir("/") - w = lib.ipywidget() + w = lib.widget() w._toast = lambda m: None w.send = lambda c, buffers=None: None