Skip to content

Commit 00d7df7

Browse files
committed
allow widgets to be used in reactpy components
1 parent 3900cb2 commit 00d7df7

12 files changed

+512
-2177
lines changed

notebooks/introduction.ipynb

Lines changed: 235 additions & 33 deletions
Large diffs are not rendered by default.

package-lock.json

Lines changed: 90 additions & 2117 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
"dev": "vite",
77
"build": "vite build",
88
"preview": "vite preview",
9-
"lint": "prettier --ignore-path .gitignore --check ."
9+
"lint": "prettier --ignore-path .gitignore --check .",
10+
"fix:lint": "prettier --ignore-path .gitignore --write ."
1011
},
1112
"dependencies": {
1213
"@jupyter-widgets/base": "^6.0.4",

reactpy_jupyter.pth

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
import reactpy_jupyter

reactpy_jupyter/__init__.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,21 +5,22 @@
55
# Distributed under the terms of the Modified BSD License.
66

77
from . import jupyter_server_extension
8+
from .widget_component import from_widget
89
from .import_resources import setup_import_resources
910
from .ipython_extension import load_ipython_extension, unload_ipython_extension
10-
from .widget import LayoutWidget, run, set_import_source_base_url, widgetize
11+
from .layout_widget import to_widget, run, set_import_source_base_url
1112

1213
__version__ = "0.8.1" # DO NOT MODIFY
1314

14-
__all__ = [
15-
"LayoutWidget",
16-
"widgetize",
17-
"run",
15+
__all__ = (
16+
"from_widget",
1817
"load_ipython_extension",
1918
"unload_ipython_extension",
19+
"to_widget",
20+
"run",
2021
"set_import_source_base_url",
2122
"jupyter_server_extension",
22-
]
23+
)
2324

2425

2526
setup_import_resources()

reactpy_jupyter/import_resources.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
REACTPY_RESOURCE_BASE_PATH,
1515
REACTPY_WEB_MODULES_DIR,
1616
)
17-
from .widget import set_import_source_base_url
17+
from .layout_widget import set_import_source_base_url
1818

1919
logger = logging.getLogger(__name__)
2020

reactpy_jupyter/ipython_extension.py

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
from __future__ import annotations
22

3+
import time
34
from functools import partial
5+
from threading import Thread
46

57
from IPython import get_ipython
68
from IPython.core.interactiveshell import ExecutionResult, InteractiveShell
79
from IPython.display import display
810
from reactpy.core.component import ComponentType
911

10-
from .widget import LayoutWidget
12+
from .layout_widget import LayoutWidget
1113

1214
_EXTENSION_LOADED = False
1315
_POST_RUN_CELL_HOOK = None
@@ -36,5 +38,20 @@ def _post_run_cell(ipython: InteractiveShell, result: ExecutionResult) -> None:
3638
display(LayoutWidget(result.result))
3739

3840

39-
if get_ipython() is not None:
40-
load_ipython_extension(get_ipython())
41+
# THIS IS A DIRTY HACK
42+
# --------------------
43+
# The IPython extension must be loaded after the IPython kernel has started so we start
44+
# a thread that waits for the kernel to start and then loads the extension. We should
45+
# find a better way to do this.
46+
47+
48+
def _load_ipyhon_extension_thread_target() -> None:
49+
"""A hack to load the IPython extension after the IPython kernel has started"""
50+
for _ in range(50):
51+
if get_ipython() is not None:
52+
load_ipython_extension(get_ipython())
53+
return None
54+
time.sleep(0.1)
55+
56+
57+
Thread(target=_load_ipyhon_extension_thread_target, daemon=True).start()

reactpy_jupyter/widget.py renamed to reactpy_jupyter/layout_widget.py

Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,21 @@
66
from pathlib import Path
77
from queue import Queue as SyncQueue
88
from threading import Thread
9-
from typing import Any, Awaitable, Callable
9+
from typing import Any, Awaitable, Callable, overload
1010

1111
import anywidget
1212
from IPython.display import DisplayHandle
1313
from IPython.display import display as ipython_display
1414
from jsonpointer import set_pointer
1515
from reactpy.core.layout import Layout
1616
from reactpy.core.types import ComponentType
17-
from traitlets import Unicode
17+
from ipywidgets import Widget, widget_serialization
18+
from traitlets import Unicode, List, Instance
1819
from typing_extensions import ParamSpec
1920

20-
DEV = bool(int(os.environ.get("REACTPY_JUPYTER_DEV", "0")))
21+
from reactpy_jupyter.widget_component import InnerWidgets, inner_widgets_context
22+
23+
DEV = bool(int(os.environ.get("REACTPY_JUPYTER_DEV_SERVER", "0")))
2124

