From 9031259ce50021e78cf41f077598e7893f3bd568 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Sat, 20 May 2023 00:43:40 -0600 Subject: [PATCH 1/2] add a use_trait hook attached to widget wrapper --- notebooks/introduction.ipynb | 161 ++++++++++++++---- reactpy_jupyter/__init__.py | 10 +- .../{layout_widget.py => component_widget.py} | 16 +- reactpy_jupyter/hooks.py | 28 +++ reactpy_jupyter/import_resources.py | 2 +- reactpy_jupyter/monkey_patch.py | 21 +-- reactpy_jupyter/widget_component.py | 59 +++++-- 7 files changed, 219 insertions(+), 78 deletions(-) rename reactpy_jupyter/{layout_widget.py => component_widget.py} (92%) create mode 100644 reactpy_jupyter/hooks.py diff --git a/notebooks/introduction.ipynb b/notebooks/introduction.ipynb index 304c932..05e3d33 100644 --- a/notebooks/introduction.ipynb +++ b/notebooks/introduction.ipynb @@ -24,11 +24,27 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 1, "metadata": { "tags": [] }, - "outputs": [], + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "901546cd31e04580810d8358cbf46d72", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "LayoutWidget(Layout(ContextProvider()))" + ] + }, + "execution_count": 1, + "metadata": {}, + "output_type": "execute_result" + } + ], "source": [ "from reactpy import component, html\n", "\n", @@ -55,11 +71,27 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 2, "metadata": { "tags": [] }, - "outputs": [], + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "ee078bce581341f7826d8578cc03f971", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "LayoutWidget(Layout(ContextProvider()))" + ] + }, + "execution_count": 2, + "metadata": {}, + "output_type": "execute_result" + } + ], "source": [ "from reactpy import component, html\n", "\n", @@ -109,11 +141,27 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 3, "metadata": { "tags": [] }, - "outputs": [], + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "3e5123eaa6fe49fcb94f2527ec7665e8", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "LayoutWidget(Layout(ContextProvider()))" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], "source": [ "import json\n", "from pathlib import Path\n", @@ -164,9 +212,25 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 4, "metadata": {}, - "outputs": [], + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "cb6e7a22534d4db6b1c6b826689cf739", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "LayoutWidget(Layout(ContextProvider()))" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], "source": [ "from reactpy_jupyter import from_widget\n", "from ipywidgets import IntSlider\n", @@ -182,38 +246,37 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "Let's consider a ReactPy component that responds to and displays changes from an `ipywidgets.IntSlider`. The ReactPy component will need to accept an `IntSlider` instance as one of its arguments, convert it to a component with `from_widget`, declare state that will track the slider's value, and register a lister that will update that state via the slider's `IntSlider.observe()` method using an [\"effect\"](https://reactpy.dev/docs/reference/hooks-api.html#use-effect):" + "Let's consider a ReactPy component that mirrors an `ipywidgets.IntSlider` - that is, it displays a slider that moves when the `IntSlider` does and when moved alters the `IntSlider`. To accomplish this, the ReactPy component will need to accept an `IntSlider` instance as one of its arguments, convert it to a component with `from_widget`, and access the attributes it expects to change or that need to be changed via a `use_trait` method on the converted widget:" ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 10, "metadata": { "tags": [] }, "outputs": [], "source": [ - "from reactpy import use_effect\n", "from reactpy_jupyter import from_widget\n", "\n", "\n", "@component\n", - "def SliderObserver(slider):\n", - " slider_component = from_widget(slider)\n", - " value, set_value = use_state(0)\n", - "\n", - " @use_effect\n", - " def register_observer():\n", - " def handle_change(change):\n", - " set_value(change[\"new\"])\n", - "\n", - " # observe the slider's value\n", - " slider.observe(handle_change, \"value\")\n", - " # unobserve the slider's value if this component is no longer displayed\n", - " return lambda: slider.unobserve(handle_change, \"value\")\n", - "\n", + "def MirrorSlider(slider_widget):\n", + " slider_component = from_widget(slider_widget)\n", + " value, set_value = slider_component.use_trait(\"value\")\n", " return html.div(\n", - " slider_component, html.p(f\"ReactPy observes the value to be: \", value)\n", + " html.h3(\"Jupyter Slider\"),\n", + " # slider_component,\n", + " html.h3(\"ReactPy Slider\"),\n", + " html.input(\n", + " {\n", + " \"type\": \"range\",\n", + " \"min\": slider_widget.min,\n", + " \"max\": slider_widget.max,\n", + " \"value\": value,\n", + " \"on_change\": lambda event: set_value(event[\"target\"][\"value\"]),\n", + " }\n", + " ),\n", " )" ] }, @@ -227,15 +290,31 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 11, "metadata": { "tags": [] }, - "outputs": [], + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "48a4b16d9b7149fe9bbc8cbf5c20bd6c", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "LayoutWidget(Layout(ContextProvider()))" + ] + }, + "execution_count": 11, + "metadata": {}, + "output_type": "execute_result" + } + ], "source": [ "from ipywidgets import IntSlider\n", "\n", - "SliderObserver(IntSlider(readout=False))" + "MirrorSlider(IntSlider(readout=False))" ] }, { @@ -248,19 +327,35 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 9, "metadata": { "tags": [] }, - "outputs": [], + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "4fc27c4a9ae04351b140ca4bcb15e5be", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "Box(children=(LayoutWidget(Layout(ContextProvider())), LayoutWidget(Layout…" + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + } + ], "source": [ "from ipywidgets import Box\n", "from reactpy_jupyter import to_widget\n", "\n", "slider = IntSlider(readout=False)\n", - "slider_observer_widget = to_widget(SliderObserver(slider))\n", + "slider_observer_widget = to_widget(MirrorSlider(slider))\n", "\n", - "Box([slider, slider_observer_widget])" + "Box([slider_observer_widget, slider_observer_widget])" ] }, { diff --git a/reactpy_jupyter/__init__.py b/reactpy_jupyter/__init__.py index 60ea3a6..098894d 100644 --- a/reactpy_jupyter/__init__.py +++ b/reactpy_jupyter/__init__.py @@ -5,8 +5,9 @@ # Distributed under the terms of the Modified BSD License. from . import jupyter_server_extension +from .component_widget import run, set_import_source_base_url, to_widget +from .hooks import use_trait from .import_resources import setup_import_resources -from .layout_widget import run, set_import_source_base_url, to_widget from .monkey_patch import execute_patch from .widget_component import from_widget @@ -14,12 +15,13 @@ __all__ = ( "from_widget", + "jupyter_server_extension", "load_ipython_extension", - "unload_ipython_extension", - "to_widget", "run", "set_import_source_base_url", - "jupyter_server_extension", + "to_widget", + "unload_ipython_extension", + "use_trait", ) diff --git a/reactpy_jupyter/layout_widget.py b/reactpy_jupyter/component_widget.py similarity index 92% rename from reactpy_jupyter/layout_widget.py rename to reactpy_jupyter/component_widget.py index 4a7de1a..52d6d85 100644 --- a/reactpy_jupyter/layout_widget.py +++ b/reactpy_jupyter/component_widget.py @@ -35,38 +35,38 @@ def run(constructor: Callable[[], ComponentType]) -> DisplayHandle | None: This function is meant to be similarly to ``reactpy.run``. """ - return ipython_display(LayoutWidget(constructor())) + return ipython_display(ComponentWidget(constructor())) _P = ParamSpec("_P") @overload -def to_widget(value: Callable[_P, ComponentType]) -> Callable[_P, LayoutWidget]: +def to_widget(value: Callable[_P, ComponentType]) -> Callable[_P, ComponentWidget]: ... @overload -def to_widget(value: ComponentType) -> LayoutWidget: +def to_widget(value: ComponentType) -> ComponentWidget: ... def to_widget( value: Callable[_P, ComponentType] | ComponentType -) -> Callable[_P, LayoutWidget] | LayoutWidget: +) -> Callable[_P, ComponentWidget] | ComponentWidget: """Turn a component into a widget or a component construtor into a widget constructor""" if isinstance(value, ComponentType): - return LayoutWidget(value) + return ComponentWidget(value) @wraps(value) - def wrapper(*args: Any, **kwargs: Any) -> LayoutWidget: - return LayoutWidget(value(*args, **kwargs)) + def wrapper(*args: Any, **kwargs: Any) -> ComponentWidget: + return ComponentWidget(value(*args, **kwargs)) return wrapper -class LayoutWidget(anywidget.AnyWidget): +class ComponentWidget(anywidget.AnyWidget): """A widget for displaying ReactPy elements""" _esm = ESM diff --git a/reactpy_jupyter/hooks.py b/reactpy_jupyter/hooks.py new file mode 100644 index 0000000..0356607 --- /dev/null +++ b/reactpy_jupyter/hooks.py @@ -0,0 +1,28 @@ +from typing import Any + +from reactpy import use_effect, use_state +from reactpy.types import State +from traitlets import HasTraits + + +def use_trait(obj: HasTraits, name: str) -> State[Any]: + """Hook to use the attribute of a HasTraits object as a state variable + + This works on Jupyter Widgets, for example. + """ + value, set_value = use_state(lambda: getattr(obj, name)) + + @use_effect + def register_observer(): + def handle_change(change): + set_value(change["new"]) + + # observe the slider's value + obj.observe(handle_change, "value") + # unobserve the slider's value if this component is no longer displayed + return lambda: obj.unobserve(handle_change, "value") + + def set_trait(new_value: Any) -> None: + setattr(obj, name, new_value) + + return State(value, set_trait) diff --git a/reactpy_jupyter/import_resources.py b/reactpy_jupyter/import_resources.py index e57d0bb..642f368 100644 --- a/reactpy_jupyter/import_resources.py +++ b/reactpy_jupyter/import_resources.py @@ -10,11 +10,11 @@ import requests from notebook import notebookapp +from .component_widget import set_import_source_base_url from .jupyter_server_extension import ( REACTPY_RESOURCE_BASE_PATH, REACTPY_WEB_MODULES_DIR, ) -from .layout_widget import set_import_source_base_url logger = logging.getLogger(__name__) diff --git a/reactpy_jupyter/monkey_patch.py b/reactpy_jupyter/monkey_patch.py index a60452e..1ffdf34 100644 --- a/reactpy_jupyter/monkey_patch.py +++ b/reactpy_jupyter/monkey_patch.py @@ -1,25 +1,8 @@ -from typing import Any -from weakref import finalize - from reactpy.core.component import Component -from reactpy_jupyter.layout_widget import to_widget - -# we can't track the widgets by adding them as a hidden attribute to the component -# because Component has __slots__ defined -LIVE_WIDGETS: dict[int, Any] = {} +from reactpy_jupyter.widget_component import WidgetComponent def execute_patch() -> None: """Monkey patch ReactPy's Component class to display as a Jupyter widget""" - - def _repr_mimebundle_(self: Component, *a, **kw) -> None: - self_id = id(self) - if self_id not in LIVE_WIDGETS: - widget = LIVE_WIDGETS[self_id] = to_widget(self) - finalize(self, lambda: LIVE_WIDGETS.pop(self_id, None)) - else: - widget = LIVE_WIDGETS[self_id] - return widget._repr_mimebundle_(*a, **kw) - - Component._repr_mimebundle_ = _repr_mimebundle_ + Component._repr_mimebundle_ = WidgetComponent._repr_mimebundle_ diff --git a/reactpy_jupyter/widget_component.py b/reactpy_jupyter/widget_component.py index fd96971..61550e0 100644 --- a/reactpy_jupyter/widget_component.py +++ b/reactpy_jupyter/widget_component.py @@ -1,28 +1,61 @@ from __future__ import annotations -from typing import Callable +from typing import Any, Callable +from weakref import finalize from attr import dataclass from ipywidgets import Widget -from reactpy import component, create_context, html, use_context, use_effect -from reactpy.types import Context, VdomDict +from reactpy import create_context, html, use_context, use_effect +from reactpy.types import Context, Key, State, VdomDict + +import reactpy_jupyter +from reactpy_jupyter.hooks import use_trait as _use_trait + +# we can't track the widgets by adding them as a hidden attribute to the component +# because Component has __slots__ defined +LIVE_WIDGETS: dict[int, Any] = {} inner_widgets_context: Context[InnerWidgets | None] = create_context(None) -@component -def from_widget(source: Widget) -> VdomDict: - inner_widgets = use_context(inner_widgets_context) +def from_widget(source: Widget, key: Key | None = None) -> WidgetComponent: + return WidgetComponent(source, key) + + +class WidgetComponent: + """implements reactpy.types.ComponentType""" + + def __init__(self, widget: Widget, key: Key | None) -> None: + self.widget = widget + self.type = type(widget) + self.key = key + + def use_trait(self, name: str) -> State[Any]: + return _use_trait(self.widget, name) + + def render(self) -> VdomDict: + inner_widgets = use_context(inner_widgets_context) + + @use_effect + def add_widget(): + inner_widgets.add(self.widget) + return lambda: inner_widgets.remove(self.widget) - @use_effect - def add_widget(): - inner_widgets.add(source) - return lambda: inner_widgets.remove(source) + if inner_widgets is None: + raise RuntimeError( + "Jupyter component must be rendered inside a JupyterLayout" + ) - if inner_widgets is None: - raise RuntimeError("Jupyter component must be rendered inside a JupyterLayout") + return html.span({"class": f"widget-model-id-{self.widget.model_id}"}) - return html.span({"class": f"widget-model-id-{source.model_id}"}) + def _repr_mimebundle_(self, *args: Any, **kwargs: Any) -> None: + self_id = id(self) + if self_id not in LIVE_WIDGETS: + widget = LIVE_WIDGETS[self_id] = reactpy_jupyter.to_widget(self) + finalize(self, lambda: LIVE_WIDGETS.pop(self_id, None)) + else: + widget = LIVE_WIDGETS[self_id] + return widget._repr_mimebundle_(*args, **kwargs) @dataclass From f1c419b39c270f01725b7dcc6517ff4282fcefa7 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Sat, 20 May 2023 00:45:21 -0600 Subject: [PATCH 2/2] clear outputs --- notebooks/introduction.ipynb | 122 ++++------------------------------- 1 file changed, 13 insertions(+), 109 deletions(-) diff --git a/notebooks/introduction.ipynb b/notebooks/introduction.ipynb index 05e3d33..22b3360 100644 --- a/notebooks/introduction.ipynb +++ b/notebooks/introduction.ipynb @@ -24,27 +24,11 @@ }, { "cell_type": "code", - "execution_count": 1, + "execution_count": null, "metadata": { "tags": [] }, - "outputs": [ - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "901546cd31e04580810d8358cbf46d72", - "version_major": 2, - "version_minor": 0 - }, - "text/plain": [ - "LayoutWidget(Layout(ContextProvider()))" - ] - }, - "execution_count": 1, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "from reactpy import component, html\n", "\n", @@ -71,27 +55,11 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": null, "metadata": { "tags": [] }, - "outputs": [ - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "ee078bce581341f7826d8578cc03f971", - "version_major": 2, - "version_minor": 0 - }, - "text/plain": [ - "LayoutWidget(Layout(ContextProvider()))" - ] - }, - "execution_count": 2, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "from reactpy import component, html\n", "\n", @@ -141,27 +109,11 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": null, "metadata": { "tags": [] }, - "outputs": [ - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "3e5123eaa6fe49fcb94f2527ec7665e8", - "version_major": 2, - "version_minor": 0 - }, - "text/plain": [ - "LayoutWidget(Layout(ContextProvider()))" - ] - }, - "execution_count": 3, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "import json\n", "from pathlib import Path\n", @@ -212,25 +164,9 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "cb6e7a22534d4db6b1c6b826689cf739", - "version_major": 2, - "version_minor": 0 - }, - "text/plain": [ - "LayoutWidget(Layout(ContextProvider()))" - ] - }, - "execution_count": 4, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "from reactpy_jupyter import from_widget\n", "from ipywidgets import IntSlider\n", @@ -251,7 +187,7 @@ }, { "cell_type": "code", - "execution_count": 10, + "execution_count": null, "metadata": { "tags": [] }, @@ -290,27 +226,11 @@ }, { "cell_type": "code", - "execution_count": 11, + "execution_count": null, "metadata": { "tags": [] }, - "outputs": [ - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "48a4b16d9b7149fe9bbc8cbf5c20bd6c", - "version_major": 2, - "version_minor": 0 - }, - "text/plain": [ - "LayoutWidget(Layout(ContextProvider()))" - ] - }, - "execution_count": 11, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "from ipywidgets import IntSlider\n", "\n", @@ -327,27 +247,11 @@ }, { "cell_type": "code", - "execution_count": 9, + "execution_count": null, "metadata": { "tags": [] }, - "outputs": [ - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "4fc27c4a9ae04351b140ca4bcb15e5be", - "version_major": 2, - "version_minor": 0 - }, - "text/plain": [ - "Box(children=(LayoutWidget(Layout(ContextProvider())), LayoutWidget(Layout…" - ] - }, - "execution_count": 9, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "from ipywidgets import Box\n", "from reactpy_jupyter import to_widget\n",