Skip to content

Commit

Permalink
feat: Drop ipywidgets dependency
Browse files Browse the repository at this point in the history
  • Loading branch information
manzt committed May 12, 2024
1 parent b4557b3 commit 7f95550
Show file tree
Hide file tree
Showing 5 changed files with 59 additions and 75 deletions.
37 changes: 27 additions & 10 deletions anywidget/_descriptor.py
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,7 @@ def __init__(
self._extra_state = (extra_state or {}).copy()
self._extra_state.setdefault(_ANYWIDGET_ID_KEY, _anywidget_id(obj))
self._no_view = no_view
self._callbacks = []

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

elif data["method"] == "request_state":
self.send_state()

# elif method == "custom":
# Handle a custom msg from the front-end.
# if "content" in data:
# self._handle_custom_msg(data["content"], msg["buffers"])
elif data["method"] == "custom":
if "content" in data:
self._handle_custom_msg(data["content"], msg["buffers"])
else: # pragma: no cover
raise ValueError(
f"Unrecognized method: {data['method']}. Please report this at "
"https://github.com/manzt/anywidget/issues"
)

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

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

def register_callback(
self, callback: Callable[[Any, Any, list[bytes]], None]
) -> None:
self._callbacks.append(callback)

def send(
self, content: str | list | dict, buffers: list[memoryview] | None = None
) -> None:
"""Send a custom message to the front-end view."""
data = {"method": "custom", "content": content}
self._comm.send(data=data, buffers=buffers) # type: ignore[arg-type]


# ------------- Helper function --------------

Expand Down Expand Up @@ -558,7 +575,7 @@ def _get_psygnal_signal_group(obj: object) -> psygnal.SignalGroup | None:
else:
psygnal = sys.modules.get("psygnal")
if psygnal is None:
return None # type: ignore[unreachable]
return None # type: ignore[unreachable]

# most likely case: signal group is called "events"
events = getattr(obj, "events", None)
Expand Down
4 changes: 2 additions & 2 deletions anywidget/_protocols.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,8 @@ class AnywidgetProtocol(Protocol):
class WidgetBase(Protocol):
"""Widget subclasses with a custom message reducer."""

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

def on_msg(
self, callback: Callable[[Any, str | list | dict, list[bytes]], None]
self, callback: Callable[[str | list | dict, list[bytes]], None]
) -> None: ...
9 changes: 5 additions & 4 deletions anywidget/experimental.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ def widget(
kwargs["_css"] = css

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

return _decorator
Expand Down Expand Up @@ -149,13 +149,14 @@ def _register_anywidget_commands(widget: WidgetBase) -> None:
return None

def handle_anywidget_command(
self: WidgetBase, msg: str | list | dict, buffers: list[bytes]
msg: typing.Any,
buffers: list[bytes | memoryview] | None = None,
) -> None:
if not isinstance(msg, dict) or msg.get("kind") != "anywidget-command":
return
cmd = cmds[msg["name"]]
response, buffers = cmd(widget, msg["msg"], buffers)
self.send(
response, buffers = cmd(widget, msg["msg"], buffers or [])
widget.send(
{
"id": msg["id"],
"kind": "anywidget-command-response",
Expand Down
79 changes: 22 additions & 57 deletions anywidget/widget.py
Original file line number Diff line number Diff line change
@@ -1,80 +1,45 @@
"""AnyWidget base class for custom Jupyter widgets."""

from __future__ import annotations

from typing import Any
from typing import Any, Callable

import ipywidgets
import traitlets.traitlets as t
import traitlets

from ._file_contents import FileContents
from ._descriptor import MimeBundleDescriptor
from ._util import (
_ANYWIDGET_ID_KEY,
_CSS_KEY,
_DEFAULT_ESM,
_ESM_KEY,
enable_custom_widget_manager_once,
in_colab,
repr_mimebundle,
try_file_contents,
)
from ._version import _ANYWIDGET_SEMVER_VERSION
from .experimental import _collect_anywidget_commands, _register_anywidget_commands


class AnyWidget(ipywidgets.DOMWidget): # type: ignore [misc]
class AnyWidget(traitlets.HasTraits): # type: ignore [misc]
"""Main AnyWidget base class."""

_model_name = t.Unicode("AnyModel").tag(sync=True)
_model_module = t.Unicode("anywidget").tag(sync=True)
_model_module_version = t.Unicode(_ANYWIDGET_SEMVER_VERSION).tag(sync=True)

_view_name = t.Unicode("AnyView").tag(sync=True)
_view_module = t.Unicode("anywidget").tag(sync=True)
_view_module_version = t.Unicode(_ANYWIDGET_SEMVER_VERSION).tag(sync=True)
_repr_mimebundle_: MimeBundleDescriptor

def __init__(self, *args: Any, **kwargs: Any) -> None:
if in_colab():
enable_custom_widget_manager_once()

anywidget_traits = {}
for key in (_ESM_KEY, _CSS_KEY):
if hasattr(self, key) and not self.has_trait(key):
value = getattr(self, key)
anywidget_traits[key] = t.Unicode(str(value)).tag(sync=True)
if isinstance(value, FileContents):
value.changed.connect(
lambda new_contents, key=key: setattr(self, key, new_contents)
)

# show default _esm if not defined
if not hasattr(self, _ESM_KEY):
anywidget_traits[_ESM_KEY] = t.Unicode(_DEFAULT_ESM).tag(sync=True)

# TODO: a better way to uniquely identify this subclasses?
# We use the fully-qualified name to get an id which we
# can use to update CSS if necessary.
anywidget_traits[_ANYWIDGET_ID_KEY] = t.Unicode(
f"{self.__class__.__module__}.{self.__class__.__name__}"
).tag(sync=True)

self.add_traits(**anywidget_traits)
super().__init__(*args, **kwargs)
_register_anywidget_commands(self)
# Access _repr_mimebundle_ descriptor to trigger comm initialization
self._repr_mimebundle_ # noqa: B018

def __init_subclass__(cls, **kwargs: dict) -> None:
"""Coerces _esm and _css to FileContents if they are files."""
"""Create the _repr_mimebundle_ descriptor and register anywidget commands."""
super().__init_subclass__(**kwargs)
for key in (_ESM_KEY, _CSS_KEY) & cls.__dict__.keys():
# TODO: Upgrate to := when we drop Python 3.7
file_contents = try_file_contents(getattr(cls, key))
if file_contents:
setattr(cls, key, file_contents)
extra_state = {
key: getattr(cls, key) for key in (_ESM_KEY, _CSS_KEY) & cls.__dict__.keys()
}
cls._repr_mimebundle_ = MimeBundleDescriptor(**extra_state)
_collect_anywidget_commands(cls)

def _repr_mimebundle_(self, **kwargs: dict) -> tuple[dict, dict] | None:
plaintext = repr(self)
if len(plaintext) > 110:
plaintext = plaintext[:110] + "…"
if self._view_name is None:
return None # type: ignore[unreachable]
return repr_mimebundle(model_id=self.model_id, repr_text=plaintext)
def send(self, msg: Any, buffers: list[memoryview] | None = None) -> None:
"""Send a message to the frontend."""
self._repr_mimebundle_.send(content=msg, buffers=buffers)

def on_msg(
self, callback: Callable[[Any, str | list | dict, list[bytes]], None]
) -> None:
"""Register a message handler."""
self._repr_mimebundle_.register_callback(callback)
5 changes: 3 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,11 @@ dynamic = ["version"]
readme = "README.md"
requires-python = ">=3.7"
dependencies = [
"ipywidgets>=7.6.0",
"importlib-metadata; python_version < '3.8'",
"typing-extensions>=4.2.0",
"jupyterlab_widgets~=3.0.10",
"psygnal>=0.8.1",
"traitlets>=4.3.1",
"typing-extensions>=4.2.0",
]

[project.optional-dependencies]
Expand Down

0 comments on commit 7f95550

Please sign in to comment.