Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
36 changes: 25 additions & 11 deletions src/projspec/library.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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)


Expand Down
5 changes: 2 additions & 3 deletions src/projspec/proj/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
31 changes: 20 additions & 11 deletions src/projspec/webui/ipywidget.py
Original file line number Diff line number Diff line change
@@ -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
-------------------
Expand Down Expand Up @@ -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 };
"""


Expand Down Expand Up @@ -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):
Expand All @@ -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):
Expand Down Expand Up @@ -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)

Expand Down
8 changes: 4 additions & 4 deletions tests/test_ipywidget_helpers.py
Original file line number Diff line number Diff line change
@@ -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.

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
17 changes: 7 additions & 10 deletions tests/test_webui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""

Expand Down Expand Up @@ -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():
Expand All @@ -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

Expand All @@ -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"
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
Loading