2225
if DEV:
2326
# from `npx vite`
@@ -45,12 +48,27 @@ def run(constructor: Callable[[], ComponentType]) -> DisplayHandle | None:
4548
_P = ParamSpec("_P")
4649

4750

48-
def widgetize(constructor: Callable[_P, ComponentType]) -> Callable[_P, LayoutWidget]:
49-
"""A decorator that turns an ReactPy element into a Jupyter Widget constructor"""
51+
@overload
52+
def to_widget(value: Callable[_P, ComponentType]) -> Callable[_P, LayoutWidget]:
53+
...
54+
55+
56+
@overload
57+
def to_widget(value: ComponentType) -> LayoutWidget:
58+
...
59+
60+
61+
def to_widget(
62+
value: Callable[_P, ComponentType] | ComponentType
63+
) -> Callable[_P, LayoutWidget] | LayoutWidget:
64+
"""Turn a component into a widget or a component construtor into a widget constructor"""
65+
66+
if isinstance(value, ComponentType):
67+
return LayoutWidget(value)
5068

51-
@wraps(constructor)
69+
@wraps(value)
5270
def wrapper(*args: Any, **kwargs: Any) -> LayoutWidget:
53-
return LayoutWidget(constructor(*args, **kwargs))
71+
return LayoutWidget(value(*args, **kwargs))
5472

5573
return wrapper
5674

@@ -60,12 +78,21 @@ class LayoutWidget(anywidget.AnyWidget):
6078

6179
_esm = ESM
6280
_import_source_base_url = Unicode().tag(sync=True)
81+
_inner_widgets = List(Instance(Widget)).tag(sync=True, **widget_serialization)
6382

6483
def __init__(self, component: ComponentType) -> None:
65-
super().__init__(_import_source_base_url=_IMPORT_SOURCE_BASE_URL)
84+
super().__init__(
85+
_import_source_base_url=_IMPORT_SOURCE_BASE_URL,
86+
_inner_widgets=[],
87+
)
6688
self._reactpy_model = {}
6789
self._reactpy_views = set()
68-
self._reactpy_layout = Layout(component)
90+
self._reactpy_layout = Layout(
91+
inner_widgets_context(
92+
component,
93+
value=InnerWidgets(self._add_inner_widget, self._remove_inner_widget),
94+
)
95+
)
6996
self._reactpy_loop = _spawn_threaded_event_loop(
7097
self._reactpy_layout_render_loop()
7198
)
@@ -107,6 +134,12 @@ async def _reactpy_layout_render_loop(self) -> None:
107134
for v_id in self._reactpy_views:
108135
self.send({"viewID": v_id, "data": update_message})
109136

137+
def _add_inner_widget(self, widget: Widget) -> None:
138+
self._inner_widgets = self._inner_widgets + [widget]
139+
140+
def _remove_inner_widget(self, widget: Widget) -> None:
141+
self._inner_widgets = [w for w in self._inner_widgets if w != widget]
142+
110143
def __repr__(self) -> str:
111144
return f"LayoutWidget({self._reactpy_layout})"
112145

reactpy_jupyter/widget_component.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
from __future__ import annotations
2+
3+
from typing import Callable
4+
from attr import dataclass
5+
from ipywidgets import Widget
6+
7+
from reactpy import component, create_context, use_context, use_effect, html
8+
from reactpy.types import VdomDict, Context
9+
10+
11+
inner_widgets_context: Context[InnerWidgets | None] = create_context(None)
12+
13+
14+
@component
15+
def from_widget(source: Widget) -> VdomDict:
16+
inner_widgets = use_context(inner_widgets_context)
17+
18+
@use_effect
19+
def add_widget():
20+
inner_widgets.add(source)
21+
return lambda: inner_widgets.remove(source)
22+
23+
if inner_widgets is None:
24+
raise RuntimeError("Jupyter component must be rendered inside a JupyterLayout")
25+
26+
return html.span({"id": f"widget-model-id-{source.model_id}"})
27+
28+
29+
@dataclass
30+
class InnerWidgets:
31+
add: Callable[[Widget], None]
32+
remove: Callable[[Widget], None]

requirements.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,5 @@ twine
22
jupyter-packaging
33
jupyter-repo2docker
44
noxopt
5+
jupyterlab
6+

setup.py

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from setuptools import find_packages, setup
1313
from setuptools.command.develop import develop
1414
from setuptools.command.sdist import sdist
15+
from setuptools.command.build_py import build_py
1516

