From 82097a02de10bfe48a1a6fe8b7ebe4244d92ca42 Mon Sep 17 00:00:00 2001 From: Joe Cheng Date: Thu, 26 Oct 2023 15:40:45 -0700 Subject: [PATCH 1/6] Add output_args and suspend_display decorators --- shiny/express/__init__.py | 8 ++ shiny/express/_output.py | 129 +++++++++++++++++++++++ shiny/render/transformer/_transformer.py | 13 ++- tests/pytest/test_express_ui.py | 40 +++++++ 4 files changed, 188 insertions(+), 2 deletions(-) create mode 100644 shiny/express/__init__.py create mode 100644 shiny/express/_output.py create mode 100644 tests/pytest/test_express_ui.py 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..f12eb5ad5 --- /dev/null +++ b/shiny/express/_output.py @@ -0,0 +1,129 @@ +from __future__ import annotations + +import contextlib +import sys +from contextlib import AbstractContextManager +from typing import Callable, ParamSpec, TypeVar, cast, overload + +from .. import ui +from ..render.transformer import OutputRenderer + +__all__ = ( + "output_args", + "suspend_display", +) + +OT = TypeVar("OT") +P = ParamSpec("P") +R = TypeVar("R") + + +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: OutputRenderer[OT]) -> OutputRenderer[OT]: + ... + + +@overload +def suspend_display(fn: Callable[P, R]) -> Callable[P, R]: + ... + + +@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..6655f5f15 100644 --- a/shiny/render/transformer/_transformer.py +++ b/shiny/render/transformer/_transformer.py @@ -264,6 +264,8 @@ def __init__( self._transformer = transform_fn self._params = params self.default_ui = default_ui + self.default_ui_args: tuple[object, ...] = [] + self.default_ui_kwargs: dict[str, object] = dict() self._auto_registered = False from ...session import get_current_session @@ -342,9 +344,16 @@ def _render_default(self) -> TagList | Tag | MetadataNode | str: 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 + return self.default_ui( + self._params.kwargs, + self.__name__, # pyright: ignore[reportGeneralTypeIssues] + *self.default_ui_args, + **self.default_ui_kwargs, + ) else: - return cast(DefaultUIFn, self.default_ui)(self.__name__) + return cast(DefaultUIFn, self.default_ui)( + self.__name__, *self.default_ui_args, **self.default_ui_kwargs + ) # Using a second class to help clarify that it is of a particular type diff --git a/tests/pytest/test_express_ui.py b/tests/pytest/test_express_ui.py new file mode 100644 index 000000000..4948e62d6 --- /dev/null +++ b/tests/pytest/test_express_ui.py @@ -0,0 +1,40 @@ +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() From 8cbcb74f47da8ec3f64db138845ed9088e189ff2 Mon Sep 17 00:00:00 2001 From: Joe Cheng Date: Thu, 26 Oct 2023 15:44:18 -0700 Subject: [PATCH 2/6] Add unit tests for other usages of suspend_display --- tests/pytest/test_express_ui.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/pytest/test_express_ui.py b/tests/pytest/test_express_ui.py index 4948e62d6..df99f2af1 100644 --- a/tests/pytest/test_express_ui.py +++ b/tests/pytest/test_express_ui.py @@ -1,3 +1,5 @@ +import sys + import pytest from shiny import render, ui @@ -38,3 +40,27 @@ def text4(): 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"))() + + assert not called + + sys.displayhook("baz") + assert called + + finally: + sys.displayhook = old_displayhook From 7aeac1b6bd07a0ee3ff2ff5ecddb1085d9aff20b Mon Sep 17 00:00:00 2001 From: Joe Cheng Date: Thu, 26 Oct 2023 16:13:58 -0700 Subject: [PATCH 3/6] Fix typing problems --- shiny/express/_output.py | 8 ++------ shiny/render/transformer/_transformer.py | 2 +- tests/pytest/test_express_ui.py | 7 +++++++ 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/shiny/express/_output.py b/shiny/express/_output.py index f12eb5ad5..da72b25f7 100644 --- a/shiny/express/_output.py +++ b/shiny/express/_output.py @@ -16,6 +16,7 @@ OT = TypeVar("OT") P = ParamSpec("P") R = TypeVar("R") +CallableT = TypeVar("CallableT", bound=Callable[..., object]) def output_args( @@ -52,12 +53,7 @@ def wrapper(renderer: OutputRenderer[OT]) -> OutputRenderer[OT]: @overload -def suspend_display(fn: OutputRenderer[OT]) -> OutputRenderer[OT]: - ... - - -@overload -def suspend_display(fn: Callable[P, R]) -> Callable[P, R]: +def suspend_display(fn: CallableT) -> CallableT: ... diff --git a/shiny/render/transformer/_transformer.py b/shiny/render/transformer/_transformer.py index 6655f5f15..45d6b6836 100644 --- a/shiny/render/transformer/_transformer.py +++ b/shiny/render/transformer/_transformer.py @@ -264,7 +264,7 @@ def __init__( self._transformer = transform_fn self._params = params self.default_ui = default_ui - self.default_ui_args: tuple[object, ...] = [] + self.default_ui_args: tuple[object, ...] = cast(tuple[object, ...], []) self.default_ui_kwargs: dict[str, object] = dict() self._auto_registered = False diff --git a/tests/pytest/test_express_ui.py b/tests/pytest/test_express_ui.py index df99f2af1..ed6a7b91d 100644 --- a/tests/pytest/test_express_ui.py +++ b/tests/pytest/test_express_ui.py @@ -1,4 +1,5 @@ import sys +from typing import Any import pytest @@ -57,6 +58,12 @@ def display_hook_spy(_): 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") From 3793ca2a19a09a4ee1c313f414959639ecfc1d6a Mon Sep 17 00:00:00 2001 From: Joe Cheng Date: Thu, 26 Oct 2023 16:39:34 -0700 Subject: [PATCH 4/6] Use correct type Co-authored-by: Winston Chang --- shiny/render/transformer/_transformer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shiny/render/transformer/_transformer.py b/shiny/render/transformer/_transformer.py index 45d6b6836..ad0f87675 100644 --- a/shiny/render/transformer/_transformer.py +++ b/shiny/render/transformer/_transformer.py @@ -264,7 +264,7 @@ def __init__( self._transformer = transform_fn self._params = params self.default_ui = default_ui - self.default_ui_args: tuple[object, ...] = cast(tuple[object, ...], []) + self.default_ui_args: tuple[object, ...] = tuple() self.default_ui_kwargs: dict[str, object] = dict() self._auto_registered = False From 75658c06df033e5ad823d581e8335c7561b46731 Mon Sep 17 00:00:00 2001 From: Joe Cheng Date: Thu, 26 Oct 2023 16:52:31 -0700 Subject: [PATCH 5/6] Greatly simplify passthrough arg implementation --- shiny/render/transformer/_transformer.py | 57 ++++++++++++++---------- 1 file changed, 33 insertions(+), 24 deletions(-) diff --git a/shiny/render/transformer/_transformer.py b/shiny/render/transformer/_transformer.py index ad0f87675..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,8 +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 @@ -342,18 +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__, # pyright: ignore[reportGeneralTypeIssues] - *self.default_ui_args, - **self.default_ui_kwargs, - ) - else: - return cast(DefaultUIFn, self.default_ui)( - self.__name__, *self.default_ui_args, **self.default_ui_kwargs + # 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 @@ -376,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( @@ -387,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: @@ -418,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( @@ -429,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] @@ -697,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]: @@ -722,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 From 786f0cce924cd101f207cb8f25125d33527bf210 Mon Sep 17 00:00:00 2001 From: Joe Cheng Date: Thu, 26 Oct 2023 16:57:20 -0700 Subject: [PATCH 6/6] Fix pyright under older Python versions --- shiny/express/_output.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/shiny/express/_output.py b/shiny/express/_output.py index da72b25f7..8733b07ec 100644 --- a/shiny/express/_output.py +++ b/shiny/express/_output.py @@ -3,9 +3,10 @@ import contextlib import sys from contextlib import AbstractContextManager -from typing import Callable, ParamSpec, TypeVar, cast, overload +from typing import Callable, TypeVar, cast, overload from .. import ui +from .._typing_extensions import ParamSpec from ..render.transformer import OutputRenderer __all__ = (