From 2d081656f9133108e77b41b47459a1d9ad0c58bd Mon Sep 17 00:00:00 2001 From: Trevor Manz Date: Wed, 24 Jul 2024 17:29:54 -0400 Subject: [PATCH 1/5] feat: Patch ipywidgets to be more flexible with child widgets --- anywidget/__init__.py | 3 + anywidget/_patch_ipywidgets.py | 104 +++++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 anywidget/_patch_ipywidgets.py diff --git a/anywidget/__init__.py b/anywidget/__init__.py index f89d8725..7f1cecd4 100644 --- a/anywidget/__init__.py +++ b/anywidget/__init__.py @@ -2,11 +2,14 @@ from __future__ import annotations +from ._patch_ipywidgets import _patch_ipywidgets from ._version import __version__ from .widget import AnyWidget __all__ = ["AnyWidget", "__version__"] +_patch_ipywidgets() + def _jupyter_labextension_paths() -> list[dict]: return [{"src": "labextension", "dest": "anywidget"}] diff --git a/anywidget/_patch_ipywidgets.py b/anywidget/_patch_ipywidgets.py new file mode 100644 index 00000000..d2eaf42b --- /dev/null +++ b/anywidget/_patch_ipywidgets.py @@ -0,0 +1,104 @@ +"""ipywidgets patch module. + +Patches ipywidgets to allow for more flexible serialization and +deserialization of (any)widgets by allowing objects that are not +strict instances of `ipywidgets.Widget`. + +Only `ipywidgets.Box` and `ipywidgets.widgets.widget_link.Link` +give problems. This code is mostly vendored from ipywidgets and +modified to allow for more flexibility. +""" + +from __future__ import annotations + +import typing as t + +import ipywidgets +import traitlets +from ipywidgets.widgets.trait_types import TypedTuple +from ipywidgets.widgets.widget import Widget, _instances + +from ._descriptor import _COMMS + + +def _get_model_id(x: t.Any) -> str | None: + """Get the model id of a widget or comm.""" + if isinstance(x, Widget): + return x.model_id + maybe_comm = _COMMS.get(id(x), None) + return getattr(maybe_comm, "comm_id", None) + + +def _widget_to_json(x: t.Any, obj: t.Any) -> t.Any: + """Recursively convert a widget to json.""" + if isinstance(x, dict): + return {k: _widget_to_json(v, obj) for k, v in x.items()} + elif isinstance(x, (list, tuple)): + return [_widget_to_json(v, obj) for v in x] + model_id = _get_model_id(x) + return f"IPY_MODEL_{model_id}" if model_id else x + + +def _json_to_widget(x: t.Any, obj: t.Any) -> t.Any: + """Recursively convert json to a widget.""" + if isinstance(x, dict): + return {k: _json_to_widget(v, obj) for k, v in x.items()} + elif isinstance(x, (list, tuple)): + return [_json_to_widget(v, obj) for v in x] + elif isinstance(x, str) and x.startswith("IPY_MODEL_") and x[10:] in _instances: + return _instances[x[10:]] + else: + return x + + +class WidgetTrait(traitlets.TraitType): + """Traitlet for validating things that can be (de)serialized into widgets.""" + + # anything that can get a model id is ok as a widget + def validate(self, obj: t.Any, value: t.Any): + if _get_model_id(value) is not None: + return value + else: + self.error(obj, value) + + +# Adapted from https://github.com/jupyter-widgets/ipywidgets/blob/bb2edf78e7dac26e4b15522a267d7b477026a840/python/ipywidgets/ipywidgets/widgets/widget_link.py#L15 +class WidgetTraitTuple(traitlets.Tuple): + """Traitlet for validating a single (Widget, 'trait_name') pair.""" + + info_text = "A (Widget, 'trait_name') pair" + + def __init__(self): + super().__init__(WidgetTrait(), traitlets.Unicode()) + + def validate_elements(self, obj: t.Any, value: t.Any) -> t.Any: + value = super().validate_elements(obj, value) + widget, trait_name = value + trait = widget.traits().get(trait_name) + trait_repr = f"{widget.__class__.__name__}.{trait_name}" + # Can't raise TraitError because the parent will swallow the message + # and throw it away in a new, less informative TraitError + if trait is None: + raise TypeError(f"No such trait: {trait_repr}") + elif not trait.metadata.get("sync"): + raise TypeError(f"{trait_repr} cannot be synced") + return value + + +def patch_ipywidgets() -> None: + """Patch ipywidgets to allow for more flexible serialization and deserialization.""" + ipywidgets.Box.children.metadata["to_json"] = _widget_to_json + ipywidgets.Box.children.metadata["from_json"] = _json_to_widget + ipywidgets.Box.children.validate = TypedTuple(WidgetTrait()).validate + + ipywidgets.widgets.widget_link.Link.source.metadata["to_json"] = _widget_to_json + ipywidgets.widgets.widget_link.Link.source.metadata["from_json"] = _json_to_widget + ipywidgets.widgets.widget_link.Link.source.validate_elements = ( + WidgetTraitTuple().validate_elements + ) + + ipywidgets.widgets.widget_link.Link.target.metadata["to_json"] = _widget_to_json + ipywidgets.widgets.widget_link.Link.target.metadata["from_json"] = _json_to_widget + ipywidgets.widgets.widget_link.Link.target.validate_elements = ( + WidgetTraitTuple().validate_elements + ) From c024d0ae8bf0649e8de70905f659c833d5c18936 Mon Sep 17 00:00:00 2001 From: Trevor Manz Date: Wed, 24 Jul 2024 17:46:42 -0400 Subject: [PATCH 2/5] formatting --- anywidget/_cellmagic.py | 2 +- anywidget/_patch_ipywidgets.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/anywidget/_cellmagic.py b/anywidget/_cellmagic.py index 7fe464fb..99f345b8 100644 --- a/anywidget/_cellmagic.py +++ b/anywidget/_cellmagic.py @@ -37,7 +37,7 @@ def vfile(self, line: str, cell: str) -> None: self._files[name] = vfile _VIRTUAL_FILES[name] = vfile - @line_magic # type: ignore[misc] + @line_magic # type: ignore[misc] def clear_vfiles(self, line: str) -> None: """Clear all virtual files.""" self._files.clear() diff --git a/anywidget/_patch_ipywidgets.py b/anywidget/_patch_ipywidgets.py index d2eaf42b..41902711 100644 --- a/anywidget/_patch_ipywidgets.py +++ b/anywidget/_patch_ipywidgets.py @@ -85,7 +85,7 @@ def validate_elements(self, obj: t.Any, value: t.Any) -> t.Any: return value -def patch_ipywidgets() -> None: +def _patch_ipywidgets() -> None: """Patch ipywidgets to allow for more flexible serialization and deserialization.""" ipywidgets.Box.children.metadata["to_json"] = _widget_to_json ipywidgets.Box.children.metadata["from_json"] = _json_to_widget From f03364ef140bbab3912cbe22580e04657f7665ba Mon Sep 17 00:00:00 2001 From: Trevor Manz Date: Wed, 24 Jul 2024 17:50:32 -0400 Subject: [PATCH 3/5] fix: typechecking --- anywidget/_patch_ipywidgets.py | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/anywidget/_patch_ipywidgets.py b/anywidget/_patch_ipywidgets.py index 41902711..3bdb6682 100644 --- a/anywidget/_patch_ipywidgets.py +++ b/anywidget/_patch_ipywidgets.py @@ -15,13 +15,14 @@ import ipywidgets import traitlets -from ipywidgets.widgets.trait_types import TypedTuple -from ipywidgets.widgets.widget import Widget, _instances +from ipywidgets import Widget from ._descriptor import _COMMS +_WIDGET_INSTANCES = ipywidgets.widgets.widget._instances -def _get_model_id(x: t.Any) -> str | None: + +def _get_model_id(x: t.Any) -> t.Any: """Get the model id of a widget or comm.""" if isinstance(x, Widget): return x.model_id @@ -45,8 +46,12 @@ def _json_to_widget(x: t.Any, obj: t.Any) -> t.Any: return {k: _json_to_widget(v, obj) for k, v in x.items()} elif isinstance(x, (list, tuple)): return [_json_to_widget(v, obj) for v in x] - elif isinstance(x, str) and x.startswith("IPY_MODEL_") and x[10:] in _instances: - return _instances[x[10:]] + elif ( + isinstance(x, str) + and x.startswith("IPY_MODEL_") + and x[10:] in _WIDGET_INSTANCES + ): + return _WIDGET_INSTANCES[x[10:]] else: return x @@ -55,7 +60,7 @@ class WidgetTrait(traitlets.TraitType): """Traitlet for validating things that can be (de)serialized into widgets.""" # anything that can get a model id is ok as a widget - def validate(self, obj: t.Any, value: t.Any): + def validate(self, obj: t.Any, value: t.Any) -> t.Any: if _get_model_id(value) is not None: return value else: @@ -68,7 +73,7 @@ class WidgetTraitTuple(traitlets.Tuple): info_text = "A (Widget, 'trait_name') pair" - def __init__(self): + def __init__(self) -> None: super().__init__(WidgetTrait(), traitlets.Unicode()) def validate_elements(self, obj: t.Any, value: t.Any) -> t.Any: @@ -89,7 +94,9 @@ def _patch_ipywidgets() -> None: """Patch ipywidgets to allow for more flexible serialization and deserialization.""" ipywidgets.Box.children.metadata["to_json"] = _widget_to_json ipywidgets.Box.children.metadata["from_json"] = _json_to_widget - ipywidgets.Box.children.validate = TypedTuple(WidgetTrait()).validate + ipywidgets.Box.children.validate = ipywidgets.widgets.trait_types.TypedTuple( + WidgetTrait() + ).validate ipywidgets.widgets.widget_link.Link.source.metadata["to_json"] = _widget_to_json ipywidgets.widgets.widget_link.Link.source.metadata["from_json"] = _json_to_widget From 3fefe5be461b4d5a473780664dc5b6a8bd339f90 Mon Sep 17 00:00:00 2001 From: Trevor Manz Date: Wed, 24 Jul 2024 18:06:18 -0400 Subject: [PATCH 4/5] fix: typing imports --- anywidget/_patch_ipywidgets.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/anywidget/_patch_ipywidgets.py b/anywidget/_patch_ipywidgets.py index 3bdb6682..1ab41d42 100644 --- a/anywidget/_patch_ipywidgets.py +++ b/anywidget/_patch_ipywidgets.py @@ -19,7 +19,7 @@ from ._descriptor import _COMMS -_WIDGET_INSTANCES = ipywidgets.widgets.widget._instances +_IPYWIDGETS_INSTANCES = ipywidgets.widgets.widget._instances def _get_model_id(x: t.Any) -> t.Any: @@ -49,9 +49,9 @@ def _json_to_widget(x: t.Any, obj: t.Any) -> t.Any: elif ( isinstance(x, str) and x.startswith("IPY_MODEL_") - and x[10:] in _WIDGET_INSTANCES + and x[10:] in _IPYWIDGETS_INSTANCES ): - return _WIDGET_INSTANCES[x[10:]] + return _IPYWIDGETS_INSTANCES[x[10:]] else: return x @@ -60,7 +60,7 @@ class WidgetTrait(traitlets.TraitType): """Traitlet for validating things that can be (de)serialized into widgets.""" # anything that can get a model id is ok as a widget - def validate(self, obj: t.Any, value: t.Any) -> t.Any: + def validate(self, obj: t.Any, value: t.Any): if _get_model_id(value) is not None: return value else: @@ -73,7 +73,7 @@ class WidgetTraitTuple(traitlets.Tuple): info_text = "A (Widget, 'trait_name') pair" - def __init__(self) -> None: + def __init__(self): super().__init__(WidgetTrait(), traitlets.Unicode()) def validate_elements(self, obj: t.Any, value: t.Any) -> t.Any: From 1ec7789bf11ee05e72f9aec8bceaf31d410be5cf Mon Sep 17 00:00:00 2001 From: Trevor Manz Date: Wed, 24 Jul 2024 18:10:59 -0400 Subject: [PATCH 5/5] ugh I hate untyped things --- anywidget/_patch_ipywidgets.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/anywidget/_patch_ipywidgets.py b/anywidget/_patch_ipywidgets.py index 1ab41d42..25dc7f76 100644 --- a/anywidget/_patch_ipywidgets.py +++ b/anywidget/_patch_ipywidgets.py @@ -60,7 +60,7 @@ class WidgetTrait(traitlets.TraitType): """Traitlet for validating things that can be (de)serialized into widgets.""" # anything that can get a model id is ok as a widget - def validate(self, obj: t.Any, value: t.Any): + def validate(self, obj: t.Any, value: t.Any) -> t.Any: if _get_model_id(value) is not None: return value else: @@ -73,7 +73,7 @@ class WidgetTraitTuple(traitlets.Tuple): info_text = "A (Widget, 'trait_name') pair" - def __init__(self): + def __init__(self) -> None: super().__init__(WidgetTrait(), traitlets.Unicode()) def validate_elements(self, obj: t.Any, value: t.Any) -> t.Any: