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/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 6fbffdc..37edb62 100644 --- a/examples/ipyleaflet/app.py +++ b/examples/ipyleaflet/app.py @@ -1,46 +1,36 @@ import ipyleaflet as L -from htmltools import css -from shiny import * - -from shinywidgets import output_widget, reactive_read, register_widget - -app_ui = ui.page_fillable( - ui.div( - ui.input_slider("zoom", "Map zoom level", value=4, min=1, max=10), - ui.output_text("map_bounds"), - style=css( - display="flex", justify_content="center", align_items="center", gap="2rem" - ), - ), - output_widget("map"), -) - - -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) - - # When the slider changes, update the map's zoom attribute (2) - @reactive.Effect - def _(): - map.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")) - - # Everytime the map's bounds change, update the output message (3) - @output - @render.text - def map_bounds(): - b = reactive_read(map, "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}" +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) -app = App(app_ui, server) +# 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}" diff --git a/examples/ipywidgets/app.py b/examples/ipywidgets/app.py index fea36f2..1192ee1 100644 --- a/examples/ipywidgets/app.py +++ b/examples/ipywidgets/app.py @@ -1,14 +1,13 @@ -import ipywidgets as ipy -from ipywidgets.widgets.widget import Widget -from shiny import * +import shiny.express +from ipywidgets import IntSlider +from shiny import render -from shinywidgets import * +from shinywidgets import reactive_read, render_widget -app_ui = ui.page_fluid(output_widget("slider"), ui.output_text("value")) - -def server(input: Inputs, output: Outputs, session: Session): - s: Widget = ipy.IntSlider( +@render_widget +def slider(): + return IntSlider( value=7, min=0, max=10, @@ -21,12 +20,7 @@ def server(input: Inputs, output: Outputs, session: Session): readout_format="d", ) - register_widget("slider", s) - - @output(id="value") - @render.text - def _(): - return f"The value of the slider is: {reactive_read(s, 'value')}" - - -app = App(app_ui, server, debug=True) +@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 568dcc6..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, register_widget +from shinywidgets import render_plotly # Generate some data and fit a linear regression n = 10000 @@ -13,15 +14,13 @@ 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"), -) +ui.page_opts(title="Plotly demo", fillable=True) +ui.input_checkbox("show_fit", "Show fitted line", value=True) -def server(input, output, session): - - scatterplot = go.FigureWidget( +@render_plotly +def scatterplot(): + return go.FigureWidget( data=[ go.Scattergl( x=x, @@ -39,11 +38,7 @@ def server(input, output, session): layout={"showlegend": False}, ) - register_widget("scatterplot", scatterplot) - - @reactive.Effect - def _(): - scatterplot.data[1].visible = input.show_fit() - -app = App(app_ui, server) +@reactive.Effect +def _(): + scatterplot.widget.data[1].visible = input.show_fit() diff --git a/examples/pydeck/app.py b/examples/pydeck/app.py index a285ac5..0629ee7 100644 --- a/examples/pydeck/app.py +++ b/examples/pydeck/app.py @@ -1,19 +1,16 @@ import pydeck as pdk -from shiny import * +from shiny import reactive +from shiny.express import input, ui -from shinywidgets import * +from shinywidgets import render_pydeck -app_ui = ui.page_fillable( - ui.input_slider("zoom", "Zoom", 0, 20, 6, step=1), - output_widget("pydeck") -) - -def server(input: Inputs, output: Outputs, session: Session): +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", # `type` positional argument is here + "HexagonLayer", UK_ACCIDENTS_DATA, get_position=["lng", "lat"], auto_highlight=True, @@ -23,7 +20,6 @@ def server(input: Inputs, output: Outputs, session: Session): extruded=True, coverage=1, ) - view_state = pdk.ViewState( longitude=-1.415, latitude=52.2323, @@ -33,16 +29,9 @@ def server(input: Inputs, output: Outputs, session: Session): pitch=40.5, bearing=-27.36, ) + return pdk.Deck(layers=[layer], initial_view_state=view_state) - deck = pdk.Deck(layers=[layer], initial_view_state=view_state) - - # Register either the deck (or deck_widget) instance - register_widget("pydeck", deck) - - @reactive.Effect() - def _(): - deck.initial_view_state.zoom = input.zoom() - deck.update() - - -app = App(app_ui, server) +@reactive.effect() +def _(): + deckmap.value.initial_view_state.zoom = input.zoom() + deckmap.value.update() 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" diff --git a/shinywidgets/__init__.py b/shinywidgets/__init__.py index ed52fd1..b4409e3 100644 --- a/shinywidgets/__init__.py +++ b/shinywidgets/__init__.py @@ -4,13 +4,32 @@ __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_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_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..f080a50 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 @@ -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?)" @@ -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." @@ -55,18 +55,19 @@ 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 - if isinstance(x, figure): - x.sizing_mode = "stretch_both" + from bokeh.plotting import figure # pyright: ignore[reportMissingTypeStubs] + + if isinstance(x, figure): # type: ignore + 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): + 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/_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..181710f --- /dev/null +++ b/shinywidgets/_render_widget.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + 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] +else: + JupyterChart = BokehModel = FigureWidget = DeckGLWidget = object + +from ._render_widget_base import ValueT, WidgetT, render_widget_base + +__all__ = ( + "render_widget", + "render_altair", + "render_bokeh", + "render_plotly", + "render_pydeck", +) + +# 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_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..da94a44 --- /dev/null +++ b/shinywidgets/_render_widget_base.py @@ -0,0 +1,210 @@ +from __future__ import annotations + +import warnings +from typing import Generic, Optional, Tuple, TypeVar, cast + +from htmltools import Tag +from ipywidgets.widgets import ( # pyright: ignore[reportMissingTypeStubs] + DOMWidget, + Layout, + Widget, +) +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 + +__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 +""" +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 = width + self.height = height + self.fill = fill + self.fillable = fillable + + self._value: ValueT | None = None + 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, + } + + @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." + ) + + 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 + + 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 has_current_context(): + return + self._contexts.add(get_current_context()) + + +def has_current_context() -> bool: + try: + 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 39d2b3f..034a4ac 100644 --- a/shinywidgets/_shinywidgets.py +++ b/shinywidgets/_shinywidgets.py @@ -1,101 +1,39 @@ 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, Optional, Sequence, Tuple, Union, cast, overload +from typing import Any, Optional, Sequence, 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] -) +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.render.transformer import ( - TransformerMetadata, - ValueFn, - output_transformer, - resolve_value_fn, -) +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 ._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 +from ._utils import is_instance_of_class, package_dir -# 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 +48,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 +79,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,41 +143,14 @@ def _restore_state(): COMM_MANAGER = ShinyCommManager() -# -------------------------------------------------------------------------------------------- -# Implement @render_widget() -# -------------------------------------------------------------------------------------------- - - -@output_transformer(default_ui=output_widget) -async def WidgetTransformer( - _meta: TransformerMetadata, - _fn: ValueFn[object | None], -) -> dict[str, Any] | None: - value = await resolve_value_fn(_fn) - if value is None: - return None - widget = as_widget(value) - widget, fill = set_layout_defaults(widget) - 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: - return WidgetTransformer(fn) - +# -------------------------------------- +# Reactivity +# -------------------------------------- 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) @@ -253,11 +164,11 @@ def reactive_depend( type: str = "change", ) -> None: """ - Reactively read a Widget's trait(s) + Take a reactive dependency on a widget trait """ 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") @@ -265,7 +176,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. " @@ -286,6 +197,9 @@ def _(): 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) @@ -299,83 +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 - if isinstance(layout, PlotlyLayout): - if layout.height is not None: - fill = False - # Default margins are also way too big - layout.template.layout.margin = dict(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 - - 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, 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