1617
if sys.platform == "win32":
1718
from subprocess import list2cmdline
@@ -135,8 +136,9 @@ def list2cmdline(cmd_list):
135136
]
136137
)
137138

139+
138140
# --------------------------------------------------------------------------------------
139-
# Build Javascript
141+
# Setup Command Classes
140142
# --------------------------------------------------------------------------------------
141143

142144

@@ -163,19 +165,34 @@ def run(self):
163165
return Command
164166

165167

168+
PTH_FILE = ROOT_DIR / f"{NAME}.pth"
169+
170+
171+
def build_with_pth_file(cls):
172+
class Command(cls):
173+
def run(self):
174+
super().run()
175+
outfile = str(Path(self.build_lib, f"{NAME}.pth"))
176+
self.copy_file(str(PTH_FILE), outfile, preserve_mode=0)
177+
178+
return Command
179+
180+
166181
package["cmdclass"] = {
167-
"sdist": build_javascript_first(sdist),
168-
"develop": build_javascript_first(develop),
182+
"sdist": build_with_pth_file(build_javascript_first(sdist)),
183+
"develop": build_with_pth_file(build_javascript_first(develop)),
169184
}
170185

171186
if sys.version_info < (3, 10, 6):
172187
from distutils.command.build import build
173188

174-
package["cmdclass"]["build"] = build_javascript_first(build)
189+
package["cmdclass"]["build"] = build_with_pth_file(build_javascript_first(build))
175190
else:
176191
from setuptools.command.build_py import build_py
177192

178-
package["cmdclass"]["build_py"] = build_javascript_first(build_py)
193+
package["cmdclass"]["build_py"] = build_with_pth_file(
194+
build_javascript_first(build_py)
195+
)
179196

180197

181198
# --------------------------------------------------------------------------------------

src/index.js

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,33 @@ import { DOMWidgetView } from "@jupyter-widgets/base";
55
export function render(view) {
66
const client = new JupyterReactPyClient(view);
77
mount(view.el, client);
8+
9+
async function updateInnerWidgets() {
10+
/** @type {String[]} */
11+
let innerModelIds = view.model.get("_inner_widgets");
12+
13+
for (let modelId of innerModelIds.map((id) =>
14+
id.slice("IPY_MODEL_".length)
15+
)) {
16+
let model = await view.model.widget_manager.get_model(modelId);
17+
let childView = await view.create_child_view(model);
18+
let containerEl = await waitForSelector(`#widget-model-id-${modelId}`, view.el);
19+
containerEl.replaceChildren(childView.el);
20+
}
21+
}
22+
23+
view.model.on("change:_inner_widgets", updateInnerWidgets);
24+
25+
updateInnerWidgets();
826
}
927

1028
let viewID = 0;
1129

1230
class JupyterReactPyClient extends BaseReactPyClient {
1331
/**
1432
* @param view {DOMWidgetView}
15-
* @param viewID {number}
1633
*/
17-
constructor(view, viewId) {
34+
constructor(view) {
1835
super();
1936
this.view = view;
2037
this.viewID = viewID++;
@@ -109,3 +126,42 @@ function concatAndResolveUrl(url, concat) {
109126
}
110127
return url3.join("/");
111128
}
129+
130+
/**
131+
* @typedef {import("@jupyter-widgets/base").WidgetModel} WidgetModel
132+
* @param {string[]} modelIds
133+
* @param {import("@jupyter-widgets/base").IWidgetManager} widgetManager
134+
* @returns {Promise<WidgetModel[]>}
135+
*/
136+
async function unpackModels(modelIds, widgetManager) {
137+
return Promise.all(
138+
modelIds.map((id) => widgetManager.get_model(id.slice("IPY_MODEL_".length)))
139+
);
140+
}
141+
142+
/**
143+
* @param {String} selector
144+
* @param {HTMLElement} containerElement
145+
* @returns {Promise<HTMLElement>}
146+
*/
147+
function waitForSelector(selector, containerElement) {
148+
return new Promise((resolve) => {
149+
let quickEl;
150+
if ((quickEl = document.querySelector(selector))) {
151+
return resolve(quickEl);
152+
}
153+
154+
const observer = new MutationObserver((mutations) => {
155+
let obsEl;
156+
if ((obsEl = document.querySelector(selector))) {
157+
resolve(obsEl);
158+
observer.disconnect();
159+
}
160+
});
161+
162+
observer.observe(containerElement, {
163+
childList: true,
164+
subtree: true,
165+
});
166+
});
167+
}

0 commit comments

Comments
 (0)