diff --git a/shiny/express/_recall_context.py b/shiny/express/_recall_context.py index f846de89c..cfab29ccb 100644 --- a/shiny/express/_recall_context.py +++ b/shiny/express/_recall_context.py @@ -5,9 +5,17 @@ from types import TracebackType from typing import Callable, Generic, Mapping, Optional, Type, TypeVar -from htmltools import MetadataNode, Tag, TagList, wrap_displayhook_handler +from htmltools import ( + MetadataNode, + Tag, + TagAttrs, + TagChild, + Tagifiable, + TagList, + wrap_displayhook_handler, +) -from .._typing_extensions import ParamSpec +from .._typing_extensions import ParamSpec, TypeGuard P = ParamSpec("P") R = TypeVar("R") @@ -21,14 +29,22 @@ def __init__( *, args: tuple[object, ...] | None = None, kwargs: Mapping[str, object] | None = None, + filter: Callable[[object], bool] | None = None, ): self.fn = fn + if args is None: args = tuple() + self.args: list[object] = list(args) + if kwargs is None: kwargs = {} - self.args: list[object] = list(args) self.kwargs: dict[str, object] = dict(kwargs) + + if filter is None: + filter = lambda x: True + self.filter = filter + # Let htmltools.wrap_displayhook_handler decide what to do with objects before # we append them. self.wrapped_append = wrap_displayhook_handler(self.args.append) @@ -63,6 +79,8 @@ def displayhook(self, x: object) -> None: # the result from the RecallContextManager. with x: pass + elif not self.filter(x): + pass else: self.wrapped_append(x) @@ -88,3 +106,39 @@ def wrapped_fn(*args: P.args, **kwargs: P.kwargs) -> RecallContextManager[R]: return RecallContextManager(fn, args=args, kwargs=kwargs) return wrapped_fn + + +def filter_ui_objects(x: object) -> TypeGuard[TagChild | TagAttrs | None]: + # Can't seem to figure out how to get typing to work + valid_types = ( # type: ignore + dict, + str, + Tagifiable, + Tag, + TagList, + MetadataNode, + str, + float, + list, + tuple, + ) + + return ( + x is None + or isinstance(x, valid_types) + # TODO: Should export ReprHtml protocol class from htmltools and add it to + # valid_types, and remove line below. + or callable(getattr(x, "_repr_html_", None)) + ) + + +class UiRecallContextManager(RecallContextManager[R]): + def __init__( + self, + fn: Callable[..., R], + *, + args: tuple[object, ...] | None = None, + kwargs: Mapping[str, object] | None = None, + filter: Callable[[object], bool] | None = filter_ui_objects, + ): + super().__init__(fn, args=args, kwargs=kwargs, filter=filter) diff --git a/shiny/express/ui/_cm_components.py b/shiny/express/ui/_cm_components.py index 579ab49bc..495491977 100644 --- a/shiny/express/ui/_cm_components.py +++ b/shiny/express/ui/_cm_components.py @@ -2,18 +2,26 @@ from __future__ import annotations -from typing import Literal, Optional - -from htmltools import Tag, TagAttrs, TagAttrValue, TagChild, TagFunction, TagList +from typing import Any, Literal, Optional, TypeGuard + +from htmltools import ( + MetadataNode, + Tag, + TagAttrs, + TagAttrValue, + TagChild, + TagFunction, + TagList, +) from ... import ui -from ...types import MISSING, MISSING_TYPE +from ...types import MISSING, MISSING_TYPE, NavSetArg from ...ui._accordion import AccordionPanel from ...ui._card import CardItem from ...ui._layout_columns import BreakpointsUser from ...ui._navs import NavMenu, NavPanel, NavSet, NavSetBar, NavSetCard from ...ui.css import CssUnit -from .._recall_context import RecallContextManager +from .._recall_context import RecallContextManager, UiRecallContextManager __all__ = ( "sidebar", @@ -108,7 +116,7 @@ def sidebar( * If four, then the values will be interpreted as top, right, bottom, and left respectively. """ - return RecallContextManager( + return UiRecallContextManager( ui.sidebar, kwargs=dict( width=width, @@ -186,7 +194,7 @@ def layout_sidebar( height Any valid CSS unit to use for the height. """ - return RecallContextManager( + return UiRecallContextManager( ui.layout_sidebar, kwargs=dict( fillable=fillable, @@ -269,7 +277,7 @@ def layout_column_wrap( **kwargs Additional attributes to apply to the containing element. """ - return RecallContextManager( + return UiRecallContextManager( ui.layout_column_wrap, kwargs=dict( width=width, @@ -378,7 +386,7 @@ def layout_columns( * [Bootstrap CSS Grid](https://getbootstrap.com/docs/5.3/layout/grid/) * [Bootstrap Breakpoints](https://getbootstrap.com/docs/5.3/layout/breakpoints/) """ - return RecallContextManager( + return UiRecallContextManager( ui.layout_columns, kwargs=dict( col_widths=col_widths, @@ -438,7 +446,7 @@ def card( # card_body("c"), "d")`, `wrapper` would be called twice, once with `"a"` and # `"b"` and once with `"d"`). - return RecallContextManager( + return UiRecallContextManager( ui.card, kwargs=dict( full_screen=full_screen, @@ -477,7 +485,7 @@ def card_header( **kwargs Additional HTML attributes for the returned Tag. """ - return RecallContextManager( + return UiRecallContextManager( ui.card_header, args=args, kwargs=dict( @@ -511,7 +519,7 @@ def card_footer( Additional HTML attributes for the returned Tag. """ - return RecallContextManager( + return UiRecallContextManager( ui.card_footer, args=args, kwargs=kwargs, @@ -558,6 +566,10 @@ def accordion( **kwargs Attributes to this tag. """ + + def _accordion_filter(x: object) -> bool: + return isinstance(x, (AccordionPanel, dict)) + return RecallContextManager( ui.accordion, kwargs=dict( @@ -569,6 +581,7 @@ def accordion( height=height, **kwargs, ), + filter=_accordion_filter, ) @@ -596,7 +609,7 @@ def accordion_panel( **kwargs Tag attributes to the `accordion-body` div Tag. """ - return RecallContextManager( + return UiRecallContextManager( ui.accordion_panel, args=(title,), kwargs=dict( @@ -645,6 +658,7 @@ def navset_tab( header=header, footer=footer, ), + filter=_navset_filter, ) @@ -681,6 +695,7 @@ def navset_pill( header=header, footer=footer, ), + filter=_navset_filter, ) @@ -718,6 +733,7 @@ def navset_underline( header=header, footer=footer, ), + filter=_navset_filter, ) @@ -754,6 +770,7 @@ def navset_hidden( header=header, footer=footer, ), + filter=_navset_filter, ) @@ -796,6 +813,7 @@ def navset_card_tab( header=header, footer=footer, ), + filter=_navset_filter, ) @@ -838,6 +856,7 @@ def navset_card_pill( header=header, footer=footer, ), + filter=_navset_filter, ) @@ -884,6 +903,7 @@ def navset_card_underline( footer=footer, placement=placement, ), + filter=_navset_filter, ) @@ -928,6 +948,7 @@ def navset_pill_list( well=well, widths=widths, ), + filter=_navset_filter, ) @@ -1026,6 +1047,7 @@ def navset_bar( collapsible=collapsible, fluid=fluid, ), + filter=_navset_filter, ) @@ -1053,7 +1075,7 @@ def nav_panel( icon An icon to appear inline with the button/link. """ - return RecallContextManager( + return UiRecallContextManager( ui.nav_panel, args=(title,), kwargs=dict( @@ -1069,7 +1091,7 @@ def nav_control() -> RecallContextManager[NavPanel]: This function wraps :func:`~shiny.ui.nav_control`. """ - return RecallContextManager(ui.nav_control) + return UiRecallContextManager(ui.nav_control) def nav_menu( @@ -1100,6 +1122,9 @@ def nav_menu( Horizontal alignment of the dropdown menu relative to dropdown toggle. """ + def _nav_menu_filter(x: object) -> TypeGuard[NavPanel | str]: + return isinstance(x, (NavPanel, str)) + return RecallContextManager( ui.nav_menu, args=(title,), @@ -1108,9 +1133,14 @@ def nav_menu( icon=icon, align=align, ), + filter=_nav_menu_filter, ) +def _navset_filter(x: object) -> TypeGuard[NavSet | MetadataNode]: + return isinstance(x, (NavSetArg, MetadataNode)) + + # ====================================================================================== # Value boxes # ====================================================================================== @@ -1178,7 +1208,7 @@ def value_box( **kwargs Additional attributes to pass to :func:`~shiny.ui.card`. """ - return RecallContextManager( + return UiRecallContextManager( ui.value_box, kwargs=dict( showcase=showcase, @@ -1208,7 +1238,7 @@ def panel_well(**kwargs: TagAttrValue) -> RecallContextManager[Tag]: A well panel is a simple container with a border and some padding. It's useful for grouping related content together. """ - return RecallContextManager( + return UiRecallContextManager( ui.panel_well, kwargs=dict( **kwargs, @@ -1252,7 +1282,7 @@ def panel_conditional( A more powerful (but slower) way to conditionally show UI content is to use :class:`~shiny.render.ui`. """ - return RecallContextManager( + return UiRecallContextManager( ui.panel_conditional, args=(condition,), kwargs=dict(**kwargs), @@ -1289,7 +1319,7 @@ def panel_fixed( -------- * :func:`~shiny.ui.panel_absolute` """ - return RecallContextManager( + return UiRecallContextManager( ui.panel_fixed, kwargs=dict( top=top, @@ -1377,7 +1407,7 @@ def panel_absolute( specify 0 for ``top``, ``left``, ``right``, and ``bottom`` rather than the more obvious ``width = "100%"`` and ``height = "100%"``. """ - return RecallContextManager( + return UiRecallContextManager( ui.panel_absolute, kwargs=dict( top=top, @@ -1426,7 +1456,7 @@ def tooltip( options](https://getbootstrap.com/docs/5.3/components/tooltips/#options). """ - return RecallContextManager( + return UiRecallContextManager( ui.tooltip, kwargs=dict( id=id, @@ -1469,7 +1499,7 @@ def popover( options](https://getbootstrap.com/docs/5.3/components/popovers/#options). """ - return RecallContextManager( + return UiRecallContextManager( ui.popover, kwargs=dict( title=title, diff --git a/shiny/express/ui/_page.py b/shiny/express/ui/_page.py index 5ebe2f75c..13cfc18a2 100644 --- a/shiny/express/ui/_page.py +++ b/shiny/express/ui/_page.py @@ -6,14 +6,14 @@ from ... import ui from ...types import MISSING, MISSING_TYPE -from .._recall_context import RecallContextManager +from .._recall_context import RecallContextManager, UiRecallContextManager from .._run import get_top_level_recall_context_manager __all__ = ("page_opts",) def page_auto_cm() -> RecallContextManager[Tag]: - return RecallContextManager(ui.page_auto) + return UiRecallContextManager(ui.page_auto) def page_opts( diff --git a/shiny/types.py b/shiny/types.py index 09b722492..b76c05dbb 100644 --- a/shiny/types.py +++ b/shiny/types.py @@ -12,7 +12,16 @@ "SilentCancelOutputException", ) -from typing import TYPE_CHECKING, Any, BinaryIO, Literal, NamedTuple, Optional, Protocol +from typing import ( + TYPE_CHECKING, + Any, + BinaryIO, + Literal, + NamedTuple, + Optional, + Protocol, + runtime_checkable, +) from htmltools import TagChild @@ -150,6 +159,7 @@ class ActionButtonValue(int): pass +@runtime_checkable class NavSetArg(Protocol): """ A value suitable for passing to a navigation container (e.g.,