From f5e532773aa002441cf97e719bab9b092219dc9f Mon Sep 17 00:00:00 2001 From: Carson Date: Wed, 15 Nov 2023 17:27:55 -0600 Subject: [PATCH 01/10] render_widget now attaches it's return value (and as_widget() equivalent) to the decorated function --- examples/ipyleaflet/app.py | 40 ++++++++++++---------- examples/ipywidgets/app.py | 41 ++++++++++++----------- examples/plotly/app.py | 46 +++++++++++++------------ examples/pydeck/app.py | 63 ++++++++++++++++++----------------- shinywidgets/_shinywidgets.py | 31 +++++++++++++++-- 5 files changed, 128 insertions(+), 93 deletions(-) diff --git a/examples/ipyleaflet/app.py b/examples/ipyleaflet/app.py index 6fbffdc..32a6cc4 100644 --- a/examples/ipyleaflet/app.py +++ b/examples/ipyleaflet/app.py @@ -1,46 +1,50 @@ import ipyleaflet as L -from htmltools import css -from shiny import * +from shiny import App, reactive, render, req, ui -from shinywidgets import output_widget, reactive_read, register_widget +from shinywidgets import output_widget, reactive_read, render_widget -app_ui = ui.page_fillable( - ui.div( +app_ui = ui.page_sidebar( + ui.sidebar( ui.input_slider("zoom", "Map zoom level", value=4, min=1, max=10), + ), + ui.card( ui.output_text("map_bounds"), - style=css( - display="flex", justify_content="center", align_items="center", gap="2rem" - ), + fill=False + ), + ui.card( + output_widget("lmap") ), - output_widget("map"), + title="ipyleaflet demo" ) def server(input, output, session): - # Initialize and display when the session starts (1) - map = L.Map(center=(52, 360), zoom=4) - register_widget("map", map) + @output + @render_widget + def lmap(): + return L.Map(center=(52, 360), zoom=4) # When the slider changes, update the map's zoom attribute (2) @reactive.Effect def _(): - map.zoom = input.zoom() + lmap.widget.zoom = input.zoom() # When zooming directly on the map, update the slider's value (2 and 3) @reactive.Effect def _(): - ui.update_slider("zoom", value=reactive_read(map, "zoom")) + zoom = reactive_read(lmap.widget, "zoom") + ui.update_slider("zoom", value=zoom) # Everytime the map's bounds change, update the output message (3) @output @render.text def map_bounds(): - b = reactive_read(map, "bounds") + b = reactive_read(lmap.widget, "bounds") req(b) - lat = [b[0][0], b[0][1]] - lon = [b[1][0], b[1][1]] - return f"The current latitude is {lat} and longitude is {lon}" + lat = [round(x) for x in [b[0][0], b[0][1]]] + lon = [round(x) for x in [b[1][0], b[1][1]]] + return f"The map bounds is currently {lat} / {lon}" app = App(app_ui, server) diff --git a/examples/ipywidgets/app.py b/examples/ipywidgets/app.py index fea36f2..d7b51f2 100644 --- a/examples/ipywidgets/app.py +++ b/examples/ipywidgets/app.py @@ -4,29 +4,32 @@ from shinywidgets import * -app_ui = ui.page_fluid(output_widget("slider"), ui.output_text("value")) +app_ui = ui.page_fluid(output_widget("slider", fillable=False, fill=False), ui.output_text("slider_val")) def server(input: Inputs, output: Outputs, session: Session): - s: Widget = ipy.IntSlider( - value=7, - min=0, - max=10, - step=1, - description="Test:", - disabled=False, - continuous_update=False, - orientation="horizontal", - readout=True, - readout_format="d", - ) - - register_widget("slider", s) - - @output(id="value") + + @output + @render_widget + def slider(): + return ipy.IntSlider( + value=7, + min=0, + max=10, + step=1, + description="Test:", + disabled=False, + continuous_update=False, + orientation="horizontal", + readout=True, + readout_format="d", + ) + + @output @render.text - def _(): - return f"The value of the slider is: {reactive_read(s, 'value')}" + def slider_val(): + val = reactive_read(slider.widget, "value") + return f"The value of the slider is: {val}" app = App(app_ui, server, debug=True) diff --git a/examples/plotly/app.py b/examples/plotly/app.py index 568dcc6..858e155 100644 --- a/examples/plotly/app.py +++ b/examples/plotly/app.py @@ -3,7 +3,7 @@ from shiny import * from sklearn.linear_model import LinearRegression -from shinywidgets import output_widget, register_widget +from shinywidgets import output_widget, render_widget # Generate some data and fit a linear regression n = 10000 @@ -15,35 +15,37 @@ app_ui = ui.page_fillable( ui.input_checkbox("show_fit", "Show fitted line", value=True), - output_widget("scatterplot"), + output_widget("scatterplot") ) def server(input, output, session): - scatterplot = go.FigureWidget( - data=[ - go.Scattergl( - x=x, - y=y, - mode="markers", - marker=dict(color="rgba(0, 0, 0, 0.05)", size=5), - ), - go.Scattergl( - x=xgrid, - y=fit.intercept_ + fit.coef_[0] * xgrid, - mode="lines", - line=dict(color="red", width=2), - ), - ], - layout={"showlegend": False}, - ) - - register_widget("scatterplot", scatterplot) + @output + @render_widget + def scatterplot(): + return go.FigureWidget( + data=[ + go.Scattergl( + x=x, + y=y, + mode="markers", + marker=dict(color="rgba(0, 0, 0, 0.05)", size=5), + ), + go.Scattergl( + x=xgrid, + y=fit.intercept_ + fit.coef_[0] * xgrid, + mode="lines", + line=dict(color="red", width=2), + ), + ], + layout={"showlegend": False}, + ) @reactive.Effect def _(): - scatterplot.data[1].visible = input.show_fit() + plt.data[1].visible = input.show_fit() + app = App(app_ui, server) diff --git a/examples/pydeck/app.py b/examples/pydeck/app.py index a285ac5..1933d4b 100644 --- a/examples/pydeck/app.py +++ b/examples/pydeck/app.py @@ -5,44 +5,45 @@ app_ui = ui.page_fillable( ui.input_slider("zoom", "Zoom", 0, 20, 6, step=1), - output_widget("pydeck") + output_widget("deckmap") ) def server(input: Inputs, output: Outputs, session: Session): - UK_ACCIDENTS_DATA = "https://raw.githubusercontent.com/visgl/deck.gl-data/master/examples/3d-heatmap/heatmap-data.csv" - - layer = pdk.Layer( - "HexagonLayer", # `type` positional argument is here - UK_ACCIDENTS_DATA, - get_position=["lng", "lat"], - auto_highlight=True, - elevation_scale=50, - pickable=True, - elevation_range=[0, 3000], - extruded=True, - coverage=1, - ) - - view_state = pdk.ViewState( - longitude=-1.415, - latitude=52.2323, - zoom=6, - min_zoom=5, - max_zoom=15, - pitch=40.5, - bearing=-27.36, - ) - - deck = pdk.Deck(layers=[layer], initial_view_state=view_state) - - # Register either the deck (or deck_widget) instance - register_widget("pydeck", deck) + @output + @render_widget + def deckmap(): + + UK_ACCIDENTS_DATA = "https://raw.githubusercontent.com/visgl/deck.gl-data/master/examples/3d-heatmap/heatmap-data.csv" + + layer = pdk.Layer( + "HexagonLayer", # `type` positional argument is here + UK_ACCIDENTS_DATA, + get_position=["lng", "lat"], + auto_highlight=True, + elevation_scale=50, + pickable=True, + elevation_range=[0, 3000], + extruded=True, + coverage=1, + ) + + view_state = pdk.ViewState( + longitude=-1.415, + latitude=52.2323, + zoom=6, + min_zoom=5, + max_zoom=15, + pitch=40.5, + bearing=-27.36, + ) + + return pdk.Deck(layers=[layer], initial_view_state=view_state) @reactive.Effect() def _(): - deck.initial_view_state.zoom = input.zoom() - deck.update() + deckmap.value.initial_view_state.zoom = input.zoom() + deckmap.value.update() app = App(app_ui, server) diff --git a/shinywidgets/_shinywidgets.py b/shinywidgets/_shinywidgets.py index 39d2b3f..9d4a6a5 100644 --- a/shinywidgets/_shinywidgets.py +++ b/shinywidgets/_shinywidgets.py @@ -26,7 +26,7 @@ from ipywidgets.widgets.widget import ( _remove_buffers, # pyright: ignore[reportUnknownVariableType, reportGeneralTypeIssues] ) -from shiny import Session, reactive +from shiny import Session, reactive, req from shiny.http_staticfiles import StaticFiles from shiny.module import resolve_id from shiny.render.transformer import ( @@ -216,10 +216,13 @@ async def WidgetTransformer( _fn: ValueFn[object | None], ) -> dict[str, Any] | None: value = await resolve_value_fn(_fn) + _fn.value = value # type: ignore + _fn.widget = None # type: ignore if value is None: return None widget = as_widget(value) widget, fill = set_layout_defaults(widget) + _fn.widget = widget # type: ignore return {"model_id": widget.model_id, "fill": fill} # type: ignore @@ -232,12 +235,34 @@ def render_widget(fn: WidgetTransformer.ValueFn) -> WidgetTransformer.OutputRend def render_widget() -> WidgetTransformer.OutputRendererDecorator: ... - def render_widget( fn: WidgetTransformer.ValueFn | None = None, ) -> WidgetTransformer.OutputRenderer | WidgetTransformer.OutputRendererDecorator: - return WidgetTransformer(fn) + res = WidgetTransformer(fn) + + # Make the `res._value_fn.widget` attribute that we set in WidgetTransformer + # accessible via `res.widget` + def get_widget(*_: object) -> Optional[Widget]: + w = res._value_fn.widget # type: ignore + if w is None: + req(False) + return None + return w + + def set_widget(*_: object): + raise RuntimeError("The widget attribute of a @render_widget function is read only.") + + setattr(res.__class__, "widget", property(get_widget, set_widget)) + def get_value(*_: object) -> object | None: + return res._value_fn.value # type: ignore + + def set_value(*_: object): + raise RuntimeError("The value attribute of a @render_widget function is read only.") + + setattr(res.__class__, "value", property(get_value, set_value)) + + return res def reactive_read(widget: Widget, names: Union[str, Sequence[str]]) -> Any: reactive_depend(widget, names) From 8976df6b37ecdc661af04592858e354f1a462817 Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 21 Dec 2023 11:44:55 -0600 Subject: [PATCH 02/10] When render_widget gets invalidated, invalidate anyone would reads the value --- shinywidgets/_shinywidgets.py | 69 +++++++++++++++++++++++++++++++---- 1 file changed, 62 insertions(+), 7 deletions(-) diff --git a/shinywidgets/_shinywidgets.py b/shinywidgets/_shinywidgets.py index 9d4a6a5..fedf715 100644 --- a/shinywidgets/_shinywidgets.py +++ b/shinywidgets/_shinywidgets.py @@ -29,6 +29,7 @@ from shiny import Session, reactive, req from shiny.http_staticfiles import StaticFiles from shiny.module import resolve_id +from shiny.reactive._core import Context, get_current_context from shiny.render.transformer import ( TransformerMetadata, ValueFn, @@ -209,20 +210,31 @@ def _restore_state(): # Implement @render_widget() # -------------------------------------------------------------------------------------------- +# TODO: pass along IT/OT to get proper typing? +UserValueFn = ValueFn[object | None] @output_transformer(default_ui=output_widget) async def WidgetTransformer( _meta: TransformerMetadata, - _fn: ValueFn[object | None], + _fn: UserValueFn, ) -> dict[str, Any] | None: value = await resolve_value_fn(_fn) + + # Attach value/widget attributes to user func so they can be accessed (in other reactive contexts) _fn.value = value # type: ignore _fn.widget = None # type: ignore + + # Invalidate any reactive contexts that have read these attributes + invalidate_contexts(_fn) + if value is None: return None + + # Ensure we have a widget & smart layout defaults widget = as_widget(value) widget, fill = set_layout_defaults(widget) _fn.widget = widget # type: ignore + return {"model_id": widget.model_id, "fill": fill} # type: ignore @@ -243,11 +255,15 @@ def render_widget( # Make the `res._value_fn.widget` attribute that we set in WidgetTransformer # accessible via `res.widget` def get_widget(*_: object) -> Optional[Widget]: - w = res._value_fn.widget # type: ignore - if w is None: + vfn = res._value_fn # pyright: ignore[reportFunctionMemberAccess] + vfn = register_current_context(vfn) + w = vfn.widget # type: ignore + if w is not None: + return w + # If widget is None, we're reading in a reactive context, other than the render context, throw a silent exception + if has_current_context(): req(False) - return None - return w + return None def set_widget(*_: object): raise RuntimeError("The widget attribute of a @render_widget function is read only.") @@ -255,15 +271,46 @@ def set_widget(*_: object): setattr(res.__class__, "widget", property(get_widget, set_widget)) def get_value(*_: object) -> object | None: - return res._value_fn.value # type: ignore + vfn = res._value_fn # pyright: ignore[reportFunctionMemberAccess] + vfn = register_current_context(vfn) + v = vfn.value # type: ignore + if v is not None: + return v + if has_current_context(): + req(False) + return None def set_value(*_: object): raise RuntimeError("The value attribute of a @render_widget function is read only.") setattr(res.__class__, "value", property(get_value, set_value)) + # Define these attributes directly on the user function so they're defined, even + # if that function hasn't been called yet. (we don't want to raise an exception in that case) + fn.widget = None # type: ignore + fn.value = None # type: ignore + return res + +def invalidate_contexts(fn: UserValueFn): + ctxs = getattr(fn, "_shinywidgets_contexts", set[Context]()) + for ctx in ctxs: + # TODO: at what point should we be removing contexts? + ctx.invalidate() + + +# If the widget/value is read in a reactive context, then we'll need to invalidate +# that context when the widget's value changes +def register_current_context(fn: UserValueFn): + if not has_current_context(): + return fn + ctxs = getattr(fn, "_shinywidgets_contexts", set[Context]()) + ctxs.add(get_current_context()) + fn._shinywidgets_contexts = ctxs # type: ignore + return fn + + def reactive_read(widget: Widget, names: Union[str, Sequence[str]]) -> Any: reactive_depend(widget, names) if isinstance(names, str): @@ -282,7 +329,7 @@ def reactive_depend( """ try: - ctx = reactive.get_current_context() # pyright: ignore[reportPrivateImportUsage] + ctx = get_current_context() except RuntimeError: raise RuntimeError("reactive_read() must be called within a reactive context") @@ -381,6 +428,14 @@ def set_layout_defaults(widget: Widget) -> Tuple[Widget, bool]: return (widget, fill) + +def has_current_context() -> bool: + try: + get_current_context() + return True + except RuntimeError: + return False + # similar to base::system.file() def package_dir(package: str) -> str: with tempfile.TemporaryDirectory(): From 8b2a3949e0039ba8f7e06aff43bbff26dfb4eb99 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 10 Jan 2024 16:52:11 -0500 Subject: [PATCH 03/10] Use shiny from github --- setup.cfg | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index 588d65d..9e39ad7 100644 --- a/setup.cfg +++ b/setup.cfg @@ -34,7 +34,8 @@ setup_requires = install_requires = ipywidgets>=7.6.5 jupyter_core - shiny>=0.5.1.9003 + # shiny>=0.6.1.9003 + shiny @ git+https://github.com/posit-dev/py-shiny.git python-dateutil>=2.8.2 # Needed because of https://github.com/python/importlib_metadata/issues/411 importlib-metadata>=4.8.3,<5; python_version < "3.8" From 0636fdb730a7c4cbbe90564e8b3670243a4c96a9 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 10 Jan 2024 16:55:10 -0500 Subject: [PATCH 04/10] Use new Renderer class to create `render_widget` method. --- shinywidgets/_shinywidgets.py | 240 +++++++++++++++++++--------------- 1 file changed, 134 insertions(+), 106 deletions(-) diff --git a/shinywidgets/_shinywidgets.py b/shinywidgets/_shinywidgets.py index fedf715..2282354 100644 --- a/shinywidgets/_shinywidgets.py +++ b/shinywidgets/_shinywidgets.py @@ -7,14 +7,13 @@ "reactive_read", "as_widget", ) - import copy import importlib import json import os import tempfile import warnings -from typing import Any, Optional, Sequence, Tuple, Union, cast, overload +from typing import Any, Generic, Optional, Sequence, Tuple, TypeVar, Union, cast from uuid import uuid4 from weakref import WeakSet @@ -30,15 +29,11 @@ from shiny.http_staticfiles import StaticFiles from shiny.module import resolve_id from shiny.reactive._core import Context, get_current_context -from shiny.render.transformer import ( - TransformerMetadata, - ValueFn, - output_transformer, - resolve_value_fn, -) +from shiny.render.renderer import Jsonifiable, Renderer, ValueFn from shiny.session import get_current_session, require_active_session from shiny.ui.css import as_css_unit from shiny.ui.fill import as_fill_item, as_fillable_container +from traitlets import Unicode from ._as_widget import as_widget from ._comm import BufferType, ShinyComm, ShinyCommManager @@ -61,8 +56,12 @@ def output_widget( - id: str, *, width: Optional[str] = None, height: Optional[str] = None, - fill: Optional[bool] = None, fillable: Optional[bool] = None + id: str, + *, + width: Optional[str] = None, + height: Optional[str] = None, + fill: Optional[bool] = None, + fillable: Optional[bool] = None, ) -> Tag: id = resolve_id(id) res = tags.div( @@ -78,7 +77,7 @@ def output_widget( class_="shiny-ipywidget-output shiny-report-size shiny-report-theme", style=css( width=as_css_unit(width), - height=as_css_unit(height) + height=as_css_unit(height), ), ) @@ -210,107 +209,142 @@ def _restore_state(): # Implement @render_widget() # -------------------------------------------------------------------------------------------- -# TODO: pass along IT/OT to get proper typing? -UserValueFn = ValueFn[object | None] - -@output_transformer(default_ui=output_widget) -async def WidgetTransformer( - _meta: TransformerMetadata, - _fn: UserValueFn, -) -> dict[str, Any] | None: - value = await resolve_value_fn(_fn) - - # Attach value/widget attributes to user func so they can be accessed (in other reactive contexts) - _fn.value = value # type: ignore - _fn.widget = None # type: ignore - - # Invalidate any reactive contexts that have read these attributes - invalidate_contexts(_fn) - - if value is None: - return None - - # Ensure we have a widget & smart layout defaults - widget = as_widget(value) - widget, fill = set_layout_defaults(widget) - _fn.widget = widget # type: ignore - - return {"model_id": widget.model_id, "fill": fill} # type: ignore - - -@overload -def render_widget(fn: WidgetTransformer.ValueFn) -> WidgetTransformer.OutputRenderer: - ... - - -@overload -def render_widget() -> WidgetTransformer.OutputRendererDecorator: - ... - -def render_widget( - fn: WidgetTransformer.ValueFn | None = None, -) -> WidgetTransformer.OutputRenderer | WidgetTransformer.OutputRendererDecorator: - res = WidgetTransformer(fn) - - # Make the `res._value_fn.widget` attribute that we set in WidgetTransformer - # accessible via `res.widget` - def get_widget(*_: object) -> Optional[Widget]: - vfn = res._value_fn # pyright: ignore[reportFunctionMemberAccess] - vfn = register_current_context(vfn) - w = vfn.widget # type: ignore - if w is not None: - return w - # If widget is None, we're reading in a reactive context, other than the render context, throw a silent exception - if has_current_context(): - req(False) - return None +ValueT = TypeVar("ValueT", bound=object) +WidgetT = TypeVar("WidgetT", bound=Widget) +T = TypeVar("T", bound=object) - def set_widget(*_: object): - raise RuntimeError("The widget attribute of a @render_widget function is read only.") - setattr(res.__class__, "widget", property(get_widget, set_widget)) +class render_widget_base(Renderer[ValueT], Generic[ValueT, WidgetT]): + def default_ui(self, id: str) -> Tag: + return output_widget( + id, + width=self.width, + height=self.height, + fill=self.fill, + fillable=self.fillable, + ) - def get_value(*_: object) -> object | None: - vfn = res._value_fn # pyright: ignore[reportFunctionMemberAccess] - vfn = register_current_context(vfn) - v = vfn.value # type: ignore - if v is not None: - return v - if has_current_context(): + def __init__( + self, + _fn: Optional[ValueFn[ValueT]] = None, + *, + width: Optional[str] = None, + height: Optional[str] = None, + fill: Optional[bool] = None, + fillable: Optional[bool] = None, + ): + super().__init__(_fn) + self.width: Optional[str] = width + self.height: Optional[str] = height + self.fill: Optional[bool] = fill + self.fillable: Optional[bool] = fillable + + self._value: ValueT | None = None # TODO-barret; Not right type + self._widget: WidgetT | None = None + self._contexts: set[Context] = set() + + async def render(self) -> Jsonifiable | None: + value = await self.fn() + + # Attach value/widget attributes to user func so they can be accessed (in other reactive contexts) + self._value = value + self._widget = None + + # Invalidate any reactive contexts that have read these attributes + self._invalidate_contexts() + + if value is None: + return None + + # Ensure we have a widget & smart layout defaults + widget = as_widget(value) + widget, fill = set_layout_defaults(widget) + + self._widget = cast(WidgetT, widget) + + return { + "model_id": str( + cast( + Unicode, + widget.model_id, # pyright: ignore[reportUnknownMemberType] + ) + ), + "fill": fill, + } + + # ######## + # Enhancements + # ######## + + # TODO-barret; Turn these into reactives. We do not have reactive values in `py-shiny`, we shouldn't have them in `py-shinywidgets` + # TODO-barret; Add `.reactive_read()` and `.reactive_depend()` methods + + def _get_reactive_obj(self, x: T) -> T | None: + self._register_current_context() + if x is not None: + return x + # If `self._value` or `self._widget` is None, we're reading in a reactive context, + # other than the render context, throw a silent exception + if self._has_current_context(): req(False) return None - def set_value(*_: object): - raise RuntimeError("The value attribute of a @render_widget function is read only.") + @property + def value(self) -> ValueT | None: + return self._get_reactive_obj(self._value) - setattr(res.__class__, "value", property(get_value, set_value)) - - # Define these attributes directly on the user function so they're defined, even - # if that function hasn't been called yet. (we don't want to raise an exception in that case) - fn.widget = None # type: ignore - fn.value = None # type: ignore + @value.setter + def value(self, value: object): + raise RuntimeError( + "The `value` attribute of a @render_widget function is read only." + ) - return res + @property + def widget(self) -> WidgetT | None: + return self._get_reactive_obj(self._widget) + @value.setter + def value(self, value: object): + raise RuntimeError( + "The `value` attribute of a @render_widget function is read only." + ) -def invalidate_contexts(fn: UserValueFn): - ctxs = getattr(fn, "_shinywidgets_contexts", set[Context]()) - for ctx in ctxs: - # TODO: at what point should we be removing contexts? - ctx.invalidate() + # ######## + # Reactivity contexts + # ######## + def _has_current_context(self) -> bool: + try: + get_current_context() + return True + except RuntimeError: + return False + + # _contexts: set[Context] + def _invalidate_contexts(self) -> None: + for ctx in self._contexts: + ctx.invalidate() + + # If the widget/value is read in a reactive context, then we'll need to invalidate + # that context when the widget's value changes + def _register_current_context(self) -> None: + if not self._has_current_context(): + return + self._contexts.add(get_current_context()) + + +class render_widget(render_widget_base[ValueT, Widget]): + ... -# If the widget/value is read in a reactive context, then we'll need to invalidate -# that context when the widget's value changes -def register_current_context(fn: UserValueFn): - if not has_current_context(): - return fn - ctxs = getattr(fn, "_shinywidgets_contexts", set[Context]()) - ctxs.add(get_current_context()) - fn._shinywidgets_contexts = ctxs # type: ignore - return fn +# TODO-future; Add support for plotly (and other packages) +# # Working proof of concept for `@render_plotly` +# import plotly.graph_objects as go +# FigureT = TypeVar("FigureT", bound=go.Figure | go.FigureWidget) +# class render_plotly(render_widget_base[FigureT, go.FigureWidget]): +# ... +# TODO-barret; Make method on RenderWidget base def reactive_read(widget: Widget, names: Union[str, Sequence[str]]) -> Any: reactive_depend(widget, names) if isinstance(names, str): @@ -383,7 +417,7 @@ def set_layout_defaults(widget: Widget) -> Tuple[Widget, bool]: if getattr(widget, "_model_module", None) == "@jupyter-widgets/controls": return (widget, False) - layout = widget.layout # type: ignore + layout = widget.layout # type: ignore # If the ipywidget Layout() height is set to something other than "auto", then # don't do filling layout https://ipywidgets.readthedocs.io/en/stable/examples/Widget%20Layout.html @@ -396,6 +430,7 @@ def set_layout_defaults(widget: Widget) -> Tuple[Widget, bool]: # Plotly provides it's own layout API (which isn't a subclass of ipywidgets.Layout) if pkg == "plotly": from plotly.graph_objs import Layout as PlotlyLayout + if isinstance(layout, PlotlyLayout): if layout.height is not None: fill = False @@ -429,13 +464,6 @@ def set_layout_defaults(widget: Widget) -> Tuple[Widget, bool]: return (widget, fill) -def has_current_context() -> bool: - try: - get_current_context() - return True - except RuntimeError: - return False - # similar to base::system.file() def package_dir(package: str) -> str: with tempfile.TemporaryDirectory(): From 12d9634af79d505cf934930f9840d78ceb3876ca Mon Sep 17 00:00:00 2001 From: Carson Sievert Date: Tue, 16 Jan 2024 15:03:58 -0600 Subject: [PATCH 05/10] Fix property --- shinywidgets/_shinywidgets.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/shinywidgets/_shinywidgets.py b/shinywidgets/_shinywidgets.py index 2282354..cb2feb1 100644 --- a/shinywidgets/_shinywidgets.py +++ b/shinywidgets/_shinywidgets.py @@ -303,10 +303,10 @@ def value(self, value: object): def widget(self) -> WidgetT | None: return self._get_reactive_obj(self._widget) - @value.setter - def value(self, value: object): + @widget.setter + def widget(self, widget: object): raise RuntimeError( - "The `value` attribute of a @render_widget function is read only." + "The `widget` attribute of a @render_widget function is read only." ) # ######## From 803b250c0c45c75cdc74d4e3094f5c3f89360b6e Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 16 Jan 2024 16:21:30 -0500 Subject: [PATCH 06/10] Commit code to hand off to @cpsievert --- shinywidgets/__init__.py | 33 +++- shinywidgets/_as_widget.py | 9 +- shinywidgets/_cdn.py | 17 ++ shinywidgets/_output_widget.py | 54 +++++ shinywidgets/_render_widget.py | 51 +++++ shinywidgets/_render_widget_base.py | 297 ++++++++++++++++++++++++++++ shinywidgets/_shinywidgets.py | 260 ++++-------------------- 7 files changed, 487 insertions(+), 234 deletions(-) create mode 100644 shinywidgets/_cdn.py create mode 100644 shinywidgets/_output_widget.py create mode 100644 shinywidgets/_render_widget.py create mode 100644 shinywidgets/_render_widget_base.py diff --git a/shinywidgets/__init__.py b/shinywidgets/__init__.py index ed52fd1..ad3039c 100644 --- a/shinywidgets/__init__.py +++ b/shinywidgets/__init__.py @@ -4,13 +4,34 @@ __email__ = "carson@rstudio.com" __version__ = "0.2.4.9000" +from ._as_widget import as_widget from ._dependencies import bokeh_dependency -from ._shinywidgets import ( - as_widget, - output_widget, - reactive_read, - register_widget, +from ._output_widget import output_widget +from ._render_widget import ( + render_altair, + render_bokeh, + render_leaflet, + render_plotly, + render_pydeck, render_widget, ) +from ._shinywidgets import reactive_read, register_widget -__all__ = ("output_widget", "register_widget", "render_widget", "reactive_read", "bokeh_dependency", "as_widget") +__all__ = ( + # Render methods first + "render_widget", + "render_altair", + "render_bokeh", + "render_leaflet", + "render_plotly", + "render_pydeck", + # Reactive read second + "reactive_read", + # UI methods third + "output_widget", + # Other methods last + "as_widget", + "bokeh_dependency", + # Soft deprecated + "register_widget", +) diff --git a/shinywidgets/_as_widget.py b/shinywidgets/_as_widget.py index 98cb415..916f606 100644 --- a/shinywidgets/_as_widget.py +++ b/shinywidgets/_as_widget.py @@ -1,6 +1,6 @@ from typing import Optional -from ipywidgets.widgets.widget import Widget +from ipywidgets.widgets.widget import Widget # pyright: ignore[reportMissingTypeStubs] from ._dependencies import widget_pkg @@ -46,7 +46,7 @@ def as_widget_altair(x: object) -> Optional[Widget]: def as_widget_bokeh(x: object) -> Optional[Widget]: try: - from jupyter_bokeh import BokehModel + from jupyter_bokeh import BokehModel # pyright: ignore[reportMissingTypeStubs] except ImportError: raise ImportError( "Install the jupyter_bokeh package to use bokeh with shinywidgets." @@ -56,15 +56,16 @@ def as_widget_bokeh(x: object) -> Optional[Widget]: # `BokehModel(x)._model.sizing_mode = "stretch_both"` # there, but that doesn't seem to work?? from bokeh.plotting import figure + if isinstance(x, figure): - x.sizing_mode = "stretch_both" + x.sizing_mode = "stretch_both" # pyright: ignore[reportGeneralTypeIssues] return BokehModel(x) # type: ignore def as_widget_plotly(x: object) -> Optional[Widget]: # Don't need a try import here since this won't be called unless x is a plotly object - import plotly.graph_objects as go + import plotly.graph_objects as go # pyright: ignore[reportMissingTypeStubs] if not isinstance(x, go.Figure): raise TypeError( diff --git a/shinywidgets/_cdn.py b/shinywidgets/_cdn.py new file mode 100644 index 0000000..6a27d7c --- /dev/null +++ b/shinywidgets/_cdn.py @@ -0,0 +1,17 @@ +import os + +all = ( + "SHINYWIDGETS_CDN", + "SHINYWIDGETS_CDN_ONLY", + "SHINYWIDGETS_EXTENSION_WARNING", +) + +# Make it easier to customize the CDN fallback (and make it CDN-only) +# https://ipywidgets.readthedocs.io/en/7.6.3/embedding.html#python-interface +# https://github.com/jupyter-widgets/ipywidgets/blob/6f6156c7/packages/html-manager/src/libembed-amd.ts#L6-L14 +SHINYWIDGETS_CDN = os.getenv("SHINYWIDGETS_CDN", "https://cdn.jsdelivr.net/npm/") +SHINYWIDGETS_CDN_ONLY = os.getenv("SHINYWIDGETS_CDN_ONLY", "false").lower() == "true" +# Should shinywidgets warn if unable to find a local path to a widget extension? +SHINYWIDGETS_EXTENSION_WARNING = ( + os.getenv("SHINYWIDGETS_EXTENSION_WARNING", "false").lower() == "true" +) diff --git a/shinywidgets/_output_widget.py b/shinywidgets/_output_widget.py new file mode 100644 index 0000000..88b09f5 --- /dev/null +++ b/shinywidgets/_output_widget.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from typing import Optional + +from htmltools import Tag, css, head_content, tags +from shiny.module import resolve_id +from shiny.ui.css import as_css_unit +from shiny.ui.fill import as_fill_item, as_fillable_container + +from ._cdn import SHINYWIDGETS_CDN, SHINYWIDGETS_CDN_ONLY +from ._dependencies import libembed_dependency, output_binding_dependency + +__all__ = ("output_widget",) + + +def output_widget( + id: str, + *, + width: Optional[str] = None, + height: Optional[str] = None, + fill: Optional[bool] = None, + fillable: Optional[bool] = None, +) -> Tag: + id = resolve_id(id) + res = tags.div( + *libembed_dependency(), + output_binding_dependency(), + head_content( + tags.script( + data_jupyter_widgets_cdn=SHINYWIDGETS_CDN, + data_jupyter_widgets_cdn_only=SHINYWIDGETS_CDN_ONLY, + ) + ), + id=id, + class_="shiny-ipywidget-output shiny-report-size shiny-report-theme", + style=css( + width=as_css_unit(width), + height=as_css_unit(height), + ), + ) + + if fill is None: + fill = height is None + + if fill: + res = as_fill_item(res) + + if fillable is None: + fillable = height is None + + if fillable: + res = as_fillable_container(res) + + return res diff --git a/shinywidgets/_render_widget.py b/shinywidgets/_render_widget.py new file mode 100644 index 0000000..29362e8 --- /dev/null +++ b/shinywidgets/_render_widget.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from ipywidgets.widgets import Widget # pyright: ignore[reportMissingTypeStubs] + +if TYPE_CHECKING: + from altair import JupyterChart + from jupyter_bokeh import BokehModel # pyright: ignore[reportMissingTypeStubs] + from plotly.graph_objects import ( # pyright: ignore[reportMissingTypeStubs] + FigureWidget, + ) + from pydeck.widget import DeckGLWidget # pyright: ignore[reportMissingTypeStubs] + + # Leaflet Widget class is the same as a Widget + # from ipyleaflet import Widget as LeafletWidget + +from ._render_widget_base import ValueT, WidgetT, render_widget_base + +__all__ = ( + "render_widget", + "render_altair", + "render_bokeh", + "render_leaflet", + "render_plotly", + "render_pydeck", +) + + +class render_widget(render_widget_base[ValueT, Widget]): + ... + + +class render_altair(render_widget_base[ValueT, JupyterChart]): + ... + + +class render_bokeh(render_widget_base[ValueT, BokehModel]): + ... + + +class render_leaflet(render_widget_base[WidgetT, WidgetT]): + ... + + +class render_plotly(render_widget_base[ValueT, FigureWidget]): + ... + + +class render_pydeck(render_widget_base[ValueT, DeckGLWidget]): + ... diff --git a/shinywidgets/_render_widget_base.py b/shinywidgets/_render_widget_base.py new file mode 100644 index 0000000..2c0d049 --- /dev/null +++ b/shinywidgets/_render_widget_base.py @@ -0,0 +1,297 @@ +from __future__ import annotations + +from typing import Any, Generic, Optional, Sequence, TypeVar, Union, cast + +from htmltools import Tag +from ipywidgets.widgets import Widget # pyright: ignore[reportMissingTypeStubs] +from shiny import reactive, req +from shiny.reactive import ( + Calc_ as shiny_reactive_calc_class, # pyright: ignore[reportPrivateImportUsage] +) +from shiny.reactive import value as shiny_reactive_value +from shiny.reactive._core import Context, get_current_context +from shiny.render.renderer import Jsonifiable, Renderer, ValueFn +from traitlets import Unicode + +from ._as_widget import as_widget +from ._output_widget import output_widget +from ._shinywidgets import reactive_depend, reactive_read, set_layout_defaults + +__all__ = ( + "render_widget_base", + "WidgetT", + "ValueT", +) + +# -------------------------------------------------------------------------------------------- +# Implement @render_widget() +# -------------------------------------------------------------------------------------------- + +ValueT = TypeVar("ValueT", bound=object) +""" +The type of the value returned by the Shiny app render function +""" +WidgetT = TypeVar("WidgetT", bound=Widget) +""" +The type of the widget created from the renderer's ValueT +""" + + +class render_widget_base(Renderer[ValueT], Generic[ValueT, WidgetT]): + """ """ + + def default_ui(self, id: str) -> Tag: + return output_widget( + id, + width=self.width, + height=self.height, + fill=self.fill, + fillable=self.fillable, + ) + + def __init__( + self, + _fn: Optional[ValueFn[ValueT]] = None, + *, + width: Optional[str] = None, + height: Optional[str] = None, + fill: Optional[bool] = None, + fillable: Optional[bool] = None, + ): + super().__init__(_fn) + self.width: Optional[str] = width + self.height: Optional[str] = height + self.fill: Optional[bool] = fill + self.fillable: Optional[bool] = fillable + + # self._value: ValueT | None = None # TODO-barret; Not right type + # self._widget: WidgetT | None = None + self._contexts: set[Context] = set() + + self._value: shiny_reactive_value[ValueT | None] = shiny_reactive_value(None) + self._widget: shiny_reactive_value[WidgetT | None] = shiny_reactive_value(None) + + async def render(self) -> Jsonifiable | None: + value = await self.fn() + + # Attach value/widget attributes to user func so they can be accessed (in other reactive contexts) + self._value.set(value) + self._widget.set(None) + + if value is None: + return None + + # Ensure we have a widget & smart layout defaults + widget = as_widget(value) + widget, fill = set_layout_defaults(widget) + + self._widget.set( + # TODO-future; Remove cast call once `as_widget()` returns a WidgetT + cast(WidgetT, widget) + ) + + return { + "model_id": str( + cast( + Unicode, + widget.model_id, # pyright: ignore[reportUnknownMemberType] + ) + ), + "fill": fill, + } + + # ######## + # Enhancements + # ######## + + # TODO-barret; Turn these into reactives. We do not have reactive values in `py-shiny`, we shouldn't have them in `py-shinywidgets` + # TODO-barret; Add `.reactive_read()` and `.reactive_depend()` methods + + def value(self) -> ValueT: + value = self._value() + req(value) + + # Can only get here if value is not `None` + value = cast(ValueT, value) + return value + + def widget(self) -> WidgetT: + widget = self._widget() + req(widget) + + # Can only get here if widget is not `None` + widget = cast(WidgetT, widget) + return widget + + # def value_trait(self, name: str) -> Any: + # return reactive_read(self.value(), name) + def widget_trait(self, name: str) -> Any: + return reactive_read(self.widget(), name) + + # ########################################################################## + + # TODO-future; Should this method be supported? Can we have full typing support for the trait values? + # Note: Barret,Carson Jan 11-2024; + # This method is a very Shiny-like approach to making reactive values from + # ipywidgets. However, it would not support reaching into the widget with full + # typing. Instead, it is recommended that we keep `reactive_read(widget_like_obj, + # name)` that upgrades a (nested within widget) value to a resolved value that will + # invalidate the current context when the widget value is updated (by some other + # means). + # + # Since we know that `@render_altair` is built on `altair.JupyterChart`, we know + # that `jchart.widget()` will return an `JupyterChart` object. This object has full + # typing, such as `jchart.widget().selections` which is a JupyterChart `Selections` + # object. Then using the `reactive_read()` function, we can create a reactive value + # from the `Selections` object. This allows for users to reach into the widget as + # much as possible (with full typing) before using `reactive_read()`. + # + # Ex: + # ---------------------- + # ```python + # @render_altair + # def jchart(): + # return some_altair_chart + # + # @render.text + # def selected_point(): + # # This is a reactive value that will invalidate the current context when the chart's selection changes + # selected_point = reactive_read(jchart.widget().selections, "point") + # return f"The selected point is: {selected_point()}" + # ``` + # ---------------------- + # + # Final realization: + # The method below (`_reactive_trait()`) does not support reaching into the widget + # result object. If the method was updated to support a nested key (str), typing + # would not be supported. + # + # Therefore, `reactive_read()` should be used until we can dynamically create + # classes that wrap a widget. (Barret: I am not hopeful that this will be possible + # or worth the effort. Ex: `jchart.traits.selections.point()` would be a reactive + # and fully typed.) + def _reactive_trait( + self, + names: Union[str, Sequence[str]], + ) -> shiny_reactive_calc_class[Any]: + """ + Create a reactive value of a widget's top-level value that can be accessed by + name. + + Ex: + + ```python + slider_value = slider.reactive_trait("value") + + @render.text + def slider_val(): + return f"The value of the slider is: {slider_value()}" + ``` + """ + + if in_reactive_context(): + raise RuntimeError( + "Calling `reactive_trait()` within a reactive context is not supported." + ) + + reactive_trait: shiny_reactive_value[Any] = shiny_reactive_value(None) + + names_was_str = isinstance(names, str) + if isinstance(names, str): + names = [names] + + @reactive.effect + def _(): + # Set the value to None incase the widget doesn't exist / have the trait + reactive_trait.set(None) + + widget = self.widget() + + for name in names: + if not widget.has_trait( # pyright: ignore[reportUnknownMemberType] + name + ): + raise ValueError( + f"The '{name}' attribute of {widget.__class__.__name__} is not a " + "widget trait, and so it's not possible to reactively read it. " + "For a list of widget traits, call `.widget().trait_names()`." + ) + + # # From `Widget.observe()` docs: + # A callable that is called when a trait changes. Its + # signature should be ``handler(change)``, where ``change`` is a + # dictionary. The change dictionary at least holds a 'type' key. + # * ``type``: the type of notification. + # Other keys may be passed depending on the value of 'type'. In the + # case where type is 'change', we also have the following keys: + # * ``owner`` : the HasTraits instance + # * ``old`` : the old value of the modified trait attribute + # * ``new`` : the new value of the modified trait attribute + # * ``name`` : the name of the modified trait attribute. + def on_key_update(change: object): + if names_was_str: + val = getattr(widget, names[0]) + else: + val = tuple(getattr(widget, name) for name in names) + + reactive_trait.set(val) + + # set value to the init widget value + on_key_update(None) + + # Setup - onchange + # When widget attr changes, update the reactive value + widget.observe( # pyright: ignore[reportUnknownMemberType] + on_key_update, + names, # pyright: ignore[reportGeneralTypeIssues] + "change", + ) + + # Teardown - onchange + # When the widget object is created again, remove the old observer + def on_ctx_invalidate(): + widget.unobserve( # pyright: ignore[reportUnknownMemberType] + on_key_update, + names, # pyright: ignore[reportGeneralTypeIssues] + "change", + ) + + get_current_context().on_invalidate(on_ctx_invalidate) + + # Return a calc object that can only be read from + @reactive.calc + def trait_calc(): + return reactive_trait() + + return trait_calc + + # Note: Should be removed once `._reactive_trait()` is removed + def _reactive_read(self, names: Union[str, Sequence[str]]) -> Any: + """ + Reactively read a Widget's trait(s) + """ + self._reactive_depend(names) + + widget = self.widget() + + return reactive_read(widget, names) + + # Note: Should be removed once `._reactive_trait()` is removed + def _reactive_depend( + self, + names: Union[str, Sequence[str]], + type: str = "change", + ) -> None: + """ + Reactively depend upon a Widget's trait(s) + """ + return reactive_depend(self.widget(), names, type) + + +def in_reactive_context() -> bool: + try: + # Raises a `RuntimeError` if there is no current context + get_current_context() + return True + except RuntimeError: + return False diff --git a/shinywidgets/_shinywidgets.py b/shinywidgets/_shinywidgets.py index cb2feb1..f1e5a1e 100644 --- a/shinywidgets/_shinywidgets.py +++ b/shinywidgets/_shinywidgets.py @@ -1,101 +1,41 @@ from __future__ import annotations -__all__ = ( - "output_widget", - "register_widget", - "render_widget", - "reactive_read", - "as_widget", -) import copy import importlib import json import os import tempfile import warnings -from typing import Any, Generic, Optional, Sequence, Tuple, TypeVar, Union, cast +from typing import Any, Optional, Sequence, Tuple, Union, cast from uuid import uuid4 from weakref import WeakSet -from htmltools import Tag, TagList, css, head_content, tags -from ipywidgets._version import ( - __protocol_version__, # pyright: ignore[reportUnknownVariableType] -) -from ipywidgets.widgets import DOMWidget, Layout, Widget -from ipywidgets.widgets.widget import ( - _remove_buffers, # pyright: ignore[reportUnknownVariableType, reportGeneralTypeIssues] -) -from shiny import Session, reactive, req +import ipywidgets # pyright: ignore[reportMissingTypeStubs] + +from ._render_widget import render_widget + +__protocol_version__ = ipywidgets._version.__protocol_version__ +DOMWidget = ipywidgets.widgets.DOMWidget +Layout = ipywidgets.widgets.Layout +Widget = ipywidgets.widgets.Widget +_remove_buffers = ipywidgets.widgets.widget._remove_buffers # pyright: ignore +from htmltools import TagList +from shiny import Session, reactive from shiny.http_staticfiles import StaticFiles -from shiny.module import resolve_id -from shiny.reactive._core import Context, get_current_context -from shiny.render.renderer import Jsonifiable, Renderer, ValueFn +from shiny.reactive._core import get_current_context from shiny.session import get_current_session, require_active_session -from shiny.ui.css import as_css_unit -from shiny.ui.fill import as_fill_item, as_fillable_container -from traitlets import Unicode from ._as_widget import as_widget +from ._cdn import SHINYWIDGETS_CDN_ONLY, SHINYWIDGETS_EXTENSION_WARNING from ._comm import BufferType, ShinyComm, ShinyCommManager -from ._dependencies import ( - libembed_dependency, - output_binding_dependency, - require_dependency, - widget_pkg, -) +from ._dependencies import require_dependency, widget_pkg -# Make it easier to customize the CDN fallback (and make it CDN-only) -# https://ipywidgets.readthedocs.io/en/7.6.3/embedding.html#python-interface -# https://github.com/jupyter-widgets/ipywidgets/blob/6f6156c7/packages/html-manager/src/libembed-amd.ts#L6-L14 -SHINYWIDGETS_CDN = os.getenv("SHINYWIDGETS_CDN", "https://cdn.jsdelivr.net/npm/") -SHINYWIDGETS_CDN_ONLY = os.getenv("SHINYWIDGETS_CDN_ONLY", "false").lower() == "true" -# Should shinywidgets warn if unable to find a local path to a widget extension? -SHINYWIDGETS_EXTENSION_WARNING = ( - os.getenv("SHINYWIDGETS_EXTENSION_WARNING", "false").lower() == "true" +__all__ = ( + "register_widget", + "reactive_read", ) -def output_widget( - id: str, - *, - width: Optional[str] = None, - height: Optional[str] = None, - fill: Optional[bool] = None, - fillable: Optional[bool] = None, -) -> Tag: - id = resolve_id(id) - res = tags.div( - *libembed_dependency(), - output_binding_dependency(), - head_content( - tags.script( - data_jupyter_widgets_cdn=SHINYWIDGETS_CDN, - data_jupyter_widgets_cdn_only=SHINYWIDGETS_CDN_ONLY, - ) - ), - id=id, - class_="shiny-ipywidget-output shiny-report-size shiny-report-theme", - style=css( - width=as_css_unit(width), - height=as_css_unit(height), - ), - ) - - if fill is None: - fill = height is None - - if fill: - res = as_fill_item(res) - - if fillable is None: - fillable = height is None - - if fillable: - res = as_fillable_container(res) - - return res - - # -------------------------------------------------------------------------------------------- # When a widget is initialized, also initialize a communication channel (via the Shiny # session). Note that when the comm is initialized, it also sends the initial state of @@ -110,7 +50,7 @@ def init_shiny_widget(w: Widget): # Break out of any module-specific session. Otherwise, input.shinywidgets_comm_send # will be some module-specific copy. while hasattr(session, "_parent"): - session = cast(Session, session._parent) + session = cast(Session, session._parent) # pyright: ignore # Previous versions of ipywidgets (< 8.0.5) had # `Widget.comm = Instance('ipykernel.comm.Comm')` @@ -141,7 +81,7 @@ def init_shiny_widget(w: Widget): # Initialize the comm...this will also send the initial state of the widget w.comm = ShinyComm( - comm_id=w._model_id, + comm_id=w._model_id, # pyright: ignore comm_manager=COMM_MANAGER, target_name="jupyter.widgets", data={"state": state, "buffer_paths": buffer_paths}, @@ -205,143 +145,9 @@ def _restore_state(): COMM_MANAGER = ShinyCommManager() -# -------------------------------------------------------------------------------------------- -# Implement @render_widget() -# -------------------------------------------------------------------------------------------- - -ValueT = TypeVar("ValueT", bound=object) -WidgetT = TypeVar("WidgetT", bound=Widget) -T = TypeVar("T", bound=object) - - -class render_widget_base(Renderer[ValueT], Generic[ValueT, WidgetT]): - def default_ui(self, id: str) -> Tag: - return output_widget( - id, - width=self.width, - height=self.height, - fill=self.fill, - fillable=self.fillable, - ) - - def __init__( - self, - _fn: Optional[ValueFn[ValueT]] = None, - *, - width: Optional[str] = None, - height: Optional[str] = None, - fill: Optional[bool] = None, - fillable: Optional[bool] = None, - ): - super().__init__(_fn) - self.width: Optional[str] = width - self.height: Optional[str] = height - self.fill: Optional[bool] = fill - self.fillable: Optional[bool] = fillable - - self._value: ValueT | None = None # TODO-barret; Not right type - self._widget: WidgetT | None = None - self._contexts: set[Context] = set() - - async def render(self) -> Jsonifiable | None: - value = await self.fn() - - # Attach value/widget attributes to user func so they can be accessed (in other reactive contexts) - self._value = value - self._widget = None - - # Invalidate any reactive contexts that have read these attributes - self._invalidate_contexts() - - if value is None: - return None - - # Ensure we have a widget & smart layout defaults - widget = as_widget(value) - widget, fill = set_layout_defaults(widget) - - self._widget = cast(WidgetT, widget) - - return { - "model_id": str( - cast( - Unicode, - widget.model_id, # pyright: ignore[reportUnknownMemberType] - ) - ), - "fill": fill, - } - - # ######## - # Enhancements - # ######## - - # TODO-barret; Turn these into reactives. We do not have reactive values in `py-shiny`, we shouldn't have them in `py-shinywidgets` - # TODO-barret; Add `.reactive_read()` and `.reactive_depend()` methods - - def _get_reactive_obj(self, x: T) -> T | None: - self._register_current_context() - if x is not None: - return x - # If `self._value` or `self._widget` is None, we're reading in a reactive context, - # other than the render context, throw a silent exception - if self._has_current_context(): - req(False) - return None - - @property - def value(self) -> ValueT | None: - return self._get_reactive_obj(self._value) - - @value.setter - def value(self, value: object): - raise RuntimeError( - "The `value` attribute of a @render_widget function is read only." - ) - - @property - def widget(self) -> WidgetT | None: - return self._get_reactive_obj(self._widget) - - @widget.setter - def widget(self, widget: object): - raise RuntimeError( - "The `widget` attribute of a @render_widget function is read only." - ) - - # ######## - # Reactivity contexts - # ######## - def _has_current_context(self) -> bool: - try: - get_current_context() - return True - except RuntimeError: - return False - - # _contexts: set[Context] - def _invalidate_contexts(self) -> None: - for ctx in self._contexts: - ctx.invalidate() - - # If the widget/value is read in a reactive context, then we'll need to invalidate - # that context when the widget's value changes - def _register_current_context(self) -> None: - if not self._has_current_context(): - return - self._contexts.add(get_current_context()) - - -class render_widget(render_widget_base[ValueT, Widget]): - ... - - -# TODO-future; Add support for plotly (and other packages) -# # Working proof of concept for `@render_plotly` -# import plotly.graph_objects as go -# FigureT = TypeVar("FigureT", bound=go.Figure | go.FigureWidget) -# class render_plotly(render_widget_base[FigureT, go.FigureWidget]): -# ... +# -------------------------------------- +# Reactivity +# -------------------------------------- # TODO-barret; Make method on RenderWidget base @@ -371,7 +177,7 @@ def reactive_depend( names = [names] for name in names: - if not widget.has_trait(name): + if not widget.has_trait(name): # pyright: ignore[reportUnknownMemberType] raise ValueError( f"The '{name}' attribute of {widget.__class__.__name__} is not a " "widget trait, and so it's not possible to reactively read it. " @@ -389,6 +195,7 @@ def _(): ctx.on_invalidate(_) +# TODO-barret; Proposal: Remove `register_widget()` in favor of `@render_widget` def register_widget( id: str, widget: Widget, session: Optional[Session] = None ) -> Widget: @@ -429,17 +236,19 @@ def set_layout_defaults(widget: Widget) -> Tuple[Widget, bool]: # Plotly provides it's own layout API (which isn't a subclass of ipywidgets.Layout) if pkg == "plotly": - from plotly.graph_objs import Layout as PlotlyLayout + from plotly.graph_objs import Layout as PlotlyLayout # pyright: ignore if isinstance(layout, PlotlyLayout): - if layout.height is not None: + if layout.height is not None: # pyright: ignore[reportUnknownMemberType] fill = False # Default margins are also way too big - layout.template.layout.margin = dict(l=16, t=32, r=16, b=16) + layout.template.layout.margin = dict( # pyright: ignore + l=16, t=32, r=16, b=16 + ) # Unfortunately, plotly doesn't want to respect the top margin template, # so change that 60px default to 32px - if layout.margin["t"] == 60: - layout.margin["t"] = 32 + if layout.margin["t"] == 60: # pyright: ignore + layout.margin["t"] = 32 # pyright: ignore widget.layout = layout @@ -452,7 +261,10 @@ def set_layout_defaults(widget: Widget) -> Tuple[Widget, bool]: # Since as_widget() has already happened, we only need to handle JupyterChart if isinstance(widget, alt.JupyterChart): - if isinstance(widget.chart, alt.ConcatChart): + if isinstance( + widget.chart, # pyright: ignore[reportUnknownMemberType] + alt.ConcatChart, + ): # Throw warning to use ui.layout_column_wrap() instead warnings.warn( "Consider using shiny.ui.layout_column_wrap() instead of alt.concat() " From d5561df4b2004677a4ab3a08389a559a192bff1f Mon Sep 17 00:00:00 2001 From: Carson Date: Tue, 16 Jan 2024 15:25:57 -0600 Subject: [PATCH 07/10] Fix plotly example --- examples/plotly/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/plotly/app.py b/examples/plotly/app.py index 858e155..85c09d5 100644 --- a/examples/plotly/app.py +++ b/examples/plotly/app.py @@ -44,7 +44,7 @@ def scatterplot(): @reactive.Effect def _(): - plt.data[1].visible = input.show_fit() + scatterplot.widget.data[1].visible = input.show_fit() From 5764d44b51c197236765a19f8376ac5b05ede2dc Mon Sep 17 00:00:00 2001 From: Carson Date: Tue, 16 Jan 2024 15:40:11 -0600 Subject: [PATCH 08/10] Remove redundant typing --- shinywidgets/_render_widget_base.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/shinywidgets/_render_widget_base.py b/shinywidgets/_render_widget_base.py index 2c0d049..d8c38c0 100644 --- a/shinywidgets/_render_widget_base.py +++ b/shinywidgets/_render_widget_base.py @@ -59,10 +59,10 @@ def __init__( fillable: Optional[bool] = None, ): super().__init__(_fn) - self.width: Optional[str] = width - self.height: Optional[str] = height - self.fill: Optional[bool] = fill - self.fillable: Optional[bool] = fillable + self.width = width + self.height = height + self.fill = fill + self.fillable = fillable # self._value: ValueT | None = None # TODO-barret; Not right type # self._widget: WidgetT | None = None From 4dadfbd95fe01f858efc4c65e82b377680130ea0 Mon Sep 17 00:00:00 2001 From: Carson Date: Tue, 16 Jan 2024 17:11:43 -0600 Subject: [PATCH 09/10] Revert to reactive property approach. Only provide decorators for packages that need coercion --- CHANGELOG.md | 1 + shinywidgets/__init__.py | 2 - shinywidgets/_as_widget.py | 8 +- shinywidgets/_render_widget.py | 25 +-- shinywidgets/_render_widget_base.py | 315 ++++++++++------------------ shinywidgets/_shinywidgets.py | 103 +-------- shinywidgets/_utils.py | 23 ++ 7 files changed, 160 insertions(+), 317 deletions(-) create mode 100644 shinywidgets/_utils.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b81ec2d..8db453a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [UNRELEASED] +* The `@render_widget` decorator now attaches a `widget` (and `value`) attribute to the function it decorates. This allows for easier access to the widget instance (or value), and eliminates the need for `register_widget` (which is now soft deprecated). (#119) * Reduce default plot margins on plotly graphs. ## [0.2.4] - 2023-11-20 diff --git a/shinywidgets/__init__.py b/shinywidgets/__init__.py index ad3039c..b4409e3 100644 --- a/shinywidgets/__init__.py +++ b/shinywidgets/__init__.py @@ -10,7 +10,6 @@ from ._render_widget import ( render_altair, render_bokeh, - render_leaflet, render_plotly, render_pydeck, render_widget, @@ -22,7 +21,6 @@ "render_widget", "render_altair", "render_bokeh", - "render_leaflet", "render_plotly", "render_pydeck", # Reactive read second diff --git a/shinywidgets/_as_widget.py b/shinywidgets/_as_widget.py index 916f606..f080a50 100644 --- a/shinywidgets/_as_widget.py +++ b/shinywidgets/_as_widget.py @@ -35,7 +35,7 @@ def as_widget(x: object) -> Widget: def as_widget_altair(x: object) -> Optional[Widget]: try: - from altair import JupyterChart + from altair import JupyterChart # pyright: ignore[reportMissingTypeStubs] except ImportError: raise RuntimeError( "Failed to import altair.JupyterChart (do you need to pip install -U altair?)" @@ -55,9 +55,9 @@ def as_widget_bokeh(x: object) -> Optional[Widget]: # TODO: ideally we'd do this in set_layout_defaults() but doing # `BokehModel(x)._model.sizing_mode = "stretch_both"` # there, but that doesn't seem to work?? - from bokeh.plotting import figure + from bokeh.plotting import figure # pyright: ignore[reportMissingTypeStubs] - if isinstance(x, figure): + if isinstance(x, figure): # type: ignore x.sizing_mode = "stretch_both" # pyright: ignore[reportGeneralTypeIssues] return BokehModel(x) # type: ignore @@ -67,7 +67,7 @@ def as_widget_plotly(x: object) -> Optional[Widget]: # Don't need a try import here since this won't be called unless x is a plotly object import plotly.graph_objects as go # pyright: ignore[reportMissingTypeStubs] - if not isinstance(x, go.Figure): + if not isinstance(x, go.Figure): # type: ignore raise TypeError( f"Don't know how to coerce {x} into a plotly.graph_objects.FigureWidget object." ) diff --git a/shinywidgets/_render_widget.py b/shinywidgets/_render_widget.py index 29362e8..181710f 100644 --- a/shinywidgets/_render_widget.py +++ b/shinywidgets/_render_widget.py @@ -2,18 +2,15 @@ from typing import TYPE_CHECKING -from ipywidgets.widgets import Widget # pyright: ignore[reportMissingTypeStubs] - if TYPE_CHECKING: - from altair import JupyterChart + from altair import JupyterChart # pyright: ignore[reportMissingTypeStubs] from jupyter_bokeh import BokehModel # pyright: ignore[reportMissingTypeStubs] from plotly.graph_objects import ( # pyright: ignore[reportMissingTypeStubs] FigureWidget, ) from pydeck.widget import DeckGLWidget # pyright: ignore[reportMissingTypeStubs] - - # Leaflet Widget class is the same as a Widget - # from ipyleaflet import Widget as LeafletWidget +else: + JupyterChart = BokehModel = FigureWidget = DeckGLWidget = object from ._render_widget_base import ValueT, WidgetT, render_widget_base @@ -21,31 +18,25 @@ "render_widget", "render_altair", "render_bokeh", - "render_leaflet", "render_plotly", "render_pydeck", ) - -class render_widget(render_widget_base[ValueT, Widget]): +# In the generic case, just relay whatever the user's return type is +# since we're not doing any coercion +class render_widget(render_widget_base[WidgetT, WidgetT]): ... - +# Package specific renderers that require coercion (via as_widget()) +# NOTE: the types on these classes should mirror what as_widget() does class render_altair(render_widget_base[ValueT, JupyterChart]): ... - class render_bokeh(render_widget_base[ValueT, BokehModel]): ... - -class render_leaflet(render_widget_base[WidgetT, WidgetT]): - ... - - class render_plotly(render_widget_base[ValueT, FigureWidget]): ... - class render_pydeck(render_widget_base[ValueT, DeckGLWidget]): ... diff --git a/shinywidgets/_render_widget_base.py b/shinywidgets/_render_widget_base.py index d8c38c0..da94a44 100644 --- a/shinywidgets/_render_widget_base.py +++ b/shinywidgets/_render_widget_base.py @@ -1,21 +1,22 @@ from __future__ import annotations -from typing import Any, Generic, Optional, Sequence, TypeVar, Union, cast +import warnings +from typing import Generic, Optional, Tuple, TypeVar, cast from htmltools import Tag -from ipywidgets.widgets import Widget # pyright: ignore[reportMissingTypeStubs] -from shiny import reactive, req -from shiny.reactive import ( - Calc_ as shiny_reactive_calc_class, # pyright: ignore[reportPrivateImportUsage] +from ipywidgets.widgets import ( # pyright: ignore[reportMissingTypeStubs] + DOMWidget, + Layout, + Widget, ) -from shiny.reactive import value as shiny_reactive_value +from shiny import req from shiny.reactive._core import Context, get_current_context from shiny.render.renderer import Jsonifiable, Renderer, ValueFn from traitlets import Unicode from ._as_widget import as_widget +from ._dependencies import widget_pkg from ._output_widget import output_widget -from ._shinywidgets import reactive_depend, reactive_read, set_layout_defaults __all__ = ( "render_widget_base", @@ -35,7 +36,7 @@ """ The type of the widget created from the renderer's ValueT """ - +T = TypeVar("T", bound=object) class render_widget_base(Renderer[ValueT], Generic[ValueT, WidgetT]): """ """ @@ -64,19 +65,19 @@ def __init__( self.fill = fill self.fillable = fillable - # self._value: ValueT | None = None # TODO-barret; Not right type - # self._widget: WidgetT | None = None + self._value: ValueT | None = None + self._widget: WidgetT | None = None self._contexts: set[Context] = set() - self._value: shiny_reactive_value[ValueT | None] = shiny_reactive_value(None) - self._widget: shiny_reactive_value[WidgetT | None] = shiny_reactive_value(None) - async def render(self) -> Jsonifiable | None: value = await self.fn() # Attach value/widget attributes to user func so they can be accessed (in other reactive contexts) - self._value.set(value) - self._widget.set(None) + self._value = value + self._widget = None + + # Invalidate any reactive contexts that have read these attributes + self._invalidate_contexts() if value is None: return None @@ -85,10 +86,7 @@ async def render(self) -> Jsonifiable | None: widget = as_widget(value) widget, fill = set_layout_defaults(widget) - self._widget.set( - # TODO-future; Remove cast call once `as_widget()` returns a WidgetT - cast(WidgetT, widget) - ) + self._widget = cast(WidgetT, widget) return { "model_id": str( @@ -100,198 +98,113 @@ async def render(self) -> Jsonifiable | None: "fill": fill, } - # ######## - # Enhancements - # ######## - - # TODO-barret; Turn these into reactives. We do not have reactive values in `py-shiny`, we shouldn't have them in `py-shinywidgets` - # TODO-barret; Add `.reactive_read()` and `.reactive_depend()` methods - - def value(self) -> ValueT: - value = self._value() - req(value) - - # Can only get here if value is not `None` - value = cast(ValueT, value) - return value - - def widget(self) -> WidgetT: - widget = self._widget() - req(widget) - - # Can only get here if widget is not `None` - widget = cast(WidgetT, widget) - return widget - - # def value_trait(self, name: str) -> Any: - # return reactive_read(self.value(), name) - def widget_trait(self, name: str) -> Any: - return reactive_read(self.widget(), name) - - # ########################################################################## - - # TODO-future; Should this method be supported? Can we have full typing support for the trait values? - # Note: Barret,Carson Jan 11-2024; - # This method is a very Shiny-like approach to making reactive values from - # ipywidgets. However, it would not support reaching into the widget with full - # typing. Instead, it is recommended that we keep `reactive_read(widget_like_obj, - # name)` that upgrades a (nested within widget) value to a resolved value that will - # invalidate the current context when the widget value is updated (by some other - # means). - # - # Since we know that `@render_altair` is built on `altair.JupyterChart`, we know - # that `jchart.widget()` will return an `JupyterChart` object. This object has full - # typing, such as `jchart.widget().selections` which is a JupyterChart `Selections` - # object. Then using the `reactive_read()` function, we can create a reactive value - # from the `Selections` object. This allows for users to reach into the widget as - # much as possible (with full typing) before using `reactive_read()`. - # - # Ex: - # ---------------------- - # ```python - # @render_altair - # def jchart(): - # return some_altair_chart - # - # @render.text - # def selected_point(): - # # This is a reactive value that will invalidate the current context when the chart's selection changes - # selected_point = reactive_read(jchart.widget().selections, "point") - # return f"The selected point is: {selected_point()}" - # ``` - # ---------------------- - # - # Final realization: - # The method below (`_reactive_trait()`) does not support reaching into the widget - # result object. If the method was updated to support a nested key (str), typing - # would not be supported. - # - # Therefore, `reactive_read()` should be used until we can dynamically create - # classes that wrap a widget. (Barret: I am not hopeful that this will be possible - # or worth the effort. Ex: `jchart.traits.selections.point()` would be a reactive - # and fully typed.) - def _reactive_trait( - self, - names: Union[str, Sequence[str]], - ) -> shiny_reactive_calc_class[Any]: - """ - Create a reactive value of a widget's top-level value that can be accessed by - name. - - Ex: - - ```python - slider_value = slider.reactive_trait("value") - - @render.text - def slider_val(): - return f"The value of the slider is: {slider_value()}" - ``` - """ - - if in_reactive_context(): - raise RuntimeError( - "Calling `reactive_trait()` within a reactive context is not supported." - ) + @property + def value(self) -> ValueT | None: + return self._get_reactive_obj(self._value) - reactive_trait: shiny_reactive_value[Any] = shiny_reactive_value(None) - - names_was_str = isinstance(names, str) - if isinstance(names, str): - names = [names] - - @reactive.effect - def _(): - # Set the value to None incase the widget doesn't exist / have the trait - reactive_trait.set(None) - - widget = self.widget() - - for name in names: - if not widget.has_trait( # pyright: ignore[reportUnknownMemberType] - name - ): - raise ValueError( - f"The '{name}' attribute of {widget.__class__.__name__} is not a " - "widget trait, and so it's not possible to reactively read it. " - "For a list of widget traits, call `.widget().trait_names()`." - ) - - # # From `Widget.observe()` docs: - # A callable that is called when a trait changes. Its - # signature should be ``handler(change)``, where ``change`` is a - # dictionary. The change dictionary at least holds a 'type' key. - # * ``type``: the type of notification. - # Other keys may be passed depending on the value of 'type'. In the - # case where type is 'change', we also have the following keys: - # * ``owner`` : the HasTraits instance - # * ``old`` : the old value of the modified trait attribute - # * ``new`` : the new value of the modified trait attribute - # * ``name`` : the name of the modified trait attribute. - def on_key_update(change: object): - if names_was_str: - val = getattr(widget, names[0]) - else: - val = tuple(getattr(widget, name) for name in names) - - reactive_trait.set(val) - - # set value to the init widget value - on_key_update(None) - - # Setup - onchange - # When widget attr changes, update the reactive value - widget.observe( # pyright: ignore[reportUnknownMemberType] - on_key_update, - names, # pyright: ignore[reportGeneralTypeIssues] - "change", - ) - - # Teardown - onchange - # When the widget object is created again, remove the old observer - def on_ctx_invalidate(): - widget.unobserve( # pyright: ignore[reportUnknownMemberType] - on_key_update, - names, # pyright: ignore[reportGeneralTypeIssues] - "change", - ) - - get_current_context().on_invalidate(on_ctx_invalidate) - - # Return a calc object that can only be read from - @reactive.calc - def trait_calc(): - return reactive_trait() + @value.setter + def value(self, value: object): + raise RuntimeError( + "The `value` attribute of a @render_widget function is read only." + ) - return trait_calc + @property + def widget(self) -> WidgetT | None: + return self._get_reactive_obj(self._widget) - # Note: Should be removed once `._reactive_trait()` is removed - def _reactive_read(self, names: Union[str, Sequence[str]]) -> Any: - """ - Reactively read a Widget's trait(s) - """ - self._reactive_depend(names) + @widget.setter + def widget(self, widget: object): + raise RuntimeError( + "The `widget` attribute of a @render_widget function is read only." + ) - widget = self.widget() + def _get_reactive_obj(self, x: T) -> T | None: + self._register_current_context() + if x is not None: + return x + if has_current_context(): + req(False) # A widget/model hasn't rendered yet + return None - return reactive_read(widget, names) + def _invalidate_contexts(self) -> None: + for ctx in self._contexts: + ctx.invalidate() - # Note: Should be removed once `._reactive_trait()` is removed - def _reactive_depend( - self, - names: Union[str, Sequence[str]], - type: str = "change", - ) -> None: - """ - Reactively depend upon a Widget's trait(s) - """ - return reactive_depend(self.widget(), names, type) + # If the widget/value is read in a reactive context, then we'll need to invalidate + # that context when the widget's value changes + def _register_current_context(self) -> None: + if not has_current_context(): + return + self._contexts.add(get_current_context()) -def in_reactive_context() -> bool: +def has_current_context() -> bool: try: - # Raises a `RuntimeError` if there is no current context get_current_context() return True except RuntimeError: return False + + +def set_layout_defaults(widget: Widget) -> Tuple[Widget, bool]: + # If we detect a user specified height on the widget, then don't + # do filling layout (akin to the behavior of output_widget(height=...)) + fill = True + + if not isinstance(widget, DOMWidget): + return (widget, fill) + + # Do nothing for "input-like" widgets (e.g., ipywidgets.IntSlider()) + if getattr(widget, "_model_module", None) == "@jupyter-widgets/controls": + return (widget, False) + + layout = widget.layout # type: ignore + + # If the ipywidget Layout() height is set to something other than "auto", then + # don't do filling layout https://ipywidgets.readthedocs.io/en/stable/examples/Widget%20Layout.html + if isinstance(layout, Layout): + if layout.height is not None and layout.height != "auto": # type: ignore + fill = False + + pkg = widget_pkg(widget) + + # Plotly provides it's own layout API (which isn't a subclass of ipywidgets.Layout) + if pkg == "plotly": + from plotly.graph_objs import Layout as PlotlyLayout # pyright: ignore + + if isinstance(layout, PlotlyLayout): + if layout.height is not None: # pyright: ignore[reportUnknownMemberType] + fill = False + # Default margins are also way too big + layout.template.layout.margin = dict( # pyright: ignore + l=16, t=32, r=16, b=16 + ) + # Unfortunately, plotly doesn't want to respect the top margin template, + # so change that 60px default to 32px + if layout.margin["t"] == 60: # pyright: ignore + layout.margin["t"] = 32 # pyright: ignore + + widget.layout = layout + + # altair, confusingly, isn't setup to fill it's Layout() container by default. I + # can't imagine a situation where you'd actually want it to _not_ fill the parent + # container since it'll be contained within the Layout() container, which has a + # full-fledged sizing API. + if pkg == "altair": + import altair as alt # pyright: ignore[reportMissingTypeStubs] + + # Since as_widget() has already happened, we only need to handle JupyterChart + if isinstance(widget, alt.JupyterChart): + if isinstance( + widget.chart, # pyright: ignore[reportUnknownMemberType] + alt.ConcatChart, + ): + # Throw warning to use ui.layout_column_wrap() instead + warnings.warn( + "Consider using shiny.ui.layout_column_wrap() instead of alt.concat() " + "for multi-column layout (the latter doesn't support filling layout)." + ) + else: + widget.chart = widget.chart.properties(width="container", height="container") # type: ignore + + return (widget, fill) diff --git a/shinywidgets/_shinywidgets.py b/shinywidgets/_shinywidgets.py index f1e5a1e..034a4ac 100644 --- a/shinywidgets/_shinywidgets.py +++ b/shinywidgets/_shinywidgets.py @@ -1,12 +1,9 @@ from __future__ import annotations import copy -import importlib import json import os -import tempfile -import warnings -from typing import Any, Optional, Sequence, Tuple, Union, cast +from typing import Any, Optional, Sequence, Union, cast from uuid import uuid4 from weakref import WeakSet @@ -28,7 +25,8 @@ from ._as_widget import as_widget from ._cdn import SHINYWIDGETS_CDN_ONLY, SHINYWIDGETS_EXTENSION_WARNING from ._comm import BufferType, ShinyComm, ShinyCommManager -from ._dependencies import require_dependency, widget_pkg +from ._dependencies import require_dependency +from ._utils import is_instance_of_class, package_dir __all__ = ( "register_widget", @@ -149,9 +147,10 @@ def _restore_state(): # Reactivity # -------------------------------------- - -# TODO-barret; Make method on RenderWidget base def reactive_read(widget: Widget, names: Union[str, Sequence[str]]) -> Any: + """ + Reactively read a widget trait + """ reactive_depend(widget, names) if isinstance(names, str): return getattr(widget, names) @@ -165,7 +164,7 @@ def reactive_depend( type: str = "change", ) -> None: """ - Reactively read a Widget's trait(s) + Take a reactive dependency on a widget trait """ try: @@ -195,10 +194,12 @@ def _(): ctx.on_invalidate(_) -# TODO-barret; Proposal: Remove `register_widget()` in favor of `@render_widget` def register_widget( id: str, widget: Widget, session: Optional[Session] = None ) -> Widget: + """ + Deprecated. Use @render_widget instead. + """ if session is None: session = require_active_session(session) @@ -212,90 +213,6 @@ def _(): return w -def set_layout_defaults(widget: Widget) -> Tuple[Widget, bool]: - # If we detect a user specified height on the widget, then don't - # do filling layout (akin to the behavior of output_widget(height=...)) - fill = True - - if not isinstance(widget, DOMWidget): - return (widget, fill) - - # Do nothing for "input-like" widgets (e.g., ipywidgets.IntSlider()) - if getattr(widget, "_model_module", None) == "@jupyter-widgets/controls": - return (widget, False) - - layout = widget.layout # type: ignore - - # If the ipywidget Layout() height is set to something other than "auto", then - # don't do filling layout https://ipywidgets.readthedocs.io/en/stable/examples/Widget%20Layout.html - if isinstance(layout, Layout): - if layout.height is not None and layout.height != "auto": # type: ignore - fill = False - - pkg = widget_pkg(widget) - - # Plotly provides it's own layout API (which isn't a subclass of ipywidgets.Layout) - if pkg == "plotly": - from plotly.graph_objs import Layout as PlotlyLayout # pyright: ignore - - if isinstance(layout, PlotlyLayout): - if layout.height is not None: # pyright: ignore[reportUnknownMemberType] - fill = False - # Default margins are also way too big - layout.template.layout.margin = dict( # pyright: ignore - l=16, t=32, r=16, b=16 - ) - # Unfortunately, plotly doesn't want to respect the top margin template, - # so change that 60px default to 32px - if layout.margin["t"] == 60: # pyright: ignore - layout.margin["t"] = 32 # pyright: ignore - - widget.layout = layout - - # altair, confusingly, isn't setup to fill it's Layout() container by default. I - # can't imagine a situation where you'd actually want it to _not_ fill the parent - # container since it'll be contained within the Layout() container, which has a - # full-fledged sizing API. - if pkg == "altair": - import altair as alt - - # Since as_widget() has already happened, we only need to handle JupyterChart - if isinstance(widget, alt.JupyterChart): - if isinstance( - widget.chart, # pyright: ignore[reportUnknownMemberType] - alt.ConcatChart, - ): - # Throw warning to use ui.layout_column_wrap() instead - warnings.warn( - "Consider using shiny.ui.layout_column_wrap() instead of alt.concat() " - "for multi-column layout (the latter doesn't support filling layout)." - ) - else: - widget.chart = widget.chart.properties(width="container", height="container") # type: ignore - - return (widget, fill) - - -# similar to base::system.file() -def package_dir(package: str) -> str: - with tempfile.TemporaryDirectory(): - pkg_file = importlib.import_module(".", package=package).__file__ - if pkg_file is None: - raise ImportError(f"Couldn't load package {package}") - return os.path.dirname(pkg_file) - - -def is_instance_of_class( - x: object, class_name: str, module_name: Optional[str] = None -) -> bool: - typ = type(x) - res = typ.__name__ == class_name - if module_name is None: - return res - else: - return res and typ.__module__ == module_name - - # It doesn't, at the moment, seem feasible to establish a comm with statically rendered widgets, # and partially for this reason, it may not be sensible to provide an input-like API for them. diff --git a/shinywidgets/_utils.py b/shinywidgets/_utils.py new file mode 100644 index 0000000..7044434 --- /dev/null +++ b/shinywidgets/_utils.py @@ -0,0 +1,23 @@ +import importlib +import os +import tempfile +from typing import Optional + +# similar to base::system.file() +def package_dir(package: str) -> str: + with tempfile.TemporaryDirectory(): + pkg_file = importlib.import_module(".", package=package).__file__ + if pkg_file is None: + raise ImportError(f"Couldn't load package {package}") + return os.path.dirname(pkg_file) + + +def is_instance_of_class( + x: object, class_name: str, module_name: Optional[str] = None +) -> bool: + typ = type(x) + res = typ.__name__ == class_name + if module_name is None: + return res + else: + return res and typ.__module__ == module_name From 18f546ee5578dace7cca25f91d275e89d271f8c5 Mon Sep 17 00:00:00 2001 From: Carson Date: Tue, 16 Jan 2024 17:13:41 -0600 Subject: [PATCH 10/10] Express-ify examples where it makes sense to --- examples/altair/app.py | 39 ++++++------------ examples/ipyleaflet/app.py | 72 +++++++++++++------------------- examples/ipywidgets/app.py | 61 ++++++++++++--------------- examples/plotly/app.py | 69 ++++++++++++++----------------- examples/pydeck/app.py | 84 ++++++++++++++++---------------------- 5 files changed, 135 insertions(+), 190 deletions(-) diff --git a/examples/altair/app.py b/examples/altair/app.py index 1a9eb5b..77ea7a5 100644 --- a/examples/altair/app.py +++ b/examples/altair/app.py @@ -1,37 +1,24 @@ import altair as alt -from shiny import App, render, ui +import shiny.express +from shiny import render from vega_datasets import data -from shinywidgets import output_widget, reactive_read, register_widget +from shinywidgets import reactive_read, render_altair -source = data.cars() -app_ui = ui.page_fluid( - ui.output_text_verbatim("selection"), - output_widget("chart") -) +# Output selection information (click on legend in the plot) +@render.text +def selection(): + pt = reactive_read(jchart.widget.selections, "point") + return "Selected point: " + str(pt) -def server(input, output, session): - - # Replicate JupyterChart interactivity - # https://altair-viz.github.io/user_guide/jupyter_chart.html#point-selections +# Replicate JupyterChart interactivity +# https://altair-viz.github.io/user_guide/jupyter_chart.html#point-selections +@render_altair +def jchart(): brush = alt.selection_point(name="point", encodings=["color"], bind="legend") - chart = alt.Chart(source).mark_point().encode( + return alt.Chart(data.cars()).mark_point().encode( x='Horsepower:Q', y='Miles_per_Gallon:Q', color=alt.condition(brush, 'Origin:N', alt.value('grey')), ).add_params(brush) - - jchart = alt.JupyterChart(chart) - - # Display/register the chart in the app_ui - register_widget("chart", jchart) - - # Reactive-ly read point selections - @output - @render.text - def selection(): - pt = reactive_read(jchart.selections, "point") - return "Selected point: " + str(pt) - -app = App(app_ui, server) diff --git a/examples/ipyleaflet/app.py b/examples/ipyleaflet/app.py index 32a6cc4..37edb62 100644 --- a/examples/ipyleaflet/app.py +++ b/examples/ipyleaflet/app.py @@ -1,50 +1,36 @@ import ipyleaflet as L -from shiny import App, reactive, render, req, ui - -from shinywidgets import output_widget, reactive_read, render_widget - -app_ui = ui.page_sidebar( - ui.sidebar( - ui.input_slider("zoom", "Map zoom level", value=4, min=1, max=10), - ), - ui.card( - ui.output_text("map_bounds"), - fill=False - ), - ui.card( - output_widget("lmap") - ), - title="ipyleaflet demo" -) - - -def server(input, output, session): - - @output - @render_widget - def lmap(): - return L.Map(center=(52, 360), zoom=4) - - # When the slider changes, update the map's zoom attribute (2) - @reactive.Effect - def _(): - lmap.widget.zoom = input.zoom() - - # When zooming directly on the map, update the slider's value (2 and 3) - @reactive.Effect - def _(): - zoom = reactive_read(lmap.widget, "zoom") - ui.update_slider("zoom", value=zoom) - - # Everytime the map's bounds change, update the output message (3) - @output - @render.text +from shiny import reactive, render, req +from shiny.express import input, ui + +from shinywidgets import reactive_read, render_widget + +ui.page_opts(title="ipyleaflet demo") + +with ui.sidebar(): + ui.input_slider("zoom", "Map zoom level", value=4, min=1, max=10) + +@render_widget +def lmap(): + return L.Map(center=(52, 360), zoom=4) + +# When the slider changes, update the map's zoom attribute +@reactive.Effect +def _(): + lmap.widget.zoom = input.zoom() + +# When zooming directly on the map, update the slider's value +@reactive.Effect +def _(): + zoom = reactive_read(lmap.widget, "zoom") + ui.update_slider("zoom", value=zoom) + + +with ui.card(fill=False): + # Everytime the map's bounds change, update the output message + @render.ui def map_bounds(): b = reactive_read(lmap.widget, "bounds") req(b) lat = [round(x) for x in [b[0][0], b[0][1]]] lon = [round(x) for x in [b[1][0], b[1][1]]] return f"The map bounds is currently {lat} / {lon}" - - -app = App(app_ui, server) diff --git a/examples/ipywidgets/app.py b/examples/ipywidgets/app.py index d7b51f2..1192ee1 100644 --- a/examples/ipywidgets/app.py +++ b/examples/ipywidgets/app.py @@ -1,35 +1,26 @@ -import ipywidgets as ipy -from ipywidgets.widgets.widget import Widget -from shiny import * - -from shinywidgets import * - -app_ui = ui.page_fluid(output_widget("slider", fillable=False, fill=False), ui.output_text("slider_val")) - - -def server(input: Inputs, output: Outputs, session: Session): - - @output - @render_widget - def slider(): - return ipy.IntSlider( - value=7, - min=0, - max=10, - step=1, - description="Test:", - disabled=False, - continuous_update=False, - orientation="horizontal", - readout=True, - readout_format="d", - ) - - @output - @render.text - def slider_val(): - val = reactive_read(slider.widget, "value") - return f"The value of the slider is: {val}" - - -app = App(app_ui, server, debug=True) +import shiny.express +from ipywidgets import IntSlider +from shiny import render + +from shinywidgets import reactive_read, render_widget + + +@render_widget +def slider(): + return IntSlider( + value=7, + min=0, + max=10, + step=1, + description="Test:", + disabled=False, + continuous_update=False, + orientation="horizontal", + readout=True, + readout_format="d", + ) + +@render.ui +def slider_val(): + val = reactive_read(slider.widget, "value") + return f"The value of the slider is: {val}" diff --git a/examples/plotly/app.py b/examples/plotly/app.py index 85c09d5..ed8da94 100644 --- a/examples/plotly/app.py +++ b/examples/plotly/app.py @@ -1,9 +1,10 @@ import numpy as np import plotly.graph_objs as go -from shiny import * +from shiny import reactive +from shiny.express import input, ui from sklearn.linear_model import LinearRegression -from shinywidgets import output_widget, render_widget +from shinywidgets import render_plotly # Generate some data and fit a linear regression n = 10000 @@ -13,39 +14,31 @@ fit = LinearRegression().fit(x.reshape(-1, 1), dat[1]) xgrid = np.linspace(start=min(x), stop=max(x), num=30) -app_ui = ui.page_fillable( - ui.input_checkbox("show_fit", "Show fitted line", value=True), - output_widget("scatterplot") -) - - -def server(input, output, session): - - @output - @render_widget - def scatterplot(): - return go.FigureWidget( - data=[ - go.Scattergl( - x=x, - y=y, - mode="markers", - marker=dict(color="rgba(0, 0, 0, 0.05)", size=5), - ), - go.Scattergl( - x=xgrid, - y=fit.intercept_ + fit.coef_[0] * xgrid, - mode="lines", - line=dict(color="red", width=2), - ), - ], - layout={"showlegend": False}, - ) - - @reactive.Effect - def _(): - scatterplot.widget.data[1].visible = input.show_fit() - - - -app = App(app_ui, server) +ui.page_opts(title="Plotly demo", fillable=True) + +ui.input_checkbox("show_fit", "Show fitted line", value=True) + +@render_plotly +def scatterplot(): + return go.FigureWidget( + data=[ + go.Scattergl( + x=x, + y=y, + mode="markers", + marker=dict(color="rgba(0, 0, 0, 0.05)", size=5), + ), + go.Scattergl( + x=xgrid, + y=fit.intercept_ + fit.coef_[0] * xgrid, + mode="lines", + line=dict(color="red", width=2), + ), + ], + layout={"showlegend": False}, + ) + + +@reactive.Effect +def _(): + scatterplot.widget.data[1].visible = input.show_fit() diff --git a/examples/pydeck/app.py b/examples/pydeck/app.py index 1933d4b..0629ee7 100644 --- a/examples/pydeck/app.py +++ b/examples/pydeck/app.py @@ -1,49 +1,37 @@ import pydeck as pdk -from shiny import * - -from shinywidgets import * - -app_ui = ui.page_fillable( - ui.input_slider("zoom", "Zoom", 0, 20, 6, step=1), - output_widget("deckmap") -) - -def server(input: Inputs, output: Outputs, session: Session): - - @output - @render_widget - def deckmap(): - - UK_ACCIDENTS_DATA = "https://raw.githubusercontent.com/visgl/deck.gl-data/master/examples/3d-heatmap/heatmap-data.csv" - - layer = pdk.Layer( - "HexagonLayer", # `type` positional argument is here - UK_ACCIDENTS_DATA, - get_position=["lng", "lat"], - auto_highlight=True, - elevation_scale=50, - pickable=True, - elevation_range=[0, 3000], - extruded=True, - coverage=1, - ) - - view_state = pdk.ViewState( - longitude=-1.415, - latitude=52.2323, - zoom=6, - min_zoom=5, - max_zoom=15, - pitch=40.5, - bearing=-27.36, - ) - - return pdk.Deck(layers=[layer], initial_view_state=view_state) - - @reactive.Effect() - def _(): - deckmap.value.initial_view_state.zoom = input.zoom() - deckmap.value.update() - - -app = App(app_ui, server) +from shiny import reactive +from shiny.express import input, ui + +from shinywidgets import render_pydeck + +ui.input_slider("zoom", "Zoom", 0, 20, 6, step=1) + +@render_pydeck +def deckmap(): + UK_ACCIDENTS_DATA = "https://raw.githubusercontent.com/visgl/deck.gl-data/master/examples/3d-heatmap/heatmap-data.csv" + layer = pdk.Layer( + "HexagonLayer", + UK_ACCIDENTS_DATA, + get_position=["lng", "lat"], + auto_highlight=True, + elevation_scale=50, + pickable=True, + elevation_range=[0, 3000], + extruded=True, + coverage=1, + ) + view_state = pdk.ViewState( + longitude=-1.415, + latitude=52.2323, + zoom=6, + min_zoom=5, + max_zoom=15, + pitch=40.5, + bearing=-27.36, + ) + return pdk.Deck(layers=[layer], initial_view_state=view_state) + +@reactive.effect() +def _(): + deckmap.value.initial_view_state.zoom = input.zoom() + deckmap.value.update()