diff --git a/anywidget/_descriptor.py b/anywidget/_descriptor.py index 25491bf2..2d56c893 100644 --- a/anywidget/_descriptor.py +++ b/anywidget/_descriptor.py @@ -304,6 +304,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[[], object] = weakref.ref(obj, self._on_obj_deleted) @@ -397,21 +398,27 @@ def _handle_msg(self, msg: CommMessage) -> None: elif data["method"] == "request_state": self.send_state() - # elif method == "custom": # noqa: ERA001 - # Handle a custom msg from the front-end. - # if "content" in data: - # self._handle_custom_msg(data["content"], msg["buffers"]) # noqa: ERA001 - else: # pragma: no cover + elif data["method"] == "custom": + if "content" in data: + self._handle_custom_msg(data["content"], msg["buffers"]) + + else: err_msg = ( f"Unrecognized method: {data['method']}. Please report this at " "https://github.com/manzt/anywidget/issues" ) raise ValueError(err_msg) - # def _handle_custom_msg(self, content: object, buffers: list[memoryview]): - # # TODO(manzt): handle custom callbacks # noqa: TD003 - # # 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: # noqa: ARG002 """Called when _repr_mimebundle_ is called on the python object.""" @@ -419,7 +426,11 @@ def __call__(self, **kwargs: Sequence[str]) -> tuple[dict, dict] | None: # noqa # (i.e. the comm knows how to represent itself as a mimebundle) if self._no_view: return None - return repr_mimebundle(model_id=self._comm.comm_id, repr_text=repr(self._obj())) + + repr_text = repr(self._obj()) + if len(repr_text) > 110: + repr_text = repr_text[:110] + "…" + return repr_mimebundle(model_id=self._comm.comm_id, repr_text=repr_text) def sync_object_with_view( self, @@ -485,6 +496,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 -------------- diff --git a/anywidget/_protocols.py b/anywidget/_protocols.py index 74fe5f90..90da933f 100644 --- a/anywidget/_protocols.py +++ b/anywidget/_protocols.py @@ -66,9 +66,9 @@ 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] | list[bytes] | None) -> None: ... def on_msg( self, - callback: Callable[[Any, str | list | dict, list[bytes]], None], + callback: Callable[[Any, str | list | dict, list[bytes] | list[memoryview]], None], ) -> None: ... diff --git a/anywidget/_version.py b/anywidget/_version.py index 623af693..602ab483 100644 --- a/anywidget/_version.py +++ b/anywidget/_version.py @@ -1,15 +1,6 @@ -try: - from importlib.metadata import PackageNotFoundError, version -except ImportError: - from importlib_metadata import ( # type: ignore[import-not-found, no-redef] - PackageNotFoundError, - version, - ) +import importlib.metadata -try: - __version__ = version("anywidget") -except PackageNotFoundError: - __version__ = "uninstalled" +__version__ = importlib.metadata.version("anywidget") def get_semver_version(version: str) -> str: diff --git a/anywidget/experimental.py b/anywidget/experimental.py index 4966921a..201e2c49 100644 --- a/anywidget/experimental.py +++ b/anywidget/experimental.py @@ -113,8 +113,8 @@ def _decorator(cls: T) -> T: _ANYWIDGET_COMMANDS = "_anywidget_commands" _AnyWidgetCommand = typing.Callable[ - [object, object, typing.List[bytes]], - typing.Tuple[object, typing.List[bytes]], + [object, object, list[bytes] | list[memoryview]], + tuple[object, list[bytes] | list[memoryview]], ] @@ -160,14 +160,14 @@ def _register_anywidget_commands(widget: WidgetBase) -> None: def handle_anywidget_command( self: WidgetBase, - msg: str | list | dict, - buffers: list[bytes], + msg: typing.Any, + buffers: list[bytes] | list[memoryview], ) -> 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", diff --git a/anywidget/widget.py b/anywidget/widget.py index f2bc7afe..6844966e 100644 --- a/anywidget/widget.py +++ b/anywidget/widget.py @@ -2,81 +2,42 @@ from __future__ import annotations -import ipywidgets -import traitlets.traitlets as t +from typing import Any, Callable -from ._file_contents import FileContents, VirtualFileContents -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 - -_PLAIN_TEXT_MAX_LEN = 110 - - -class AnyWidget(ipywidgets.DOMWidget): # type: ignore [misc] - """Main AnyWidget base class.""" +import traitlets - _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) - - def __init__(self, *args: object, **kwargs: object) -> None: - if in_colab(): - enable_custom_widget_manager_once() +from ._descriptor import MimeBundleDescriptor +from ._util import _CSS_KEY, _ESM_KEY +from .experimental import _collect_anywidget_commands, _register_anywidget_commands - 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, (VirtualFileContents, 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) +class AnyWidget(traitlets.HasTraits): # type: ignore [misc] + """Main AnyWidget base class.""" - # TODO(manzt): a better way to uniquely identify this subclasses? # noqa: TD003 - # 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) + _repr_mimebundle_: MimeBundleDescriptor - self.add_traits(**anywidget_traits) + def __init__(self, *args: Any, **kwargs: Any) -> None: 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(manzt): Upgrate to := when we drop Python 3.7 - # https://github.com/manzt/anywidget/pull/167 - 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: # noqa: ARG002 - plaintext = repr(self) - if len(plaintext) > _PLAIN_TEXT_MAX_LEN: - 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) diff --git a/pyproject.toml b/pyproject.toml index 77cbff4b..f7c44142 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,11 +12,6 @@ license = { text = "MIT" } dynamic = ["version"] readme = "README.md" requires-python = ">=3.8" -dependencies = [ - "ipywidgets>=7.6.0", - "typing-extensions>=4.2.0", - "psygnal>=0.8.1", -] classifiers = [ "Framework :: Jupyter", "Framework :: Jupyter :: JupyterLab", @@ -27,22 +22,29 @@ classifiers = [ "Programming Language :: Python", "Programming Language :: Python :: 3", ] - -[project.urls] -homepage = "https://github.com/manzt/anywidget" +dependencies = [ + "comm>=0.1.4", + "psygnal>=0.8.1", + "traitlets>=4.3.1", + "jupyterlab-widgets~=3.0.12", # Installs widgets extension for JupyterLab + "widgetsnbextension~=4.0.12", # Installs widgets extension for classic notebooks +] [project.optional-dependencies] dev = ["watchfiles>=0.18.0"] +[project.urls] +homepage = "https://github.com/manzt/anywidget" + [tool.uv] dev-dependencies = [ - "comm>=0.1.4", "jupyterlab>=4.2.4", "msgspec>=0.18.6", "mypy>=1.11.1", "pydantic>=2.5.3", "pytest>=7.4.4", "ruff>=0.6.1", + "typing-extensions>=4.12.2", "watchfiles>=0.23.0", ] @@ -153,5 +155,5 @@ disallow_untyped_calls = false [[tool.mypy.overrides]] # this might be missing in pre-commit, but they aren't typed anyway -module = ["ipywidgets", "traitlets.*", "comm", "IPython.*"] +module = ["traitlets.*", "comm", "IPython.*"] ignore_missing_imports = true diff --git a/uv.lock b/uv.lock index 7bbffa14..d4199450 100644 --- a/uv.lock +++ b/uv.lock @@ -37,9 +37,11 @@ name = "anywidget" version = "0.9.13" source = { editable = "." } dependencies = [ - { name = "ipywidgets" }, + { name = "comm" }, + { name = "jupyterlab-widgets" }, { name = "psygnal" }, - { name = "typing-extensions" }, + { name = "traitlets" }, + { name = "widgetsnbextension" }, ] [package.optional-dependencies] @@ -49,33 +51,35 @@ dev = [ [package.dev-dependencies] dev = [ - { name = "comm" }, { name = "jupyterlab" }, { name = "msgspec" }, { name = "mypy" }, { name = "pydantic" }, { name = "pytest" }, { name = "ruff" }, + { name = "typing-extensions" }, { name = "watchfiles" }, ] [package.metadata] requires-dist = [ - { name = "ipywidgets", specifier = ">=7.6.0" }, + { name = "comm", specifier = ">=0.1.4" }, + { name = "jupyterlab-widgets", specifier = "~=3.0.12" }, { name = "psygnal", specifier = ">=0.8.1" }, - { name = "typing-extensions", specifier = ">=4.2.0" }, + { name = "traitlets", specifier = ">=4.3.1" }, { name = "watchfiles", marker = "extra == 'dev'", specifier = ">=0.18.0" }, + { name = "widgetsnbextension", specifier = "~=4.0.12" }, ] [package.metadata.requires-dev] dev = [ - { name = "comm", specifier = ">=0.1.4" }, { name = "jupyterlab", specifier = ">=4.2.4" }, { name = "msgspec", specifier = ">=0.18.6" }, { name = "mypy", specifier = ">=1.11.1" }, { name = "pydantic", specifier = ">=2.5.3" }, { name = "pytest", specifier = ">=7.4.4" }, { name = "ruff", specifier = ">=0.6.1" }, + { name = "typing-extensions", specifier = ">=4.12.2" }, { name = "watchfiles", specifier = ">=0.23.0" }, ] @@ -620,22 +624,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8d/97/8fe103906cd81bc42d3b0175b5534a9f67dccae47d6451131cf8d0d70bb2/ipython-8.12.3-py3-none-any.whl", hash = "sha256:b0340d46a933d27c657b211a329d0be23793c36595acf9e6ef4164bc01a1804c", size = 798307 }, ] -[[package]] -name = "ipywidgets" -version = "8.1.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "comm" }, - { name = "ipython" }, - { name = "jupyterlab-widgets" }, - { name = "traitlets" }, - { name = "widgetsnbextension" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c7/4c/dab2a281b07596a5fc220d49827fe6c794c66f1493d7a74f1df0640f2cc5/ipywidgets-8.1.5.tar.gz", hash = "sha256:870e43b1a35656a80c18c9503bbf2d16802db1cb487eec6fab27d683381dde17", size = 116723 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/22/2d/9c0b76f2f9cc0ebede1b9371b6f317243028ed60b90705863d493bae622e/ipywidgets-8.1.5-py3-none-any.whl", hash = "sha256:3290f526f87ae6e77655555baba4f36681c555b8bdbbff430b70e52c34c86245", size = 139767 }, -] - [[package]] name = "isoduration" version = "20.11.0" @@ -1260,8 +1248,6 @@ version = "6.0.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/18/c7/8c6872f7372eb6a6b2e4708b88419fb46b857f7a2e1892966b851cc79fc9/psutil-6.0.0.tar.gz", hash = "sha256:8faae4f310b6d969fa26ca0545338b21f73c6b15db7c4a8d934a5482faa818f2", size = 508067 } wheels = [ - { url = "https://files.pythonhosted.org/packages/c5/66/78c9c3020f573c58101dc43a44f6855d01bbbd747e24da2f0c4491200ea3/psutil-6.0.0-cp27-none-win32.whl", hash = "sha256:02b69001f44cc73c1c5279d02b30a817e339ceb258ad75997325e0e6169d8b35", size = 249766 }, - { url = "https://files.pythonhosted.org/packages/e1/3f/2403aa9558bea4d3854b0e5e567bc3dd8e9fbc1fc4453c0aa9aafeb75467/psutil-6.0.0-cp27-none-win_amd64.whl", hash = "sha256:21f1fb635deccd510f69f485b87433460a603919b45e2a324ad65b0cc74f8fb1", size = 253024 }, { url = "https://files.pythonhosted.org/packages/0b/37/f8da2fbd29690b3557cca414c1949f92162981920699cd62095a984983bf/psutil-6.0.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:c588a7e9b1173b6e866756dde596fd4cad94f9399daf99ad8c3258b3cb2b47a0", size = 250961 }, { url = "https://files.pythonhosted.org/packages/35/56/72f86175e81c656a01c4401cd3b1c923f891b31fbcebe98985894176d7c9/psutil-6.0.0-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ed2440ada7ef7d0d608f20ad89a04ec47d2d3ab7190896cd62ca5fc4fe08bf0", size = 287478 }, { url = "https://files.pythonhosted.org/packages/19/74/f59e7e0d392bc1070e9a70e2f9190d652487ac115bb16e2eff6b22ad1d24/psutil-6.0.0-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5fd9a97c8e94059b0ef54a7d4baf13b405011176c3b6ff257c247cae0d560ecd", size = 290455 },