diff --git a/shiny/express/__init__.py b/shiny/express/__init__.py new file mode 100644 index 000000000..feede5144 --- /dev/null +++ b/shiny/express/__init__.py @@ -0,0 +1,8 @@ +from __future__ import annotations + +from ._output import output_args, suspend_display + +__all__ = ( + "output_args", + "suspend_display", +) diff --git a/shiny/express/_output.py b/shiny/express/_output.py new file mode 100644 index 000000000..8733b07ec --- /dev/null +++ b/shiny/express/_output.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +import contextlib +import sys +from contextlib import AbstractContextManager +from typing import Callable, TypeVar, cast, overload + +from .. import ui +from .._typing_extensions import ParamSpec +from ..render.transformer import OutputRenderer + +__all__ = ( + "output_args", + "suspend_display", +) + +OT = TypeVar("OT") +P = ParamSpec("P") +R = TypeVar("R") +CallableT = TypeVar("CallableT", bound=Callable[..., object]) + + +def output_args( + *args: object, **kwargs: object +) -> Callable[[OutputRenderer[OT]], OutputRenderer[OT]]: + """Sets default UI arguments for a Shiny rendering function. + + Each Shiny render function (like :func:`~shiny.render.plot`) can display itself when + declared within a Shiny inline-style application. In the case of + :func:`~shiny.render.plot`, the :func:`~shiny.ui.output_plot` function is called + implicitly to display the plot. Use the `@output_args` decorator to specify + arguments to be passed to `output_plot` (or whatever the corresponding UI function + is) when the render function displays itself. + + Parameters + ---------- + *args + Positional arguments to be passed to the UI function. + **kwargs + Keyword arguments to be passed to the UI function. + + Returns + ------- + : + A decorator that sets the default UI arguments for a Shiny rendering function. + """ + + def wrapper(renderer: OutputRenderer[OT]) -> OutputRenderer[OT]: + renderer.default_ui_args = args + renderer.default_ui_kwargs = kwargs + return renderer + + return wrapper + + +@overload +def suspend_display(fn: CallableT) -> CallableT: + ... + + +@overload +def suspend_display() -> AbstractContextManager[None]: + ... + + +def suspend_display( + fn: Callable[P, R] | OutputRenderer[OT] | None = None +) -> Callable[P, R] | OutputRenderer[OT] | AbstractContextManager[None]: + """Suppresses the display of UI elements in various ways. + + If used as a context manager (`with suspend_display():`), it suppresses the display + of all UI elements within the context block. (This is useful when you want to + temporarily suppress the display of a large number of UI elements, or when you want + to suppress the display of UI elements that are not directly under your control.) + + If used as a decorator (without parentheses) on a Shiny rendering function, it + prevents that function from automatically outputting itself at the point of its + declaration. (This is useful when you want to define the rendering logic for an + output, but want to explicitly call a UI output function to indicate where and how + it should be displayed.) + + If used as a decorator (without parentheses) on any other function, it turns + Python's `sys.displayhook` into a no-op for the duration of the function call. + + Parameters + ---------- + fn + The function to decorate. If `None`, returns a context manager that suppresses + the display of UI elements within the context block. + + Returns + ------- + : + If `fn` is `None`, returns a context manager that suppresses the display of UI + elements within the context block. Otherwise, returns a decorated version of + `fn`. + """ + + if fn is None: + return suspend_display_ctxmgr() + + # Special case for OutputRenderer; when we decorate those, we just mean "don't + # display yourself" + if isinstance(fn, OutputRenderer): + fn.default_ui = null_ui + return cast(Callable[P, R], fn) + + return suspend_display_ctxmgr()(fn) + + +@contextlib.contextmanager +def suspend_display_ctxmgr(): + oldhook = sys.displayhook + sys.displayhook = null_displayhook + try: + yield + finally: + sys.displayhook = oldhook + + +def null_ui(id: str, *args: object, **kwargs: object) -> ui.TagList: + return ui.TagList() + + +def null_displayhook(x: object) -> None: + pass diff --git a/shiny/render/transformer/_transformer.py b/shiny/render/transformer/_transformer.py index eb81f72e6..be736ec94 100644 --- a/shiny/render/transformer/_transformer.py +++ b/shiny/render/transformer/_transformer.py @@ -230,6 +230,7 @@ def __init__( transform_fn: TransformFn[IT, P, OT], params: TransformerParams[P], default_ui: Optional[DefaultUIFnImpl] = None, + default_ui_passthrough_args: Optional[tuple[str, ...]] = None, ) -> None: """ Parameters @@ -264,6 +265,10 @@ def __init__( self._transformer = transform_fn self._params = params self.default_ui = default_ui + self.default_ui_passthrough_args = default_ui_passthrough_args + self.default_ui_args: tuple[object, ...] = tuple() + self.default_ui_kwargs: dict[str, object] = dict() + self._auto_registered = False from ...session import get_current_session @@ -340,11 +345,21 @@ def _render_default(self) -> TagList | Tag | MetadataNode | str: if self.default_ui is None: raise TypeError("No default UI exists for this type of render function") - params = tuple(inspect.signature(self.default_ui).parameters.values()) - if len(params) > 0 and params[0].name == "_params": - return self.default_ui(self._params.kwargs, self.__name__) # type: ignore - else: - return cast(DefaultUIFn, self.default_ui)(self.__name__) + # Merge the kwargs from the render function passthrough, with the kwargs from + # explicit @output_args call. The latter take priority. + kwargs: dict[str, object] = dict() + if self.default_ui_passthrough_args is not None: + kwargs.update( + { + k: v + for k, v in self._params.kwargs.items() + if k in self.default_ui_passthrough_args + } + ) + kwargs.update(self.default_ui_kwargs) + return cast(DefaultUIFn, self.default_ui)( + self.__name__, *self.default_ui_args, **kwargs + ) # Using a second class to help clarify that it is of a particular type @@ -367,6 +382,7 @@ def __init__( transform_fn: TransformFn[IT, P, OT], params: TransformerParams[P], default_ui: Optional[DefaultUIFnImpl] = None, + default_ui_passthrough_args: Optional[tuple[str, ...]] = None, ) -> None: if is_async_callable(value_fn): raise TypeError( @@ -378,6 +394,7 @@ def __init__( transform_fn=transform_fn, params=params, default_ui=default_ui, + default_ui_passthrough_args=default_ui_passthrough_args, ) def __call__(self) -> OT: @@ -409,6 +426,7 @@ def __init__( transform_fn: TransformFn[IT, P, OT], params: TransformerParams[P], default_ui: Optional[DefaultUIFnImpl] = None, + default_ui_passthrough_args: Optional[tuple[str, ...]] = None, ) -> None: if not is_async_callable(value_fn): raise TypeError( @@ -420,6 +438,7 @@ def __init__( transform_fn=transform_fn, params=params, default_ui=default_ui, + default_ui_passthrough_args=default_ui_passthrough_args, ) async def __call__(self) -> OT: # pyright: ignore[reportIncompatibleMethodOverride] @@ -688,17 +707,6 @@ def output_transformer( # If default_ui_passthrough_args was used, modify the default_ui function so it is # ready to mix in extra arguments from the decorator. - if ( - default_ui is not None - and default_ui_passthrough_args is not None - and len(default_ui_passthrough_args) > 0 - ): - default_ui_impl = decorator_args_passthrough( - default_ui, default_ui_passthrough_args - ) - else: - default_ui_impl = default_ui - def output_transformer_impl( transform_fn: TransformFn[IT, P, OT], ) -> OutputTransformer[IT, OT, P]: @@ -713,12 +721,22 @@ def as_value_fn( ) -> OutputRenderer[OT]: if is_async_callable(fn): return OutputRendererAsync( - fn, transform_fn, params, default_ui_impl + fn, + transform_fn, + params, + default_ui, + default_ui_passthrough_args, ) else: # To avoid duplicate work just for a typeguard, we cast the function fn = cast(ValueFnSync[IT], fn) - return OutputRendererSync(fn, transform_fn, params, default_ui_impl) + return OutputRendererSync( + fn, + transform_fn, + params, + default_ui, + default_ui_passthrough_args, + ) if value_fn is None: return as_value_fn diff --git a/tests/pytest/test_express_ui.py b/tests/pytest/test_express_ui.py new file mode 100644 index 000000000..ed6a7b91d --- /dev/null +++ b/tests/pytest/test_express_ui.py @@ -0,0 +1,73 @@ +import sys +from typing import Any + +import pytest + +from shiny import render, ui +from shiny.express import output_args, suspend_display + + +def test_render_output_controls(): + @render.text + def text1(): + return "text" + + assert ( + ui.TagList(text1.tagify()).get_html_string() + == ui.output_text_verbatim("text1").get_html_string() + ) + + @suspend_display + @render.text + def text2(): + return "text" + + assert ui.TagList(text2.tagify()).get_html_string() == "" + + @output_args(placeholder=True) + @render.text + def text3(): + return "text" + + assert ( + ui.TagList(text3.tagify()).get_html_string() + == ui.output_text_verbatim("text3", placeholder=True).get_html_string() + ) + + @output_args(width=100) + @render.text + def text4(): + return "text" + + with pytest.raises(TypeError, match="width"): + text4.tagify() + + +def test_suspend_display(): + old_displayhook = sys.displayhook + try: + called = False + + def display_hook_spy(_): + nonlocal called + called = True + + sys.displayhook = display_hook_spy + + with suspend_display(): + sys.displayhook("foo") + suspend_display(lambda: sys.displayhook("bar"))() + + @suspend_display + def whatever(x: Any): + sys.displayhook(x) + + whatever(100) + + assert not called + + sys.displayhook("baz") + assert called + + finally: + sys.displayhook = old_displayhook