Skip to content

Commit 3c3d96f

Browse files
authored
Merge pull request #102 from martindurant/any
Remove need for ipywidgets
2 parents 34d7371 + 4f6019b commit 3c3d96f

6 files changed

Lines changed: 59 additions & 40 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ test = ["pytest", "pytest-cov", "django", "streamlit", "copier", "jinja2-time",
3636
"maturin", "uv", "briefcase", "textual"]
3737
qt = ["pyqt>5,<6", "pyqtwebengin>5,<6"]
3838
textual = ["textual>=0.80"]
39-
ipywidget = ["anywidget>=0.9", "ipywidgets>=8", "ipython"]
39+
ipywidget = ["anywidget>=0.9"]
4040

4141
[project.scripts]
4242
projspec = "projspec.__main__:main"

src/projspec/library.py

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -101,10 +101,10 @@ def filter(self, filters: list[tuple[str, str]]) -> dict[str, Project]:
101101
return {k: v for k, v in self.entries.items() if _match(v, filters)}
102102

103103
# ------------------------------------------------------------------
104-
# Rich display / ipywidget
104+
# Rich display / widget
105105
# ------------------------------------------------------------------
106-
def ipywidget(self):
107-
"""Return an interactive Jupyter widget for this library.
106+
def widget(self):
107+
"""Return an interactive widget for this library.
108108
109109
The widget mirrors the two-pane UI used by the VSCode extension,
110110
the Qt app and the PyCharm plugin: a filterable project list on
@@ -113,8 +113,13 @@ def ipywidget(self):
113113
actions (rescan, create spec, remove from library, …) and per-
114114
artifact Make buttons.
115115
116-
Requires the optional ``anywidget`` and ``ipywidgets`` packages;
117-
install them via ``pip install projspec[ipywidget]``.
116+
Built on :mod:`anywidget` — no :mod:`ipywidgets` dependency is
117+
required, so the widget runs under marimo as well as classic
118+
Jupyter / JupyterLab. Install via ``pip install projspec[ipywidget]``.
119+
120+
In marimo, return the widget from a cell to display it. In
121+
Jupyter, you can also just evaluate a :class:`ProjectLibrary`
122+
directly (``_ipython_display_`` is called automatically).
118123
119124
Only a single widget per notebook is supported - see the
120125
:mod:`projspec.webui.ipywidget` module docstring.
@@ -123,21 +128,30 @@ def ipywidget(self):
123128

124129
return make_widget(self)
125130

131+
def ipywidget(self):
132+
"""Deprecated alias for :meth:`widget`."""
133+
return self.widget()
134+
126135
def _ipython_display_(self):
127136
"""Auto-display as the interactive widget when possible.
128137
129-
Falls back to a plain ``repr`` when ``anywidget`` /
130-
``ipywidgets`` is not available - Jupyter will then use the
131-
normal text representation.
138+
Falls back to a plain ``repr`` when ``anywidget`` is not
139+
available — Jupyter will then use the normal text representation.
140+
141+
Not called by marimo; marimo users should return the widget from
142+
a cell (e.g. ``library.widget()``).
132143
"""
133144
try:
134-
widget = self.ipywidget()
145+
widget = self.widget()
135146
except ImportError:
136147
# No optional deps; let Jupyter fall back to repr().
137148
print(repr(self))
138149
return
139-
from IPython.display import display
140-
150+
try:
151+
from IPython.display import display
152+
except ImportError: # pragma: no cover - IPython not installed
153+
print(repr(self))
154+
return
141155
display(widget)
142156

143157

src/projspec/proj/base.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -635,9 +635,8 @@ def to_dict(self, compact=True) -> dict:
635635
def _ipython_display_(self):
636636
"""Auto-display as the interactive widget when possible.
637637
638-
Falls back to a plain ``repr`` when ``anywidget`` /
639-
``ipywidgets`` is not available - Jupyter will then use the
640-
normal text representation.
638+
Falls back to a plain ``repr`` when ``anywidget`` is not
639+
available - Jupyter will then use the normal text representation.
641640
"""
642641
from projspec.library import ProjectLibrary
643642

