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