From f8189bb0dc936ed16ae975c440bd5f79d2d7d02a Mon Sep 17 00:00:00 2001 From: Trevor Manz Date: Wed, 26 Jun 2024 15:03:09 -0400 Subject: [PATCH 1/4] feat: Implement hoisting --- anywidget/widget.py | 36 ++++++++++--- packages/anywidget/src/widget.js | 89 ++++++++++++++++++++++++++------ 2 files changed, 103 insertions(+), 22 deletions(-) diff --git a/anywidget/widget.py b/anywidget/widget.py index c9e013e0..0e3bed3c 100644 --- a/anywidget/widget.py +++ b/anywidget/widget.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Any +import typing import ipywidgets import traitlets.traitlets as t @@ -21,6 +21,27 @@ from ._version import _ANYWIDGET_SEMVER_VERSION from .experimental import _collect_anywidget_commands, _register_anywidget_commands +if typing.TYPE_CHECKING: + import pathlib + + +class _Asset(ipywidgets.Widget): + data = t.Unicode().tag(sync=True) + + def __init__(self, data: str | pathlib.Path) -> None: + file_contents = try_file_contents(data) + super().__init__(data=str(file_contents) if file_contents else data) + if file_contents: + file_contents.changed.connect( + lambda new_contents: setattr(self, "data", new_contents) + ) + self._file_contents = file_contents + + def as_traittype(self) -> t.TraitType: + return t.Instance(_Asset, default_value=self).tag( + sync=True, to_json=lambda x, _: "anywidget-asset:" + x.model_id + ) + class AnyWidget(ipywidgets.DOMWidget): # type: ignore [misc] """Main AnyWidget base class.""" @@ -33,7 +54,7 @@ class AnyWidget(ipywidgets.DOMWidget): # type: ignore [misc] _view_module = t.Unicode("anywidget").tag(sync=True) _view_module_version = t.Unicode(_ANYWIDGET_SEMVER_VERSION).tag(sync=True) - def __init__(self, *args: Any, **kwargs: Any) -> None: + def __init__(self, *args: typing.Any, **kwargs: typing.Any) -> None: if in_colab(): enable_custom_widget_manager_once() @@ -49,7 +70,7 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: # show default _esm if not defined if not hasattr(self, _ESM_KEY): - anywidget_traits[_ESM_KEY] = t.Unicode(_DEFAULT_ESM).tag(sync=True) + anywidget_traits[_ESM_KEY] = _Asset(data=_DEFAULT_ESM).as_traittype() # TODO: a better way to uniquely identify this subclasses? # We use the fully-qualified name to get an id which we @@ -66,10 +87,11 @@ def __init_subclass__(cls, **kwargs: dict) -> None: """Coerces _esm and _css to FileContents if they are files.""" 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) + value = getattr(cls, key) + if isinstance(value, t.TraitType): + # we don't know how to handle this + continue + setattr(cls, key, _Asset(value)) _collect_anywidget_commands(cls) def _repr_mimebundle_(self, **kwargs: dict) -> tuple[dict, dict] | None: diff --git a/packages/anywidget/src/widget.js b/packages/anywidget/src/widget.js index f60ebcd6..ba6e6d20 100644 --- a/packages/anywidget/src/widget.js +++ b/packages/anywidget/src/widget.js @@ -243,6 +243,72 @@ function throw_anywidget_error(source) { throw source; } +/** + * @param {unknown} v + * @return {v is {}} + */ +function is_object(v) { + return typeof v === "object" && v !== null; +} + +/** + * @param {unknown} v + * @return {v is import("@jupyter-widgets/base").DOMWidgetModel} + */ +function is_model(v) { + return is_object(v) && "on" in v && typeof v.on === "function"; +} + +/** + * @template {"_esm" | "_css"} T + * @param {import("@jupyter-widgets/base").DOMWidgetModel} model + * @param {T} asset_name + * @returns {{ get(name: T): string, on(event: `change:${T}`, callback: () => void): void, off(event: `change:${T}`): void }} + */ +function resolve_asset_model(model, asset_name) { + let value = model.get(asset_name); + if (is_model(value)) { + return { + /** @param {T} _name */ + get(_name) { + return value.get("data"); + }, + /** + * @param {`change:${T}`} _event + * @param {() => void} callback + */ + on(_event, callback) { + value.on("change:data", callback); + }, + /** + * @param {`change:${T}`} _event + */ + off(_event) { + return value.off("change:data"); + }, + }; + } + return model; +} + +/** + * @template {"_esm" | "_css"} T + * @param {import("@jupyter-widgets/base").DOMWidgetModel} base_model + * @param {T} asset_name + * @param {() => void} cb + */ +function create_asset_signal(base_model, asset_name, cb) { + let model = resolve_asset_model(base_model, asset_name); + /** @type {import("solid-js").Signal} */ + let [asset, set_asset] = solid.createSignal(model.get(asset_name)); + model.on(`change:${asset_name}`, () => { + cb(); + set_asset(model.get(asset_name)); + }); + solid.onCleanup(() => model.off(`change:${asset_name}`)); + return asset; +} + /** * @typedef InvokeOptions * @prop {DataView[]} [buffers] @@ -301,25 +367,18 @@ class Runtime { /** @param {import("@jupyter-widgets/base").DOMWidgetModel} model */ constructor(model) { + let id = () => model.get("_anywidget_id"); + this.#disposer = solid.createRoot((dispose) => { - let [css, set_css] = solid.createSignal(model.get("_css")); - model.on("change:_css", () => { - let id = model.get("_anywidget_id"); - console.debug(`[anywidget] css hot updated: ${id}`); - set_css(model.get("_css")); - }); - solid.createEffect(() => { - let id = model.get("_anywidget_id"); - load_css(css(), id); + let css = create_asset_signal(model, "_css", () => { + console.debug(`[anywidget] css hot updated: ${id()}`); }); + solid.createEffect(() => load_css(css(), id())); - /** @type {import("solid-js").Signal} */ - let [esm, setEsm] = solid.createSignal(model.get("_esm")); - model.on("change:_esm", async () => { - let id = model.get("_anywidget_id"); - console.debug(`[anywidget] esm hot updated: ${id}`); - setEsm(model.get("_esm")); + let esm = create_asset_signal(model, "_esm", () => { + console.debug(`[anywidget] esm hot updated: ${id()}`); }); + /** @type {void | (() => import("vitest").Awaitable)} */ let cleanup; this.#widget_result = solid.createResource(esm, async (update) => { From e1d230b9c7cb4eb78d53b6dad9f40363fbc115c4 Mon Sep 17 00:00:00 2001 From: Trevor Manz Date: Wed, 24 Jul 2024 11:14:56 -0400 Subject: [PATCH 2/4] refactor: static asset --- anywidget/_cellmagic.py | 2 +- anywidget/_static_asset.py | 63 ++++++++++++++++++++++++++++++++++++++ anywidget/widget.py | 38 ++--------------------- 3 files changed, 67 insertions(+), 36 deletions(-) create mode 100644 anywidget/_static_asset.py 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/_static_asset.py b/anywidget/_static_asset.py new file mode 100644 index 00000000..e806ef88 --- /dev/null +++ b/anywidget/_static_asset.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import pathlib +import typing + +import traitlets as t + +from anywidget._file_contents import VirtualFileContents + +from ._descriptor import open_comm +from ._util import try_file_contents + +if typing.TYPE_CHECKING: + import pathlib + + import comm + + +def send_asset_to_front_end(comm: comm.base_comm.BaseComm, contents: str) -> None: + """Send the static asset to the front end.""" + msg = {"method": "update", "state": {"data": contents}, "buffer_paths": []} + comm.send(data=msg, buffers=[]) + + +class StaticAsset: + """ + Represents a static asset (e.g. a file) for the anywidget front end. + + This class is used _internally_ to hoist static files (_esm, _css) into + the front end such that they can be shared across widget instances. This + implementation detail may change in the future, so this class is not + intended for direct use in user code. + """ + + def __init__(self, data: str | pathlib.Path) -> None: + """ + Create a static asset for the anywidget front end. + + Parameters + ---------- + data : str or pathlib.Path + The data to be shared with the front end. + """ + self._comm = open_comm() + self._file_contents = try_file_contents(data) or VirtualFileContents(str(data)) + send_asset_to_front_end(self._comm, str(self)) + self._file_contents.changed.connect( + lambda contents: send_asset_to_front_end(self._comm, contents) + ) + + def __str__(self) -> str: + """Return the string representation of the asset.""" + return str(self._file_contents) + + def __del__(self) -> None: + """Close the comm when the asset is deleted.""" + self._comm.close() + + def as_traittype(self) -> t.TraitType: + """Return a traitlet that represents the asset.""" + return t.Instance(StaticAsset, default_value=self).tag( + sync=True, to_json=lambda *_: "anywidget-static-asset:" + self._comm.comm_id + ) diff --git a/anywidget/widget.py b/anywidget/widget.py index 0e3bed3c..c52b38d4 100644 --- a/anywidget/widget.py +++ b/anywidget/widget.py @@ -7,7 +7,7 @@ import ipywidgets import traitlets.traitlets as t -from ._file_contents import FileContents, VirtualFileContents +from ._static_asset import StaticAsset from ._util import ( _ANYWIDGET_ID_KEY, _CSS_KEY, @@ -16,32 +16,10 @@ 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 -if typing.TYPE_CHECKING: - import pathlib - - -class _Asset(ipywidgets.Widget): - data = t.Unicode().tag(sync=True) - - def __init__(self, data: str | pathlib.Path) -> None: - file_contents = try_file_contents(data) - super().__init__(data=str(file_contents) if file_contents else data) - if file_contents: - file_contents.changed.connect( - lambda new_contents: setattr(self, "data", new_contents) - ) - self._file_contents = file_contents - - def as_traittype(self) -> t.TraitType: - return t.Instance(_Asset, default_value=self).tag( - sync=True, to_json=lambda x, _: "anywidget-asset:" + x.model_id - ) - class AnyWidget(ipywidgets.DOMWidget): # type: ignore [misc] """Main AnyWidget base class.""" @@ -59,18 +37,8 @@ def __init__(self, *args: typing.Any, **kwargs: typing.Any) -> None: 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, (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] = _Asset(data=_DEFAULT_ESM).as_traittype() + anywidget_traits[_ESM_KEY] = StaticAsset(_DEFAULT_ESM).as_traittype() # TODO: a better way to uniquely identify this subclasses? # We use the fully-qualified name to get an id which we @@ -91,7 +59,7 @@ def __init_subclass__(cls, **kwargs: dict) -> None: if isinstance(value, t.TraitType): # we don't know how to handle this continue - setattr(cls, key, _Asset(value)) + setattr(cls, key, StaticAsset(value).as_traittype()) _collect_anywidget_commands(cls) def _repr_mimebundle_(self, **kwargs: dict) -> tuple[dict, dict] | None: From 3d9e9e89bf192e786398d540013581631968bc7f Mon Sep 17 00:00:00 2001 From: Trevor Manz Date: Wed, 24 Jul 2024 14:38:13 -0400 Subject: [PATCH 3/4] feat: Hoist modules --- anywidget/_static_asset.py | 9 ++--- anywidget/widget.py | 59 ++++++++++++++++++++++++-------- packages/anywidget/src/widget.js | 22 ++++++++++++ 3 files changed, 69 insertions(+), 21 deletions(-) diff --git a/anywidget/_static_asset.py b/anywidget/_static_asset.py index e806ef88..500b01be 100644 --- a/anywidget/_static_asset.py +++ b/anywidget/_static_asset.py @@ -3,8 +3,6 @@ import pathlib import typing -import traitlets as t - from anywidget._file_contents import VirtualFileContents from ._descriptor import open_comm @@ -56,8 +54,5 @@ def __del__(self) -> None: """Close the comm when the asset is deleted.""" self._comm.close() - def as_traittype(self) -> t.TraitType: - """Return a traitlet that represents the asset.""" - return t.Instance(StaticAsset, default_value=self).tag( - sync=True, to_json=lambda *_: "anywidget-static-asset:" + self._comm.comm_id - ) + def serialize(self) -> str: + return f"anywidget-static-asset:{self._comm.comm_id}" diff --git a/anywidget/widget.py b/anywidget/widget.py index c52b38d4..3cd298c5 100644 --- a/anywidget/widget.py +++ b/anywidget/widget.py @@ -3,6 +3,7 @@ from __future__ import annotations import typing +from contextlib import contextmanager import ipywidgets import traitlets.traitlets as t @@ -36,30 +37,29 @@ def __init__(self, *args: typing.Any, **kwargs: typing.Any) -> None: if in_colab(): enable_custom_widget_manager_once() - anywidget_traits = {} + self._anywidget_internal_state = {} + for key in (_ESM_KEY, _CSS_KEY): + if hasattr(self, key) and not self.has_trait(key): + self._anywidget_internal_state[key] = getattr(self, key) + if not hasattr(self, _ESM_KEY): - anywidget_traits[_ESM_KEY] = StaticAsset(_DEFAULT_ESM).as_traittype() + self._anywidget_internal_state[_ESM_KEY] = _DEFAULT_ESM + + self._anywidget_internal_state[_ANYWIDGET_ID_KEY] = _id_for(self) - # 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) + with _patch_get_state(self, self._anywidget_internal_state): + super().__init__(*args, **kwargs) - self.add_traits(**anywidget_traits) - super().__init__(*args, **kwargs) _register_anywidget_commands(self) def __init_subclass__(cls, **kwargs: dict) -> None: """Coerces _esm and _css to FileContents if they are files.""" super().__init_subclass__(**kwargs) for key in (_ESM_KEY, _CSS_KEY) & cls.__dict__.keys(): + # TODO: Upgrate to := when we drop Python 3.7 value = getattr(cls, key) - if isinstance(value, t.TraitType): - # we don't know how to handle this - continue - setattr(cls, key, StaticAsset(value).as_traittype()) + if not isinstance(value, StaticAsset): + setattr(cls, key, StaticAsset(value)) _collect_anywidget_commands(cls) def _repr_mimebundle_(self, **kwargs: dict) -> tuple[dict, dict] | None: @@ -69,3 +69,34 @@ def _repr_mimebundle_(self, **kwargs: dict) -> tuple[dict, dict] | None: if self._view_name is None: return None # type: ignore[unreachable] return repr_mimebundle(model_id=self.model_id, repr_text=plaintext) + + +def _id_for(obj: typing.Any) -> str: + """Return a unique identifier for an object.""" + # 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. + return f"{obj.__class__.__module__}.{obj.__class__.__name__}" + + +@contextmanager +def _patch_get_state( + widget: AnyWidget, extra_state: dict[str, str | StaticAsset] +) -> typing.Generator[None, None, None]: + """Patch get_state to include anywidget-specific data.""" + original_get_state = widget.get_state + + def temp_get_state(): + return { + **original_get_state(), + **{ + k: v.serialize() if isinstance(v, StaticAsset) else v + for k, v in extra_state.items() + }, + } + + widget.get_state = temp_get_state + try: + yield + finally: + widget.get_state = original_get_state diff --git a/packages/anywidget/src/widget.js b/packages/anywidget/src/widget.js index ba6e6d20..57f3b623 100644 --- a/packages/anywidget/src/widget.js +++ b/packages/anywidget/src/widget.js @@ -467,6 +467,22 @@ class Runtime { } } +let anywidget_static_asset = { + /** @param {{ model_id: string }} model */ + serialize(model) { + return `anywidget-static-asset:${model.model_id}`; + }, + /** + * @param {string} value + * @param {import("@jupyter-widgets/base").DOMWidgetModel["widget_manager"]} widget_manager + */ + async deserialize(value, widget_manager) { + let model_id = value.slice("anywidget-static-asset:".length); + let model = await widget_manager.get_model(model_id); + return model; + }, +}; + // @ts-expect-error - injected by bundler let version = globalThis.VERSION; @@ -498,6 +514,12 @@ export default function ({ DOMWidgetModel, DOMWidgetView }) { RUNTIMES.set(this, runtime); } + static serializers = { + ...DOMWidgetModel.serializers, + _esm: anywidget_static_asset, + _css: anywidget_static_asset, + }; + /** * @param {Record} state * From 2876384c46cb32c584b34c950c480e86b629100c Mon Sep 17 00:00:00 2001 From: Trevor Manz Date: Mon, 16 Dec 2024 07:42:42 -0500 Subject: [PATCH 4/4] linting --- anywidget/_file_contents.py | 2 +- anywidget/widget.py | 9 ++++----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/anywidget/_file_contents.py b/anywidget/_file_contents.py index 485597c3..04cfee9c 100644 --- a/anywidget/_file_contents.py +++ b/anywidget/_file_contents.py @@ -8,7 +8,7 @@ from psygnal import Signal -__all__ = ["FileContents", "VirtualFileContents", "_VIRTUAL_FILES"] +__all__ = ["_VIRTUAL_FILES", "FileContents", "VirtualFileContents"] _VIRTUAL_FILES: weakref.WeakValueDictionary[str, VirtualFileContents] = ( weakref.WeakValueDictionary() diff --git a/anywidget/widget.py b/anywidget/widget.py index 44cfb054..1ebdb8c0 100644 --- a/anywidget/widget.py +++ b/anywidget/widget.py @@ -35,7 +35,6 @@ class AnyWidget(ipywidgets.DOMWidget): # type: ignore [misc] _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() @@ -58,7 +57,7 @@ def __init_subclass__(cls, **kwargs: dict) -> None: """Coerces _esm and _css to FileContents if they are files.""" super().__init_subclass__(**kwargs) for key in (_ESM_KEY, _CSS_KEY) & cls.__dict__.keys(): - # TODO: Upgrate to := when we drop Python 3.7 + # TODO: Upgrate to := when we drop Python 3.7 # noqa: TD002, TD003 value = getattr(cls, key) if not isinstance(value, StaticAsset): setattr(cls, key, StaticAsset(value)) @@ -73,9 +72,9 @@ def _repr_mimebundle_(self, **kwargs: dict) -> tuple[dict, dict] | None: # noqa return repr_mimebundle(model_id=self.model_id, repr_text=plaintext) -def _id_for(obj: typing.Any) -> str: +def _id_for(obj: object) -> str: """Return a unique identifier for an object.""" - # TODO: a better way to uniquely identify this subclasses? + # TODO: a better way to uniquely identify this subclasses? # noqa: TD002, TD003 # We use the fully-qualified name to get an id which we # can use to update CSS if necessary. return f"{obj.__class__.__module__}.{obj.__class__.__name__}" @@ -88,7 +87,7 @@ def _patch_get_state( """Patch get_state to include anywidget-specific data.""" original_get_state = widget.get_state - def temp_get_state(): + def temp_get_state() -> dict: return { **original_get_state(), **{