Skip to content

Commit e92e1e9

Browse files
jcheng5wch
andauthored
Add output_args and suspend_display decorators (#786)
Co-authored-by: Winston Chang <[email protected]>
1 parent 70d37ed commit e92e1e9

File tree

4 files changed

+243
-18
lines changed

4 files changed

+243
-18
lines changed

shiny/express/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from __future__ import annotations
2+
3+
from ._output import output_args, suspend_display
4+
5+
__all__ = (
6+
"output_args",
7+
"suspend_display",
8+
)

shiny/express/_output.py

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
from __future__ import annotations
2+
3+
import contextlib
4+
import sys
5+
from contextlib import AbstractContextManager
6+
from typing import Callable, TypeVar, cast, overload
7+
8+
from .. import ui
9+
from .._typing_extensions import ParamSpec
10+
from ..render.transformer import OutputRenderer
11+
12+
__all__ = (
13+
"output_args",
14+
"suspend_display",
15+
)
16+
17+
OT = TypeVar("OT")
18+
P = ParamSpec("P")
19+
R = TypeVar("R")
20+
CallableT = TypeVar("CallableT", bound=Callable[..., object])
21+
22+
23+
def output_args(
24+
*args: object, **kwargs: object
25+
) -> Callable[[OutputRenderer[OT]], OutputRenderer[OT]]:
26+
"""Sets default UI arguments for a Shiny rendering function.
27+
28+
Each Shiny render function (like :func:`~shiny.render.plot`) can display itself when
29+
declared within a Shiny inline-style application. In the case of
30+
:func:`~shiny.render.plot`, the :func:`~shiny.ui.output_plot` function is called
31+
implicitly to display the plot. Use the `@output_args` decorator to specify
32+
arguments to be passed to `output_plot` (or whatever the corresponding UI function
33+
is) when the render function displays itself.
34+
35+
Parameters
36+
----------
37+
*args
38+
Positional arguments to be passed to the UI function.
39+
**kwargs
40+
Keyword arguments to be passed to the UI function.
41+
42+
Returns
43+
-------
44+
:
45+
A decorator that sets the default UI arguments for a Shiny rendering function.
46+
"""
47+
48+
def wrapper(renderer: OutputRenderer[OT]) -> OutputRenderer[OT]:
49+
renderer.default_ui_args = args
50+
renderer.default_ui_kwargs = kwargs
51+
return renderer
52+
53+
return wrapper
54+
55+
56+
@overload
57+
def suspend_display(fn: CallableT) -> CallableT:
58+
...
59+
60+
61+
@overload
62+
def suspend_display() -> AbstractContextManager[None]:
63+
...
64+
65+
66+
def suspend_display(
67+
fn: Callable[P, R] | OutputRenderer[OT] | None = None
68+
) -> Callable[P, R] | OutputRenderer[OT] | AbstractContextManager[None]:
69+
"""Suppresses the display of UI elements in various ways.
70+
71+
If used as a context manager (`with suspend_display():`), it suppresses the display
72+
of all UI elements within the context block. (This is useful when you want to
73+
temporarily suppress the display of a large number of UI elements, or when you want
74+
to suppress the display of UI elements that are not directly under your control.)
75+
76+
If used as a decorator (without parentheses) on a Shiny rendering function, it
77+
prevents that function from automatically outputting itself at the point of its
78+
declaration. (This is useful when you want to define the rendering logic for an
79+
output, but want to explicitly call a UI output function to indicate where and how
80+
it should be displayed.)
81+
82+
If used as a decorator (without parentheses) on any other function, it turns
83+
Python's `sys.displayhook` into a no-op for the duration of the function call.
84+
85+
Parameters
86+
----------
87+
fn
88+
The function to decorate. If `None`, returns a context manager that suppresses
89+
the display of UI elements within the context block.
90+
91+
Returns
92+
-------
93+
:
94+
If `fn` is `None`, returns a context manager that suppresses the display of UI
95+
elements within the context block. Otherwise, returns a decorated version of
96+
`fn`.
97+
"""
98+
99+
if fn is None:
100+
return suspend_display_ctxmgr()
101+
102+
# Special case for OutputRenderer; when we decorate those, we just mean "don't
103+
# display yourself"
104+
if isinstance(fn, OutputRenderer):
105+
fn.default_ui = null_ui
106+
return cast(Callable[P, R], fn)
107+
108+
return suspend_display_ctxmgr()(fn)
109+
110+
111+
@contextlib.contextmanager
112+
def suspend_display_ctxmgr():
113+
oldhook = sys.displayhook
114+
sys.displayhook = null_displayhook
115+
try:
116+
yield
117+
finally:
118+
sys.displayhook = oldhook
119+
120+
121+
def null_ui(id: str, *args: object, **kwargs: object) -> ui.TagList:
122+
return ui.TagList()
123+
124+
125+
def null_displayhook(x: object) -> None:
126+
pass

shiny/render/transformer/_transformer.py

Lines changed: 36 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,7 @@ def __init__(
230230
transform_fn: TransformFn[IT, P, OT],
231231
params: TransformerParams[P],
232232
default_ui: Optional[DefaultUIFnImpl] = None,
233+
default_ui_passthrough_args: Optional[tuple[str, ...]] = None,
233234
) -> None:
234235
"""
235236
Parameters
@@ -264,6 +265,10 @@ def __init__(
264265
self._transformer = transform_fn
265266
self._params = params
266267
self.default_ui = default_ui
268+
self.default_ui_passthrough_args = default_ui_passthrough_args
269+
self.default_ui_args: tuple[object, ...] = tuple()
270+
self.default_ui_kwargs: dict[str, object] = dict()
271+
267272
self._auto_registered = False
268273

269274
from ...session import get_current_session
@@ -340,11 +345,21 @@ def _render_default(self) -> TagList | Tag | MetadataNode | str:
340345
if self.default_ui is None:
341346
raise TypeError("No default UI exists for this type of render function")
342347

343-
params = tuple(inspect.signature(self.default_ui).parameters.values())
344-
if len(params) > 0 and params[0].name == "_params":
345-
return self.default_ui(self._params.kwargs, self.__name__) # type: ignore
346-
else:
347-
return cast(DefaultUIFn, self.default_ui)(self.__name__)
348+
# Merge the kwargs from the render function passthrough, with the kwargs from
349+
# explicit @output_args call. The latter take priority.
350+
kwargs: dict[str, object] = dict()
351+
if self.default_ui_passthrough_args is not None:
352+
kwargs.update(
353+
{
354+
k: v
355+
for k, v in self._params.kwargs.items()
356+
if k in self.default_ui_passthrough_args
357+
}
358+
)
359+
kwargs.update(self.default_ui_kwargs)
360+
return cast(DefaultUIFn, self.default_ui)(
361+
self.__name__, *self.default_ui_args, **kwargs
362+
)
348363

349364

350365
# Using a second class to help clarify that it is of a particular type
@@ -367,6 +382,7 @@ def __init__(
367382
transform_fn: TransformFn[IT, P, OT],
368383
params: TransformerParams[P],
369384
default_ui: Optional[DefaultUIFnImpl] = None,
385+
default_ui_passthrough_args: Optional[tuple[str, ...]] = None,
370386
) -> None:
371387
if is_async_callable(value_fn):
372388
raise TypeError(
@@ -378,6 +394,7 @@ def __init__(
378394
transform_fn=transform_fn,
379395
params=params,
380396
default_ui=default_ui,
397+
default_ui_passthrough_args=default_ui_passthrough_args,
381398
)
382399

383400
def __call__(self) -> OT:
@@ -409,6 +426,7 @@ def __init__(
409426
transform_fn: TransformFn[IT, P, OT],
410427
params: TransformerParams[P],
411428
default_ui: Optional[DefaultUIFnImpl] = None,
429+
default_ui_passthrough_args: Optional[tuple[str, ...]] = None,
412430
) -> None:
413431
if not is_async_callable(value_fn):
414432
raise TypeError(
@@ -420,6 +438,7 @@ def __init__(
420438
transform_fn=transform_fn,
421439
params=params,
422440
default_ui=default_ui,
441+
default_ui_passthrough_args=default_ui_passthrough_args,
423442
)
424443

425444
async def __call__(self) -> OT: # pyright: ignore[reportIncompatibleMethodOverride]
@@ -688,17 +707,6 @@ def output_transformer(
688707

689708
# If default_ui_passthrough_args was used, modify the default_ui function so it is
690709
# ready to mix in extra arguments from the decorator.
691-
if (
692-
default_ui is not None
693-
and default_ui_passthrough_args is not None
694-
and len(default_ui_passthrough_args) > 0
695-
):
696-
default_ui_impl = decorator_args_passthrough(
697-
default_ui, default_ui_passthrough_args
698-
)
699-
else:
700-
default_ui_impl = default_ui
701-
702710
def output_transformer_impl(
703711
transform_fn: TransformFn[IT, P, OT],
704712
) -> OutputTransformer[IT, OT, P]:
@@ -713,12 +721,22 @@ def as_value_fn(
713721
) -> OutputRenderer[OT]:
714722
if is_async_callable(fn):
715723
return OutputRendererAsync(
716-
fn, transform_fn, params, default_ui_impl
724+
fn,
725+
transform_fn,
726+
params,
727+
default_ui,
728+
default_ui_passthrough_args,
717729
)
718730
else:
719731
# To avoid duplicate work just for a typeguard, we cast the function
720732
fn = cast(ValueFnSync[IT], fn)
721-
return OutputRendererSync(fn, transform_fn, params, default_ui_impl)
733+
return OutputRendererSync(
734+
fn,
735+
transform_fn,
736+
params,
737+
default_ui,
738+
default_ui_passthrough_args,
739+
)
722740

723741
if value_fn is None:
724742
return as_value_fn

tests/pytest/test_express_ui.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import sys
2+
from typing import Any
3+
4+
import pytest
5+
6+
from shiny import render, ui
7+
from shiny.express import output_args, suspend_display
8+
9+
10+
def test_render_output_controls():
11+
@render.text
12+
def text1():
13+
return "text"
14+
15+
assert (
16+
ui.TagList(text1.tagify()).get_html_string()
17+
== ui.output_text_verbatim("text1").get_html_string()
18+
)
19+
20+
@suspend_display
21+
@render.text
22+
def text2():
23+
return "text"
24+
25+
assert ui.TagList(text2.tagify()).get_html_string() == ""
26+
27+
@output_args(placeholder=True)
28+
@render.text
29+
def text3():
30+
return "text"
31+
32+
assert (
33+
ui.TagList(text3.tagify()).get_html_string()
34+
== ui.output_text_verbatim("text3", placeholder=True).get_html_string()
35+
)
36+
37+
@output_args(width=100)
38+
@render.text
39+
def text4():
40+
return "text"
41+
42+
with pytest.raises(TypeError, match="width"):
43+
text4.tagify()
44+
45+
46+
def test_suspend_display():
47+
old_displayhook = sys.displayhook
48+
try:
49+
called = False
50+
51+
def display_hook_spy(_):
52+
nonlocal called
53+
called = True
54+
55+
sys.displayhook = display_hook_spy
56+
57+
with suspend_display():
58+
sys.displayhook("foo")
59+
suspend_display(lambda: sys.displayhook("bar"))()
60+
61+
@suspend_display
62+
def whatever(x: Any):
63+
sys.displayhook(x)
64+
65+
whatever(100)
66+
67+
assert not called
68+
69+
sys.displayhook("baz")
70+
assert called
71+
72+
finally:
73+
sys.displayhook = old_displayhook

0 commit comments

Comments
 (0)