Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions shiny/express/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from __future__ import annotations

from ._output import output_args, suspend_display

__all__ = (
"output_args",
"suspend_display",
)
126 changes: 126 additions & 0 deletions shiny/express/_output.py
Original file line number Diff line number Diff line change
@@ -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
54 changes: 36 additions & 18 deletions shiny/render/transformer/_transformer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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(
Expand All @@ -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:
Expand Down Expand Up @@ -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(
Expand All @@ -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]
Expand Down Expand Up @@ -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]:
Expand All @@ -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
Expand Down
73 changes: 73 additions & 0 deletions tests/pytest/test_express_ui.py
Original file line number Diff line number Diff line change
@@ -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