src/projspec/webui/ipywidget.py

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
1-
"""Jupyter ``ipywidget`` representation of :class:`ProjectLibrary`.
1+
"""Jupyter / marimo widget representation of :class:`ProjectLibrary`.
22
33
This module owns the *host* side of the shared webui transport for the
4-
Jupyter Notebook / JupyterLab / VSCode-notebook / Colab environments. It
5-
builds an :mod:`anywidget` ``AnyWidget`` that loads the shared HTML, CSS
6-
and JS from :mod:`projspec.webui` and drives it from Python with the same
7-
command vocabulary as the VSCode extension and the Qt app.
4+
Jupyter Notebook / JupyterLab / VSCode-notebook / Colab / marimo
5+
environments. It builds an :mod:`anywidget` ``AnyWidget`` that loads the
6+
shared HTML, CSS and JS from :mod:`projspec.webui` and drives it from
7+
Python with the same command vocabulary as the VSCode extension and the
8+
Qt app.
9+
10+
Only :mod:`anywidget` is required — :mod:`ipywidgets` is **not** needed,
11+
which means the widget runs under marimo as well as classic Jupyter.
812
913
Current limitations
1014
-------------------
@@ -120,6 +124,12 @@
120124
try { el.removeChild(root); } catch {}
121125
};
122126
}
127+
128+
// AFM spec requires a default export; the named `render` export is
129+
// deprecated in anywidget ≥ 0.9.13 and not recognised by some hosts
130+
// (e.g. marimo). Export both so the module satisfies current validators
131+
// while remaining backward-compatible with older anywidget runtimes.
132+
export default { render };
123133
"""
124134

125135

@@ -179,13 +189,12 @@ def _build_widget(library: "ProjectLibrary"):
179189
"""
180190
try:
181191
import anywidget
182-
import traitlets
183192
except ImportError as exc: # pragma: no cover - optional dep
184193
raise ImportError(
185194
"The ipywidget representation of ProjectLibrary requires the "
186-
"'anywidget' and 'ipywidgets' packages. Install them with "
195+
"'anywidget' package. Install it with "
187196
"``pip install projspec[ipywidget]`` or "
188-
"``pip install anywidget ipywidgets``."
197+
"``pip install anywidget``."
189198
) from exc
190199

191200
class ProjectLibraryWidget(anywidget.AnyWidget):
@@ -198,10 +207,10 @@ class ProjectLibraryWidget(anywidget.AnyWidget):
198207
"""
199208

200209
_esm = _build_esm()
201-
# CSS is embedded in the ESM; traitlets.Unicode default is fine.
210+
# CSS is embedded in the ESM; anywidget ignores an empty _css.
202211
_css = ""
203212

204-
# No traitlets-level state: the widget exchanges messages via
213+
# No traitlets state: the widget exchanges messages via
205214
# ``send`` / ``msg:custom`` instead of syncing a model attribute.
206215

207216
def __init__(self, library_obj: "ProjectLibrary", **kwargs: Any):
@@ -709,7 +718,7 @@ def walk(cls: type) -> None:
709718
def make_widget(library: "ProjectLibrary"):
710719
"""Return an anywidget-backed DOMWidget for ``library``.
711720
712-
Public entry point used by :meth:`ProjectLibrary.ipywidget`.
721+
Public entry point used by :meth:`ProjectLibrary.widget`.
713722
"""
714723
return _build_widget(library)
715724

tests/test_ipywidget_helpers.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Tests for module-level helpers in projspec.webui.ipywidget.
22
3-
These functions have no dependency on anywidget, ipywidgets, or a live
3+
These functions have no dependency on anywidget or a live
44
Jupyter kernel, so they run in any environment. Widget-construction tests
55
that *do* require anywidget remain in test_webui.py.
66
@@ -334,7 +334,7 @@ def widget_and_lib(tmp_path):
334334
proj_url = "file://" + proj_path
335335
lib.entries[proj_url] = projspec.Project(proj_path, walk=False)
336336

337-
widget = lib.ipywidget()
337+
widget = lib.widget()
338338
widget.send = lambda c, buffers=None: None
339339
widget._toast = lambda m: None
340340
return widget, lib, proj_url
@@ -486,7 +486,7 @@ def test_resolve_entry_path_remote_keeps_protocol(self, tmp_path):
486486
key = proj.fs.unstrip_protocol(proj.url)
487487
lib.entries[key] = proj
488488

489-
widget = lib.ipywidget()
489+
widget = lib.widget()
490490
widget.send = lambda c, buffers=None: None
491491
widget._toast = lambda m: None
492492

@@ -543,7 +543,7 @@ def test_resolve_entry_path_old_library_keeps_protocol(self, tmp_path):
543543
# sanity: the reconstructed entry's fs is (wrongly) local here
544544
assert lib.entries["memory:///ipw_old"].is_local()
545545

546-
widget = lib.ipywidget()
546+
widget = lib.widget()
547547
widget.send = lambda c, buffers=None: None
548548
widget._toast = lambda m: None
549549

tests/test_webui.py

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
33
These tests don't require a browser or a real Jupyter kernel: we just
44
check that the static resources are self-consistent and that the
5-
``ProjectLibrary.ipywidget`` plumbing degrades gracefully when the
5+
``ProjectLibrary.widget`` plumbing degrades gracefully when the
66
optional ``anywidget`` dependency is unavailable.
77
"""
88

