Skip to content

Commit 7f95550

Browse files
committed
feat: Drop ipywidgets dependency
1 parent b4557b3 commit 7f95550

File tree

5 files changed

+59
-75
lines changed

5 files changed

+59
-75
lines changed

anywidget/_descriptor.py

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,7 @@ def __init__(
300300
self._extra_state = (extra_state or {}).copy()
301301
self._extra_state.setdefault(_ANYWIDGET_ID_KEY, _anywidget_id(obj))
302302
self._no_view = no_view
303+
self._callbacks = []
303304

304305
try:
305306
self._obj: Callable[[], Any] = weakref.ref(obj, self._on_obj_deleted)
@@ -388,21 +389,25 @@ def _handle_msg(self, msg: CommMessage) -> None:
388389

389390
elif data["method"] == "request_state":
390391
self.send_state()
391-
392-
# elif method == "custom":
393-
# Handle a custom msg from the front-end.
394-
# if "content" in data:
395-
# self._handle_custom_msg(data["content"], msg["buffers"])
392+
elif data["method"] == "custom":
393+
if "content" in data:
394+
self._handle_custom_msg(data["content"], msg["buffers"])
396395
else: # pragma: no cover
397396
raise ValueError(
398397
f"Unrecognized method: {data['method']}. Please report this at "
399398
"https://github.com/manzt/anywidget/issues"
400399
)
401400

402-
# def _handle_custom_msg(self, content: Any, buffers: list[memoryview]):
403-
# # TODO: handle custom callbacks
404-
# # https://github.com/jupyter-widgets/ipywidgets/blob/6547f840edc1884c75e60386ec7fb873ba13f21c/python/ipywidgets/ipywidgets/widgets/widget.py#L662
405-
# ...
401+
def _handle_custom_msg(self, content: Any, buffers: list[memoryview]):
402+
# https://github.com/jupyter-widgets/ipywidgets/blob/b78de43e12ff26e4aa16e6e4c6844a7c82a8ee1c/python/ipywidgets/ipywidgets/widgets/widget.py#L186
403+
for callback in self._callbacks:
404+
try:
405+
callback(content, buffers)
406+
except Exception:
407+
warnings.warn(
408+
"Error in custom message callback",
409+
stacklevel=2,
410+
)
406411

407412
def __call__(self, **kwargs: Sequence[str]) -> tuple[dict, dict] | None:
408413
"""Called when _repr_mimebundle_ is called on the python object."""
@@ -468,6 +473,18 @@ def unsync_object_with_view(self) -> None:
468473
with contextlib.suppress(Exception):
469474
self._disconnectors.pop()()
470475

476+
def register_callback(
477+
self, callback: Callable[[Any, Any, list[bytes]], None]
478+
) -> None:
479+
self._callbacks.append(callback)
480+
481+
def send(
482+
self, content: str | list | dict, buffers: list[memoryview] | None = None
483+
) -> None:
484+
"""Send a custom message to the front-end view."""
485+
data = {"method": "custom", "content": content}
486+
self._comm.send(data=data, buffers=buffers) # type: ignore[arg-type]
487+
471488

472489
# ------------- Helper function --------------
473490

@@ -558,7 +575,7 @@ def _get_psygnal_signal_group(obj: object) -> psygnal.SignalGroup | None:
558575
else:
559576
psygnal = sys.modules.get("psygnal")
560577
if psygnal is None:
561-
return None # type: ignore[unreachable]
578+
return None # type: ignore[unreachable]
562579

563580
# most likely case: signal group is called "events"
564581
events = getattr(obj, "events", None)

anywidget/_protocols.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,8 @@ class AnywidgetProtocol(Protocol):
6464
class WidgetBase(Protocol):
6565
"""Widget subclasses with a custom message reducer."""
6666

67-
def send(self, msg: str | dict | list, buffers: list[bytes]) -> None: ...
67+
def send(self, msg: Any, buffers: list[memoryview] | None) -> None: ...
6868

6969
def on_msg(
70-
self, callback: Callable[[Any, str | list | dict, list[bytes]], None]
70+
self, callback: Callable[[str | list | dict, list[bytes]], None]
7171
) -> None: ...

anywidget/experimental.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ def widget(
4747
kwargs["_css"] = css
4848

4949
def _decorator(cls: _T) -> _T:
50-
setattr(cls, "_repr_mimebundle_", MimeBundleDescriptor(**kwargs)) # noqa: B010
50+
setattr(cls, "_repr_mimebundle_", MimeBundleDescriptor(**kwargs)) # noqa: B010
5151
return cls
5252

5353
return _decorator
@@ -149,13 +149,14 @@ def _register_anywidget_commands(widget: WidgetBase) -> None:
149149
return None
150150

151151
def handle_anywidget_command(
152-
self: WidgetBase, msg: str | list | dict, buffers: list[bytes]
152+
msg: typing.Any,
153+
buffers: list[bytes | memoryview] | None = None,
153154
) -> None:
154155
if not isinstance(msg, dict) or msg.get("kind") != "anywidget-command":
155156
return
156157
cmd = cmds[msg["name"]]
157-
response, buffers = cmd(widget, msg["msg"], buffers)
158-
self.send(
158+
response, buffers = cmd(widget, msg["msg"], buffers or [])
159+
widget.send(
159160
{
160161
"id": msg["id"],
161162
"kind": "anywidget-command-response",

anywidget/widget.py

Lines changed: 22 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,80 +1,45 @@
11
"""AnyWidget base class for custom Jupyter widgets."""
2+
23
from __future__ import annotations
34

4-
from typing import Any
5+
from typing import Any, Callable
56

6-
import ipywidgets
7-
import traitlets.traitlets as t
7+
import traitlets
88

9-
from ._file_contents import FileContents
9+
from ._descriptor import MimeBundleDescriptor
1010
from ._util import (
11-
_ANYWIDGET_ID_KEY,
1211
_CSS_KEY,
13-
_DEFAULT_ESM,
1412
_ESM_KEY,
15-
enable_custom_widget_manager_once,
16-
in_colab,
17-
repr_mimebundle,
18-
try_file_contents,
1913
)
20-
from ._version import _ANYWIDGET_SEMVER_VERSION
2114
from .experimental import _collect_anywidget_commands, _register_anywidget_commands
2215

2316

24-
class AnyWidget(ipywidgets.DOMWidget): # type: ignore [misc]
17+
class AnyWidget(traitlets.HasTraits): # type: ignore [misc]
2518
"""Main AnyWidget base class."""
2619

27-
_model_name = t.Unicode("AnyModel").tag(sync=True)
28-
_model_module = t.Unicode("anywidget").tag(sync=True)
29-
_model_module_version = t.Unicode(_ANYWIDGET_SEMVER_VERSION).tag(sync=True)
30-
31-
_view_name = t.Unicode("AnyView").tag(sync=True)
32-
_view_module = t.Unicode("anywidget").tag(sync=True)
33-
_view_module_version = t.Unicode(_ANYWIDGET_SEMVER_VERSION).tag(sync=True)
20+
_repr_mimebundle_: MimeBundleDescriptor
3421

3522
def __init__(self, *args: Any, **kwargs: Any) -> None:
36-
if in_colab():
37-
enable_custom_widget_manager_once()
38-
39-
anywidget_traits = {}
40-
for key in (_ESM_KEY, _CSS_KEY):
41-
if hasattr(self, key) and not self.has_trait(key):
42-
value = getattr(self, key)
43-
anywidget_traits[key] = t.Unicode(str(value)).tag(sync=True)
44-
if isinstance(value, FileContents):
45-
value.changed.connect(
46-
lambda new_contents, key=key: setattr(self, key, new_contents)
47-
)
48-
49-
# show default _esm if not defined
50-
if not hasattr(self, _ESM_KEY):
51-
anywidget_traits[_ESM_KEY] = t.Unicode(_DEFAULT_ESM).tag(sync=True)
52-
53-
# TODO: a better way to uniquely identify this subclasses?
54-
# We use the fully-qualified name to get an id which we
55-
# can use to update CSS if necessary.
56-
anywidget_traits[_ANYWIDGET_ID_KEY] = t.Unicode(
57-
f"{self.__class__.__module__}.{self.__class__.__name__}"
58-
).tag(sync=True)
59-
60-
self.add_traits(**anywidget_traits)
6123
super().__init__(*args, **kwargs)
6224
_register_anywidget_commands(self)
25+
# Access _repr_mimebundle_ descriptor to trigger comm initialization
26+
self._repr_mimebundle_ # noqa: B018
6327

6428
def __init_subclass__(cls, **kwargs: dict) -> None:
65-
"""Coerces _esm and _css to FileContents if they are files."""
29+
"""Create the _repr_mimebundle_ descriptor and register anywidget commands."""
6630
super().__init_subclass__(**kwargs)
67-
for key in (_ESM_KEY, _CSS_KEY) & cls.__dict__.keys():
68-
# TODO: Upgrate to := when we drop Python 3.7
69-
file_contents = try_file_contents(getattr(cls, key))
70-
if file_contents:
71-
setattr(cls, key, file_contents)
31+
extra_state = {
32+
key: getattr(cls, key) for key in (_ESM_KEY, _CSS_KEY) & cls.__dict__.keys()
33+
}
34+
cls._repr_mimebundle_ = MimeBundleDescriptor(**extra_state)
7235
_collect_anywidget_commands(cls)
7336

74-
def _repr_mimebundle_(self, **kwargs: dict) -> tuple[dict, dict] | None:
75-
plaintext = repr(self)
76-
if len(plaintext) > 110:
77-
plaintext = plaintext[:110] + "…"
78-
if self._view_name is None:
79-
return None # type: ignore[unreachable]
80-
return repr_mimebundle(model_id=self.model_id, repr_text=plaintext)
37+
def send(self, msg: Any, buffers: list[memoryview] | None = None) -> None:
38+
"""Send a message to the frontend."""
39+
self._repr_mimebundle_.send(content=msg, buffers=buffers)
40+
41+
def on_msg(
42+
self, callback: Callable[[Any, str | list | dict, list[bytes]], None]
43+
) -> None:
44+
"""Register a message handler."""
45+
self._repr_mimebundle_.register_callback(callback)

pyproject.toml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,11 @@ dynamic = ["version"]
1313
readme = "README.md"
1414
requires-python = ">=3.7"
1515
dependencies = [
16-
"ipywidgets>=7.6.0",
1716
"importlib-metadata; python_version < '3.8'",
18-
"typing-extensions>=4.2.0",
17+
"jupyterlab_widgets~=3.0.10",
1918
"psygnal>=0.8.1",
19+
"traitlets>=4.3.1",
20+
"typing-extensions>=4.2.0",
2021
]
2122

2223
[project.optional-dependencies]

0 commit comments

Comments
 (0)