@@ -86,14 +86,14 @@ def test_panel_css_and_js_are_strings():
8686

8787

8888
def test_ipywidget_degrades_when_anywidget_missing(monkeypatch, tmp_path):
89-
"""Without anywidget / ipywidgets, .ipywidget() raises ImportError
89+
"""Without anywidget, .widget() raises ImportError
9090
and _ipython_display_ falls back to printing the repr."""
9191
try:
9292
import anywidget # noqa: F401
9393
except ImportError:
9494
lib = ProjectLibrary(str(tmp_path / "lib.json"), auto_save=False)
9595
with pytest.raises(ImportError):
96-
lib.ipywidget()
96+
lib.widget()
9797

9898

9999
def test_ipywidget_esm_builds():
@@ -116,13 +116,12 @@ def test_ipywidget_esm_builds():
116116

117117

118118
def test_ipywidget_construction_if_available(tmp_path):
119-
"""If anywidget is available, ProjectLibrary.ipywidget() returns a
119+
"""If anywidget is available, ProjectLibrary.widget() returns a
120120
widget with a non-empty _esm."""
121121
anywidget = pytest.importorskip("anywidget")
122-
pytest.importorskip("ipywidgets")
123122

124123
lib = ProjectLibrary(str(tmp_path / "lib.json"), auto_save=False)
125-
widget = lib.ipywidget()
124+
widget = lib.widget()
126125
assert isinstance(widget, anywidget.AnyWidget)
127126
assert widget._esm and "export function render" in widget._esm
128127

@@ -136,7 +135,6 @@ def test_ipywidget_handlers_respond(tmp_path):
136135
report as 'the button doesn't work'.
137136
"""
138137
pytest.importorskip("anywidget")
139-
pytest.importorskip("ipywidgets")
140138
from projspec import Project
141139

142140
lib_file = tmp_path / "lib.json"
@@ -148,7 +146,7 @@ def test_ipywidget_handlers_respond(tmp_path):
148146
proj_url = "file://" + proj_path
149147
lib.entries[proj_url] = Project(proj_path, walk=False)
150148

151-
widget = lib.ipywidget()
149+
widget = lib.widget()
152150
outbox: list[dict] = []
153151
toasts: list[str] = []
154152
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):
257255
import fsspec
258256

259257
pytest.importorskip("anywidget")
260-
pytest.importorskip("ipywidgets")
261258
from projspec.artifact.process import Process
262259
from projspec.proj.base import Project, ProjectSpec
263260
from projspec.utils import AttrDict, is_installed
@@ -297,7 +294,7 @@ def test_make_cwd_uses_project_path_not_library_key(tmp_path, monkeypatch):
297294

298295
# Kernel cwd is deliberately somewhere else.
299296
monkeypatch.chdir("/")
300-
w = lib.ipywidget()
297+
w = lib.widget()
301298
w._toast = lambda m: None
302299
w.send = lambda c, buffers=None: None
303300

0 commit comments

Comments
 (0)