diff --git a/CHANGELOG.md b/CHANGELOG.md index 7402eeea3..eda448fd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [UNRELEASED] + +### New features + +* Added `shiny.render.renderer_components` decorator to help create new output renderers. (#621) + +### Bug fixes + +### Other changes + + ## [0.5.1] - 2023-08-08 ### Bug fixes diff --git a/docs/_quartodoc.yml b/docs/_quartodoc.yml index 77e5d0a15..aaa17d8e1 100644 --- a/docs/_quartodoc.yml +++ b/docs/_quartodoc.yml @@ -125,6 +125,24 @@ quartodoc: - render.data_frame - render.DataGrid - render.DataTable + - kind: page + path: OutputRender + flatten: true + summary: + name: "Create rendering outputs" + desc: "" + contents: + - render.transformer.output_transformer + - render.transformer.OutputTransformer + - render.transformer.TransformerMetadata + - render.transformer.TransformerParams + - render.transformer.OutputRenderer + - render.transformer.OutputRendererSync + - render.transformer.OutputRendererAsync + - render.transformer.is_async_callable + - render.transformer.resolve_value_fn + - render.transformer.ValueFn + - render.transformer.TransformFn - title: Reactive programming desc: "" contents: @@ -178,7 +196,7 @@ quartodoc: desc: "" contents: - kind: page - path: MiscTypes.html + path: MiscTypes flatten: true summary: name: "Miscellaneous types" @@ -192,7 +210,7 @@ quartodoc: - ui._input_slider.SliderValueArg - ui._input_slider.SliderStepArg - kind: page - path: TagTypes.html + path: TagTypes summary: name: "Tag types" desc: "" @@ -205,7 +223,7 @@ quartodoc: - htmltools.TagChild - htmltools.TagList - kind: page - path: ExceptionTypes.html + path: ExceptionTypes summary: name: "Exception types" desc: "" diff --git a/e2e/inputs/test_input_checkbox.py b/e2e/inputs/test_input_checkbox.py index ff7efba4b..272036851 100644 --- a/e2e/inputs/test_input_checkbox.py +++ b/e2e/inputs/test_input_checkbox.py @@ -16,7 +16,7 @@ def test_input_checkbox_kitchen(page: Page, app: ShinyAppProc) -> None: somevalue.expect_checked(False) somevalue.expect_width(None) - # TODO-karan test output value + # TODO-karan: test output value somevalue.set(True) @@ -28,4 +28,4 @@ def test_input_checkbox_kitchen(page: Page, app: ShinyAppProc) -> None: somevalue.toggle() somevalue.expect_checked(True) - # TODO-karan test output value + # TODO-karan: test output value diff --git a/e2e/server/output_transformer/app.py b/e2e/server/output_transformer/app.py new file mode 100644 index 000000000..0c39cb48b --- /dev/null +++ b/e2e/server/output_transformer/app.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +from typing import Optional, overload + +from shiny import App, Inputs, Outputs, Session, ui +from shiny.render.transformer import ( + TransformerMetadata, + ValueFn, + is_async_callable, + output_transformer, + resolve_value_fn, +) + + +@output_transformer +async def TestTextTransformer( + _meta: TransformerMetadata, + _fn: ValueFn[str | None], + *, + extra_txt: Optional[str] = None, +) -> str | None: + value = await resolve_value_fn(_fn) + value = str(value) + value += "; " + value += "async" if is_async_callable(_fn) else "sync" + if extra_txt: + value = value + "; " + str(extra_txt) + return value + + +@overload +def render_test_text( + *, extra_txt: Optional[str] = None +) -> TestTextTransformer.OutputRendererDecorator: + ... + + +@overload +def render_test_text( + _fn: TestTextTransformer.ValueFn, +) -> TestTextTransformer.OutputRenderer: + ... + + +def render_test_text( + _fn: TestTextTransformer.ValueFn | None = None, + *, + extra_txt: Optional[str] = None, +) -> TestTextTransformer.OutputRenderer | TestTextTransformer.OutputRendererDecorator: + return TestTextTransformer( + _fn, + TestTextTransformer.params(extra_txt=extra_txt), + ) + + +app_ui = ui.page_fluid( + ui.code("t1:"), + ui.output_text_verbatim("t1"), + ui.code("t2:"), + ui.output_text_verbatim("t2"), + ui.code("t3:"), + ui.output_text_verbatim("t3"), + ui.code("t4:"), + ui.output_text_verbatim("t4"), + ui.code("t5:"), + ui.output_text_verbatim("t5"), + ui.code("t6:"), + ui.output_text_verbatim("t6"), +) + + +def server(input: Inputs, output: Outputs, session: Session): + @output + @render_test_text + def t1(): + return "t1; no call" + # return "hello" + + @output + @render_test_text + async def t2(): + return "t2; no call" + + @output + @render_test_text() + def t3(): + return "t3; call" + + @output + @render_test_text() + async def t4(): + return "t4; call" + + @output + @render_test_text(extra_txt="w/ extra_txt") + def t5(): + return "t5; call" + + @output + @render_test_text(extra_txt="w/ extra_txt") + async def t6(): + return "t6; call" + + +app = App(app_ui, server) diff --git a/e2e/server/output_transformer/test_output_transformer.py b/e2e/server/output_transformer/test_output_transformer.py new file mode 100644 index 000000000..e2c1fc5f7 --- /dev/null +++ b/e2e/server/output_transformer/test_output_transformer.py @@ -0,0 +1,14 @@ +from conftest import ShinyAppProc +from controls import OutputTextVerbatim +from playwright.sync_api import Page + + +def test_output_image_kitchen(page: Page, local_app: ShinyAppProc) -> None: + page.goto(local_app.url) + + OutputTextVerbatim(page, "t1").expect_value("t1; no call; sync") + OutputTextVerbatim(page, "t2").expect_value("t2; no call; async") + OutputTextVerbatim(page, "t3").expect_value("t3; call; sync") + OutputTextVerbatim(page, "t4").expect_value("t4; call; async") + OutputTextVerbatim(page, "t5").expect_value("t5; call; sync; w/ extra_txt") + OutputTextVerbatim(page, "t6").expect_value("t6; call; async; w/ extra_txt") diff --git a/examples/event/app.py b/examples/event/app.py index 412c25b3b..c96f7ca85 100644 --- a/examples/event/app.py +++ b/examples/event/app.py @@ -41,7 +41,6 @@ def btn() -> int: def _(): print("@calc() event: ", str(btn())) - @output @render.ui @reactive.event(input.btn) def btn_value(): diff --git a/shiny/__init__.py b/shiny/__init__.py index cd5d98004..12c95b494 100644 --- a/shiny/__init__.py +++ b/shiny/__init__.py @@ -1,6 +1,6 @@ """A package for building reactive web applications.""" -__version__ = "0.5.1" +__version__ = "0.5.1.9000" from ._shinyenv import is_pyodide as _is_pyodide diff --git a/shiny/_deprecated.py b/shiny/_deprecated.py index bb00d4e92..f6be2e40b 100644 --- a/shiny/_deprecated.py +++ b/shiny/_deprecated.py @@ -39,16 +39,16 @@ def render_ui(): return render.ui() -def render_plot(*args: Any, **kwargs: Any): +def render_plot(*args: Any, **kwargs: Any): # type: ignore """Deprecated. Please use render.plot() instead of render_plot().""" warn_deprecated("render_plot() is deprecated. Use render.plot() instead.") - return render.plot(*args, **kwargs) + return render.plot(*args, **kwargs) # type: ignore -def render_image(*args: Any, **kwargs: Any): +def render_image(*args: Any, **kwargs: Any): # type: ignore """Deprecated. Please use render.image() instead of render_image().""" warn_deprecated("render_image() is deprecated. Use render.image() instead.") - return render.image(*args, **kwargs) + return render.image(*args, **kwargs) # type: ignore def event(*args: Any, **kwargs: Any): diff --git a/shiny/_utils.py b/shiny/_utils.py index 5c471143f..2ec1ebc16 100644 --- a/shiny/_utils.py +++ b/shiny/_utils.py @@ -245,13 +245,23 @@ async def fn_async(*args: P.args, **kwargs: P.kwargs) -> T: return fn_async +# This function should generally be used in this code base instead of +# `iscoroutinefunction()`. def is_async_callable( obj: Callable[P, T] | Callable[P, Awaitable[T]] ) -> TypeGuard[Callable[P, Awaitable[T]]]: """ - Returns True if `obj` is an `async def` function, or if it's an object with a - `__call__` method which is an `async def` function. This function should generally - be used in this code base instead of iscoroutinefunction(). + Determine if an object is an async function. + + This is a more general version of `inspect.iscoroutinefunction()`, which only works + on functions. This function works on any object that has a `__call__` method, such + as a class instance. + + Returns + ------- + : + Returns True if `obj` is an `async def` function, or if it's an object with a + `__call__` method which is an `async def` function. """ if inspect.iscoroutinefunction(obj): return True @@ -262,6 +272,12 @@ def is_async_callable( return False +# def not_is_async_callable( +# obj: Callable[P, T] | Callable[P, Awaitable[T]] +# ) -> TypeGuard[Callable[P, T]]: +# return not is_async_callable(obj) + + # See https://stackoverflow.com/a/59780868/412655 for an excellent explanation # of how this stuff works. # For a more in-depth explanation, see diff --git a/shiny/api-examples/output_transformer/app.py b/shiny/api-examples/output_transformer/app.py new file mode 100644 index 000000000..1c92b0830 --- /dev/null +++ b/shiny/api-examples/output_transformer/app.py @@ -0,0 +1,140 @@ +from __future__ import annotations + +from typing import Literal, overload + +from shiny import App, Inputs, Outputs, Session, ui +from shiny.render.transformer import ( + TransformerMetadata, + ValueFn, + output_transformer, + resolve_value_fn, +) + +####### +# Package authors can create their own output transformer methods by leveraging +# `output_transformer` decorator. +# +# The transformer is kept simple for demonstration purposes, but it can be much more +# complex (e.g. shiny.render.plotly) +####### + + +# Create renderer components from the async handler function: `capitalize_components()` +@output_transformer +async def CapitalizeTransformer( + # Contains information about the render call: `name` and `session` + _meta: TransformerMetadata, + # The app-supplied output value function + _fn: ValueFn[str | None], + *, + # Extra parameters that app authors can supply to the render decorator + # (e.g. `@render_capitalize(to="upper")`) + to: Literal["upper", "lower"] = "upper", +) -> str | None: + # Get the value + value = await resolve_value_fn(_fn) + # Equvalent to: + # if shiny.render.transformer.is_async_callable(_fn): + # value = await _fn() + # else: + # value = _fn() + + # Render nothing if `value` is `None` + if value is None: + return None + + if to == "upper": + return value.upper() + if to == "lower": + return value.lower() + raise ValueError(f"Invalid value for `to`: {to}") + + +# First, create an overload where users can supply the extra parameters. +# Example of usage: +# ``` +# @output +# @render_capitalize(to="upper") +# def value(): +# return input.caption() +# ``` +# Note: Return type is `OutputRendererDecorator` +@overload +def render_capitalize( + *, + to: Literal["upper", "lower"] = "upper", +) -> CapitalizeTransformer.OutputRendererDecorator: + ... + + +# Second, create an overload where users are not using parentheses to the method. +# While it doesn't look necessary, it is needed for the type checker. +# Example of usage: +# ``` +# @output +# @render_capitalize +# def value(): +# return input.caption() +# ``` +# Note: `_fn` type is the transformer's `ValueFn` +# Note: Return type is the transformer's `OutputRenderer` +@overload +def render_capitalize( + _fn: CapitalizeTransformer.ValueFn, +) -> CapitalizeTransformer.OutputRenderer: + ... + + +# Lastly, implement the renderer. +# Note: `_fn` type is the transformer's `ValueFn` or `None` +# Note: Return type is the transformer's `OutputRenderer` or `OutputRendererDecorator` +def render_capitalize( + _fn: CapitalizeTransformer.ValueFn | None = None, + *, + to: Literal["upper", "lower"] = "upper", +) -> ( + CapitalizeTransformer.OutputRenderer | CapitalizeTransformer.OutputRendererDecorator +): + return CapitalizeTransformer( + _fn, + CapitalizeTransformer.params(to=to), + ) + + +####### +# End of package author code +####### + +app_ui = ui.page_fluid( + ui.h1("Capitalization renderer"), + ui.input_text("caption", "Caption:", "Data summary"), + "No parentheses:", + ui.output_text_verbatim("no_parens"), + "To upper:", + ui.output_text_verbatim("to_upper"), + "To lower:", + ui.output_text_verbatim("to_lower"), +) + + +def server(input: Inputs, output: Outputs, session: Session): + @output + # Without parentheses + @render_capitalize + def no_parens(): + return input.caption() + + @output + # With parentheses. Equivalent to `@render_capitalize()` + @render_capitalize(to="upper") + def to_upper(): + return input.caption() + + @output + @render_capitalize(to="lower") + # Works with async output value functions + async def to_lower(): + return input.caption() + + +app = App(app_ui, server) diff --git a/shiny/reactive/_reactives.py b/shiny/reactive/_reactives.py index 642933aa8..988c28b2e 100644 --- a/shiny/reactive/_reactives.py +++ b/shiny/reactive/_reactives.py @@ -22,7 +22,7 @@ from .._docstring import add_example from .._utils import is_async_callable, run_coro_sync from .._validation import req -from ..render import RenderFunction +from ..render.transformer import OutputRenderer from ..types import MISSING, MISSING_TYPE, ActionButtonValue, SilentException from ._core import Context, Dependents, ReactiveWarning, isolate @@ -782,7 +782,7 @@ def decorator(user_fn: Callable[[], T]) -> Callable[[], T]: + "In other words, `@reactive.Calc` must be above `@reactive.event()`." ) - if isinstance(user_fn, RenderFunction): + if isinstance(user_fn, OutputRenderer): # At some point in the future, we may allow this condition, if we find an # use case. For now we'll disallow it, for simplicity. raise TypeError( diff --git a/shiny/render/__init__.py b/shiny/render/__init__.py index b530c3e6b..3444cd5f0 100644 --- a/shiny/render/__init__.py +++ b/shiny/render/__init__.py @@ -2,29 +2,25 @@ Tools for reactively rendering output for the user interface. """ -from ._render import ( # noqa: F401 + +from . import ( # noqa: F401 + transformer, # pyright: ignore[reportUnusedImport] +) + +from ._deprecated import ( # noqa: F401 RenderFunction, # pyright: ignore[reportUnusedImport] RenderFunctionAsync, # pyright: ignore[reportUnusedImport] - RenderText, # pyright: ignore[reportUnusedImport] - RenderTextAsync, # pyright: ignore[reportUnusedImport] +) + +from ._render import ( text, - RenderPlot, # pyright: ignore[reportUnusedImport] - RenderPlotAsync, # pyright: ignore[reportUnusedImport] plot, - RenderImage, # pyright: ignore[reportUnusedImport] - RenderImageAsync, # pyright: ignore[reportUnusedImport] image, - RenderTable, # pyright: ignore[reportUnusedImport] - RenderTableAsync, # pyright: ignore[reportUnusedImport] table, - RenderUI, # pyright: ignore[reportUnusedImport] - RenderUIAsync, # pyright: ignore[reportUnusedImport] ui, ) -from ._dataframe import ( # noqa: F401 - RenderDataFrame, # pyright: ignore[reportUnusedImport] - RenderDataFrameAsync, # pyright: ignore[reportUnusedImport] +from ._dataframe import ( DataGrid, DataTable, data_frame, @@ -32,12 +28,13 @@ __all__ = ( - "DataGrid", - "DataTable", + # TODO-future: Document which variables are exposed via different import approaches "data_frame", "text", "plot", "image", "table", "ui", + "DataGrid", + "DataTable", ) diff --git a/shiny/render/_coordmap.py b/shiny/render/_coordmap.py index 2369012c5..05b74400b 100644 --- a/shiny/render/_coordmap.py +++ b/shiny/render/_coordmap.py @@ -1,7 +1,4 @@ # pyright: reportUnknownMemberType=false - -# Needed for types imported only during TYPE_CHECKING with Python 3.7 - 3.9 -# See https://www.python.org/dev/peps/pep-0655/#usage-in-python-3-11 from __future__ import annotations import re diff --git a/shiny/render/_dataframe.py b/shiny/render/_dataframe.py index 9c5df9bb9..171a35e6c 100644 --- a/shiny/render/_dataframe.py +++ b/shiny/render/_dataframe.py @@ -2,14 +2,10 @@ import abc import json -import typing from typing import ( TYPE_CHECKING, Any, - Awaitable, - Callable, Literal, - Optional, Protocol, Union, cast, @@ -17,10 +13,14 @@ runtime_checkable, ) -from .. import _utils from .._docstring import add_example -from . import RenderFunction, RenderFunctionAsync from ._dataframe_unsafe import serialize_numpy_dtypes +from .transformer import ( + TransformerMetadata, + ValueFn, + output_transformer, + resolve_value_fn, +) if TYPE_CHECKING: import pandas as pd @@ -219,108 +219,44 @@ def serialize_pandas_df(df: "pd.DataFrame") -> dict[str, Any]: DataFrameResult = Union[None, "pd.DataFrame", DataGrid, DataTable] -RenderDataFrameFunc = Callable[[], DataFrameResult] -RenderDataFrameFuncAsync = Callable[[], Awaitable[DataFrameResult]] - - -@runtime_checkable -class PandasCompatible(Protocol): - # Signature doesn't matter, runtime_checkable won't look at it anyway - def to_pandas(self) -> object: - ... - - -class RenderDataFrame(RenderFunction[DataFrameResult, object]): - def __init__( - self, - fn: RenderDataFrameFunc, - ) -> None: - super().__init__(fn) - # The Render*Async subclass will pass in an async function, but it tells the - # static type checker that it's synchronous. wrap_async() is smart -- if is - # passed an async function, it will not change it. - self._fn: RenderDataFrameFuncAsync = _utils.wrap_async(fn) - - def __call__(self) -> object: - return _utils.run_coro_sync(self._run()) - - async def _run(self) -> object: - x = await self._fn() - - if x is None: - return None - - if not isinstance(x, AbstractTabularData): - x = DataGrid( - cast_to_pandas( - x, "@render.data_frame doesn't know how to render objects of type" - ) - ) - - return x.to_payload() +@output_transformer +async def DataFrameTransformer( + _meta: TransformerMetadata, + _fn: ValueFn[DataFrameResult | None], +) -> object | None: + x = await resolve_value_fn(_fn) + if x is None: + return None -def cast_to_pandas(x: object, error_message_begin: str) -> object: - import pandas as pd - - if not isinstance(x, pd.DataFrame): - if not isinstance(x, PandasCompatible): - raise TypeError( - error_message_begin - + f" '{str(type(x))}'. Use either a pandas.DataFrame, or an object" - " that has a .to_pandas() method." + if not isinstance(x, AbstractTabularData): + x = DataGrid( + cast_to_pandas( + x, "@render.data_frame doesn't know how to render objects of type" ) - return x.to_pandas() - return x - - -class RenderDataFrameAsync( - RenderDataFrame, RenderFunctionAsync[DataFrameResult, object] -): - def __init__( - self, - fn: RenderDataFrameFuncAsync, - ) -> None: - if not _utils.is_async_callable(fn): - raise TypeError(self.__class__.__name__ + " requires an async function") - super().__init__( - typing.cast(RenderDataFrameFunc, fn), ) - - async def __call__( # pyright: ignore[reportIncompatibleMethodOverride] - self, - ) -> object: - return await self._run() + return x.to_payload() @overload -def data_frame(fn: RenderDataFrameFunc | RenderDataFrameFuncAsync) -> RenderDataFrame: +def data_frame() -> DataFrameTransformer.OutputRendererDecorator: ... @overload -def data_frame() -> ( - Callable[[RenderDataFrameFunc | RenderDataFrameFuncAsync], RenderDataFrame] -): +def data_frame( + _fn: DataFrameTransformer.ValueFn, +) -> DataFrameTransformer.OutputRenderer: ... @add_example() def data_frame( - fn: Optional[RenderDataFrameFunc | RenderDataFrameFuncAsync] = None, -) -> ( - RenderDataFrame - | Callable[[RenderDataFrameFunc | RenderDataFrameFuncAsync], RenderDataFrame] -): + _fn: DataFrameTransformer.ValueFn | None = None, +) -> DataFrameTransformer.OutputRenderer | DataFrameTransformer.OutputRendererDecorator: """ Reactively render a Pandas data frame object (or similar) as a basic HTML table. - Parameters - ---------- - index - Whether to print index (row) labels. - selection - Returns ------- @@ -343,14 +279,25 @@ def data_frame( -------- ~shiny.ui.output_data_frame """ + return DataFrameTransformer(_fn) + - def wrapper(fn: RenderDataFrameFunc | RenderDataFrameFuncAsync) -> RenderDataFrame: - if _utils.is_async_callable(fn): - return RenderDataFrameAsync(fn) - else: - return RenderDataFrame(cast(RenderDataFrameFunc, fn)) +@runtime_checkable +class PandasCompatible(Protocol): + # Signature doesn't matter, runtime_checkable won't look at it anyway + def to_pandas(self) -> object: + ... - if fn is None: - return wrapper - else: - return wrapper(fn) + +def cast_to_pandas(x: object, error_message_begin: str) -> object: + import pandas as pd + + if not isinstance(x, pd.DataFrame): + if not isinstance(x, PandasCompatible): + raise TypeError( + error_message_begin + + f" '{str(type(x))}'. Use either a pandas.DataFrame, or an object" + " that has a .to_pandas() method." + ) + return x.to_pandas() + return x diff --git a/shiny/render/_deprecated.py b/shiny/render/_deprecated.py new file mode 100644 index 000000000..093596bca --- /dev/null +++ b/shiny/render/_deprecated.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Generic + +from .transformer._transformer import ( + IT, + OT, + OutputRendererAsync, + OutputRendererSync, + TransformerMetadata, + TransformerParams, + ValueFn, + ValueFnAsync, + ValueFnSync, +) + +# ====================================================================================== +# Deprecated classes +# ====================================================================================== + + +# A RenderFunction object is given a app-supplied function which returns an `IT`. When +# the .__call__ method is invoked, it calls the app-supplied function (which returns an +# `IT`), then converts the `IT` to an `OT`. Note that in many cases but not all, `IT` +# and `OT` will be the same. +class RenderFunction(Generic[IT, OT], OutputRendererSync[OT], ABC): + """ + Deprecated. Please use :func:`~shiny.render.renderer_components` instead. + """ + + @abstractmethod + def __call__(self) -> OT: + ... + + @abstractmethod + async def run(self) -> OT: + ... + + def __init__(self, fn: ValueFnSync[IT]) -> None: + async def transformer(_meta: TransformerMetadata, _fn: ValueFn[IT]) -> OT: + ret = await self.run() + return ret + + super().__init__( + value_fn=fn, + transform_fn=transformer, + params=TransformerParams.empty_params(), + ) + self._fn = fn + + +# The reason for having a separate RenderFunctionAsync class is because the __call__ +# method is marked here as async; you can't have a single class where one method could +# be either sync or async. +class RenderFunctionAsync(Generic[IT, OT], OutputRendererAsync[OT], ABC): + """ + Deprecated. Please use :func:`~shiny.render.renderer_components` instead. + """ + + @abstractmethod + async def __call__(self) -> OT: # pyright: ignore[reportIncompatibleMethodOverride] + ... + + @abstractmethod + async def run(self) -> OT: + ... + + def __init__(self, fn: ValueFnAsync[IT]) -> None: + async def transformer(_meta: TransformerMetadata, _fn: ValueFn[IT]) -> OT: + ret = await self.run() + return ret + + super().__init__( + value_fn=fn, + transform_fn=transformer, + params=TransformerParams.empty_params(), + ) + self._fn = fn diff --git a/shiny/render/_render.py b/shiny/render/_render.py index b12901d05..1d3b4ec3b 100644 --- a/shiny/render/_render.py +++ b/shiny/render/_render.py @@ -1,24 +1,10 @@ -# Needed for types imported only during TYPE_CHECKING with Python 3.7 - 3.9 -# See https://www.python.org/dev/peps/pep-0655/#usage-in-python-3-11 from __future__ import annotations __all__ = ( - "RenderFunction", - "RenderFunctionAsync", - "RenderText", - "RenderTextAsync", "text", - "RenderPlot", - "RenderPlotAsync", "plot", - "RenderImage", - "RenderImageAsync", "image", - "RenderTable", - "RenderTableAsync", "table", - "RenderUI", - "RenderUIAsync", "ui", ) @@ -29,124 +15,61 @@ from typing import ( TYPE_CHECKING, Any, - Awaitable, - Callable, - Generic, Optional, - TypeVar, + Protocol, Union, cast, overload, + runtime_checkable, ) -# These aren't used directly in this file, but they seem necessary for Sphinx to work -# cleanly. -from htmltools import Tag # pyright: ignore[reportUnusedImport] # noqa: F401 -from htmltools import Tagifiable # pyright: ignore[reportUnusedImport] # noqa: F401 -from htmltools import TagList # pyright: ignore[reportUnusedImport] # noqa: F401 from htmltools import TagChild if TYPE_CHECKING: - from ..session import Session from ..session._utils import RenderedDeps import pandas as pd -from typing import Protocol, runtime_checkable - from .. import _utils from .._namespaces import ResolvedId from ..types import ImgData from ._try_render_plot import try_render_matplotlib, try_render_pil, try_render_plotnine - -# Input type for the user-spplied function that is passed to a render.xx -IT = TypeVar("IT") -# Output type after the RenderFunction.__call__ method is called on the IT object. -OT = TypeVar("OT") - - -# ====================================================================================== -# RenderFunction/RenderFunctionAsync base class -# ====================================================================================== - - -# A RenderFunction object is given a user-provided function which returns an IT. When -# the .__call___ method is invoked, it calls the user-provided function (which returns -# an IT), then converts the IT to an OT. Note that in many cases but not all, IT and OT -# will be the same. -class RenderFunction(Generic[IT, OT]): - def __init__(self, fn: Callable[[], IT]) -> None: - self.__name__ = fn.__name__ - self.__doc__ = fn.__doc__ - - def __call__(self) -> OT: - raise NotImplementedError - - def set_metadata(self, session: Session, name: str) -> None: - """When RenderFunctions are assigned to Output object slots, this method - is used to pass along session and name information. - """ - self._session: Session = session - self._name: str = name - - -# The reason for having a separate RenderFunctionAsync class is because the __call__ -# method is marked here as async; you can't have a single class where one method could -# be either sync or async. -class RenderFunctionAsync(RenderFunction[IT, OT]): - async def __call__(self) -> OT: # pyright: ignore[reportIncompatibleMethodOverride] - raise NotImplementedError - +from .transformer import ( + TransformerMetadata, + ValueFn, + is_async_callable, + output_transformer, + resolve_value_fn, +) # ====================================================================================== # RenderText # ====================================================================================== -RenderTextFunc = Callable[[], "str | None"] -RenderTextFuncAsync = Callable[[], Awaitable["str | None"]] - - -class RenderText(RenderFunction["str | None", "str | None"]): - def __init__(self, fn: RenderTextFunc) -> None: - super().__init__(fn) - # The Render*Async subclass will pass in an async function, but it tells the - # static type checker that it's synchronous. wrap_async() is smart -- if is - # passed an async function, it will not change it. - self._fn: RenderTextFuncAsync = _utils.wrap_async(fn) - - def __call__(self) -> str | None: - return _utils.run_coro_sync(self._run()) - - async def _run(self) -> str | None: - res = await self._fn() - if res is None: - return None - return str(res) -class RenderTextAsync(RenderText, RenderFunctionAsync["str | None", "str | None"]): - def __init__(self, fn: RenderTextFuncAsync) -> None: - if not _utils.is_async_callable(fn): - raise TypeError(self.__class__.__name__ + " requires an async function") - super().__init__(typing.cast(RenderTextFunc, fn)) - - async def __call__( # pyright: ignore[reportIncompatibleMethodOverride] - self, - ) -> str | None: - return await self._run() +@output_transformer +async def TextTransformer( + _meta: TransformerMetadata, + _fn: ValueFn[str | None], +) -> str | None: + value = await resolve_value_fn(_fn) + if value is None: + return None + return str(value) @overload -def text(fn: RenderTextFunc | RenderTextFuncAsync) -> RenderText: +def text() -> TextTransformer.OutputRendererDecorator: ... @overload -def text() -> Callable[[RenderTextFunc | RenderTextFuncAsync], RenderText]: +def text(_fn: TextTransformer.ValueFn) -> TextTransformer.OutputRenderer: ... def text( - fn: Optional[RenderTextFunc | RenderTextFuncAsync] = None, -) -> RenderText | Callable[[RenderTextFunc | RenderTextFuncAsync], RenderText]: + _fn: TextTransformer.ValueFn | None = None, +) -> TextTransformer.OutputRenderer | TextTransformer.OutputRendererDecorator: """ Reactively render text. @@ -166,18 +89,7 @@ def text( -------- ~shiny.ui.output_text """ - - def wrapper(fn: RenderTextFunc | RenderTextFuncAsync) -> RenderText: - if _utils.is_async_callable(fn): - return RenderTextAsync(fn) - else: - fn = typing.cast(RenderTextFunc, fn) - return RenderText(fn) - - if fn is None: - return wrapper - else: - return wrapper(fn) + return TextTransformer(_fn) # ====================================================================================== @@ -187,120 +99,103 @@ def wrapper(fn: RenderTextFunc | RenderTextFuncAsync) -> RenderText: # Union[matplotlib.figure.Figure, PIL.Image.Image] # However, if we did that, we'd have to import those modules at load time, which adds # a nontrivial amount of overhead. So for now, we're just using `object`. -RenderPlotFunc = Callable[[], object] -RenderPlotFuncAsync = Callable[[], Awaitable[object]] - - -class RenderPlot(RenderFunction[object, "ImgData | None"]): - _ppi: float = 96 - _is_userfn_async = False - - def __init__( - self, fn: RenderPlotFunc, *, alt: Optional[str] = None, **kwargs: object - ) -> None: - super().__init__(fn) - self._alt: Optional[str] = alt - self._kwargs = kwargs - # The Render*Async subclass will pass in an async function, but it tells the - # static type checker that it's synchronous. wrap_async() is smart -- if is - # passed an async function, it will not change it. - self._fn: RenderPlotFuncAsync = _utils.wrap_async(fn) - - def __call__(self) -> ImgData | None: - return _utils.run_coro_sync(self._run()) - - async def _run(self) -> ImgData | None: - inputs = self._session.root_scope().input - - # Reactively read some information about the plot. - pixelratio: float = typing.cast( - float, inputs[ResolvedId(".clientdata_pixelratio")]() - ) - width: float = typing.cast( - float, inputs[ResolvedId(f".clientdata_output_{self._name}_width")]() +@output_transformer +async def PlotTransformer( + _meta: TransformerMetadata, + _fn: ValueFn[ImgData | None], + *, + alt: Optional[str] = None, + **kwargs: object, +) -> ImgData | None: + is_userfn_async = is_async_callable(_fn) + name = _meta.name + session = _meta.session + + ppi: float = 96 + + inputs = session.root_scope().input + + # Reactively read some information about the plot. + pixelratio: float = typing.cast( + float, inputs[ResolvedId(".clientdata_pixelratio")]() + ) + width: float = typing.cast( + float, inputs[ResolvedId(f".clientdata_output_{name}_width")]() + ) + height: float = typing.cast( + float, inputs[ResolvedId(f".clientdata_output_{name}_height")]() + ) + + # Call the user function to get the plot object. + x = await resolve_value_fn(_fn) + + # Note that x might be None; it could be a matplotlib.pyplot + + # Try each type of renderer in turn. The reason we do it this way is to avoid + # importing modules that aren't already loaded. That could slow things down, or + # worse, cause an error if the module isn't installed. + # + # Each try_render function should indicate whether it was able to make sense of + # the x value (or, in the case of matplotlib, possibly it decided to use the + # global pyplot figure) by returning a tuple that starts with True. The second + # tuple element may be None in this case, which means the try_render function + # explicitly wants the plot to be blanked. + # + # If a try_render function returns a tuple that starts with False, then the next + # try_render function should be tried. If none succeed, an error is raised. + ok: bool + result: ImgData | None + + if "plotnine" in sys.modules: + ok, result = try_render_plotnine( + x, + width, + height, + pixelratio, + ppi, + alt, + **kwargs, ) - height: float = typing.cast( - float, inputs[ResolvedId(f".clientdata_output_{self._name}_height")]() + if ok: + return result + + if "matplotlib" in sys.modules: + ok, result = try_render_matplotlib( + x, + width, + height, + pixelratio=pixelratio, + ppi=ppi, + allow_global=not is_userfn_async, + alt=alt, + **kwargs, ) - - x = await self._fn() - - # Note that x might be None; it could be a matplotlib.pyplot - - # Try each type of renderer in turn. The reason we do it this way is to avoid - # importing modules that aren't already loaded. That could slow things down, or - # worse, cause an error if the module isn't installed. - # - # Each try_render function should indicate whether it was able to make sense of - # the x value (or, in the case of matplotlib, possibly it decided to use the - # global pyplot figure) by returning a tuple that starts with True. The second - # tuple element may be None in this case, which means the try_render function - # explicitly wants the plot to be blanked. - # - # If a try_render function returns a tuple that starts with False, then the next - # try_render function should be tried. If none succeed, an error is raised. - ok: bool - result: ImgData | None - - if "plotnine" in sys.modules: - ok, result = try_render_plotnine( - x, width, height, pixelratio, self._ppi, **self._kwargs - ) - if ok: - return result - - if "matplotlib" in sys.modules: - ok, result = try_render_matplotlib( - x, - width, - height, - pixelratio=pixelratio, - ppi=self._ppi, - allow_global=not self._is_userfn_async, - alt=self._alt, - **self._kwargs, - ) - if ok: - return result - - if "PIL" in sys.modules: - ok, result = try_render_pil( - x, width, height, pixelratio, self._ppi, **self._kwargs - ) - if ok: - return result - - # This check must happen last because matplotlib might be able to plot even if - # x is None - if x is None: - return None - - raise Exception( - f"@render.plot doesn't know to render objects of type '{str(type(x))}'. " - + "Consider either requesting support for this type of plot object, and/or " - + " explictly saving the object to a (png) file and using @render.image." + if ok: + return result + + if "PIL" in sys.modules: + ok, result = try_render_pil( + x, + width, + height, + pixelratio, + ppi, + alt, + **kwargs, ) + if ok: + return result + # This check must happen last because + # matplotlib might be able to plot even if x is `None` + if x is None: + return None -class RenderPlotAsync(RenderPlot, RenderFunctionAsync[object, "ImgData | None"]): - _is_userfn_async = True - - def __init__( - self, fn: RenderPlotFuncAsync, alt: Optional[str] = None, **kwargs: Any - ) -> None: - if not _utils.is_async_callable(fn): - raise TypeError(self.__class__.__name__ + " requires an async function") - super().__init__(typing.cast(RenderPlotFunc, fn), alt=alt, **kwargs) - - async def __call__( # pyright: ignore[reportIncompatibleMethodOverride] - self, - ) -> ImgData | None: - return await self._run() - - -@overload -def plot(fn: RenderPlotFunc | RenderPlotFuncAsync) -> RenderPlot: - ... + raise Exception( + f"@render.plot doesn't know to render objects of type '{str(type(x))}'. " + + "Consider either requesting support for this type of plot object, and/or " + + " explictly saving the object to a (png) file and using @render.image." + ) @overload @@ -308,17 +203,21 @@ def plot( *, alt: Optional[str] = None, **kwargs: Any, -) -> Callable[[RenderPlotFunc | RenderPlotFuncAsync], RenderPlot]: +) -> PlotTransformer.OutputRendererDecorator: + ... + + +@overload +def plot(_fn: PlotTransformer.ValueFn) -> PlotTransformer.OutputRenderer: ... -# TODO: Use more specific types for render.plot def plot( - fn: Optional[RenderPlotFunc | RenderPlotFuncAsync] = None, + _fn: PlotTransformer.ValueFn | None = None, *, alt: Optional[str] = None, **kwargs: Any, -) -> RenderPlot | Callable[[RenderPlotFunc | RenderPlotFuncAsync], RenderPlot]: +) -> PlotTransformer.OutputRenderer | PlotTransformer.OutputRendererDecorator: """ Reactively render a plot object as an HTML image. @@ -362,93 +261,54 @@ def plot( ~shiny.ui.output_plot ~shiny.render.image """ - - def wrapper(fn: RenderPlotFunc | RenderPlotFuncAsync) -> RenderPlot: - if _utils.is_async_callable(fn): - return RenderPlotAsync(fn, alt=alt, **kwargs) - else: - return RenderPlot(fn, alt=alt, **kwargs) - - if fn is None: - return wrapper - else: - return wrapper(fn) + return PlotTransformer(_fn, PlotTransformer.params(alt=alt, **kwargs)) # ====================================================================================== # RenderImage # ====================================================================================== -RenderImageFunc = Callable[[], "ImgData | None"] -RenderImageFuncAsync = Callable[[], Awaitable["ImgData | None"]] - - -class RenderImage(RenderFunction["ImgData | None", "ImgData | None"]): - def __init__( - self, - fn: RenderImageFunc, - *, - delete_file: bool = False, - ) -> None: - super().__init__(fn) - self._delete_file: bool = delete_file - # The Render*Async subclass will pass in an async function, but it tells the - # static type checker that it's synchronous. wrap_async() is smart -- if is - # passed an async function, it will not change it. - self._fn: RenderImageFuncAsync = _utils.wrap_async(fn) - - def __call__(self) -> ImgData | None: - return _utils.run_coro_sync(self._run()) - - async def _run(self) -> ImgData | None: - res: ImgData | None = await self._fn() - if res is None: - return None - - src: str = res.get("src") - try: - with open(src, "rb") as f: - data = base64.b64encode(f.read()) - data_str = data.decode("utf-8") - content_type = _utils.guess_mime_type(src) - res["src"] = f"data:{content_type};base64,{data_str}" - return res - finally: - if self._delete_file: - os.remove(src) - - -class RenderImageAsync( - RenderImage, RenderFunctionAsync["ImgData | None", "ImgData | None"] -): - def __init__(self, fn: RenderImageFuncAsync, delete_file: bool = False) -> None: - if not _utils.is_async_callable(fn): - raise TypeError(self.__class__.__name__ + " requires an async function") - super().__init__(typing.cast(RenderImageFunc, fn), delete_file=delete_file) - - async def __call__( # pyright: ignore[reportIncompatibleMethodOverride] - self, - ) -> ImgData | None: - return await self._run() +@output_transformer +async def ImageTransformer( + _meta: TransformerMetadata, + _fn: ValueFn[ImgData | None], + *, + delete_file: bool = False, +) -> ImgData | None: + res = await resolve_value_fn(_fn) + if res is None: + return None + + src: str = res.get("src") + try: + with open(src, "rb") as f: + data = base64.b64encode(f.read()) + data_str = data.decode("utf-8") + content_type = _utils.guess_mime_type(src) + res["src"] = f"data:{content_type};base64,{data_str}" + return res + finally: + if delete_file: + os.remove(src) @overload -def image(fn: RenderImageFunc | RenderImageFuncAsync) -> RenderImage: +def image( + *, + delete_file: bool = False, +) -> ImageTransformer.OutputRendererDecorator: ... @overload -def image( - *, - delete_file: bool = False, -) -> Callable[[RenderImageFunc | RenderImageFuncAsync], RenderImage]: +def image(_fn: ImageTransformer.ValueFn) -> ImageTransformer.OutputRenderer: ... def image( - fn: Optional[RenderImageFunc | RenderImageFuncAsync] = None, + _fn: ImageTransformer.ValueFn | None = None, *, delete_file: bool = False, -) -> RenderImage | Callable[[RenderImageFunc | RenderImageFuncAsync], RenderImage]: +) -> ImageTransformer.OutputRendererDecorator | ImageTransformer.OutputRenderer: """ Reactively render a image file as an HTML image. @@ -475,18 +335,7 @@ def image( ~shiny.types.ImgData ~shiny.render.plot """ - - def wrapper(fn: RenderImageFunc | RenderImageFuncAsync) -> RenderImage: - if _utils.is_async_callable(fn): - return RenderImageAsync(fn, delete_file=delete_file) - else: - fn = typing.cast(RenderImageFunc, fn) - return RenderImage(fn, delete_file=delete_file) - - if fn is None: - return wrapper - else: - return wrapper(fn) + return ImageTransformer(_fn, ImageTransformer.params(delete_file=delete_file)) # ====================================================================================== @@ -501,94 +350,55 @@ def to_pandas(self) -> "pd.DataFrame": ... -TableResult = Union[None, "pd.DataFrame", PandasCompatible] -RenderTableFunc = Callable[[], TableResult] -RenderTableFuncAsync = Callable[[], Awaitable[TableResult]] - - -class RenderTable(RenderFunction[object, "RenderedDeps | None"]): - def __init__( - self, - fn: RenderTableFunc, - *, - index: bool = False, - classes: str = "table shiny-table w-auto", - border: int = 0, - **kwargs: object, - ) -> None: - super().__init__(fn) - self._index = index - self._classes = classes - self._border = border - self._kwargs = kwargs - # The Render*Async subclass will pass in an async function, but it tells the - # static type checker that it's synchronous. wrap_async() is smart -- if is - # passed an async function, it will not change it. - self._fn: RenderTableFuncAsync = _utils.wrap_async(fn) - - def __call__(self) -> RenderedDeps | None: - return _utils.run_coro_sync(self._run()) - - async def _run(self) -> RenderedDeps | None: - x = await self._fn() - - if x is None: - return None - - import pandas - import pandas.io.formats.style - - html: str - if isinstance(x, pandas.io.formats.style.Styler): - html = x.to_html(**self._kwargs) # pyright: ignore[reportUnknownMemberType] - else: - if not isinstance(x, pandas.DataFrame): - if not isinstance(x, PandasCompatible): - raise TypeError( - "@render.table doesn't know how to render objects of type " - f"'{str(type(x))}'. Return either a pandas.DataFrame, or an object " - "that has a .to_pandas() method." - ) - x = x.to_pandas() - - html = x.to_html( # pyright: ignore[reportUnknownMemberType] - index=self._index, - classes=self._classes, - border=self._border, - **self._kwargs, - ) - return {"deps": [], "html": html} - - -class RenderTableAsync(RenderTable, RenderFunctionAsync[object, "ImgData | None"]): - def __init__( - self, - fn: RenderTableFuncAsync, - *, - index: bool = False, - classes: str = "table shiny-table w-auto", - border: int = 0, - **kwargs: Any, - ) -> None: - if not _utils.is_async_callable(fn): - raise TypeError(self.__class__.__name__ + " requires an async function") - super().__init__( - typing.cast(RenderTableFunc, fn), - index=index, - classes=classes, - border=border, - **kwargs, - ) - - async def __call__( # pyright: ignore[reportIncompatibleMethodOverride] - self, - ) -> RenderedDeps | None: - return await self._run() +TableResult = Union["pd.DataFrame", PandasCompatible, None] -@overload -def table(fn: RenderTableFunc | RenderTableFuncAsync) -> RenderTable: - ... +@output_transformer +async def TableTransformer( + _meta: TransformerMetadata, + _fn: ValueFn[TableResult | None], + *, + index: bool = False, + classes: str = "table shiny-table w-auto", + border: int = 0, + **kwargs: object, +) -> RenderedDeps | None: + x = await resolve_value_fn(_fn) + + if x is None: + return None + + import pandas + import pandas.io.formats.style + + html: str + if isinstance(x, pandas.io.formats.style.Styler): + html = cast( # pyright: ignore[reportUnnecessaryCast] + str, + x.to_html( # pyright: ignore[reportUnknownMemberType] + **kwargs # pyright: ignore[reportGeneralTypeIssues] + ), + ) + else: + if not isinstance(x, pandas.DataFrame): + if not isinstance(x, PandasCompatible): + raise TypeError( + "@render.table doesn't know how to render objects of type " + f"'{str(type(x))}'. Return either a pandas.DataFrame, or an object " + "that has a .to_pandas() method." + ) + x = x.to_pandas() + + html = cast( # pyright: ignore[reportUnnecessaryCast] + str, + x.to_html( # pyright: ignore[reportUnknownMemberType] + index=index, + classes=classes, + border=border, + **kwargs, # pyright: ignore[reportGeneralTypeIssues] + ), + ) + return {"deps": [], "html": html} @overload @@ -598,19 +408,23 @@ def table( classes: str = "table shiny-table w-auto", border: int = 0, **kwargs: Any, -) -> Callable[[RenderTableFunc | RenderTableFuncAsync], RenderTable]: +) -> TableTransformer.OutputRendererDecorator: + ... + + +@overload +def table(_fn: TableTransformer.ValueFn) -> TableTransformer.OutputRenderer: ... -# TODO: Use more specific types for render.table def table( - fn: Optional[RenderTableFunc | RenderTableFuncAsync] = None, + _fn: TableTransformer.ValueFn | None = None, *, index: bool = False, classes: str = "table shiny-table w-auto", border: int = 0, - **kwargs: Any, -) -> RenderTable | Callable[[RenderTableFunc | RenderTableFuncAsync], RenderTable]: + **kwargs: object, +) -> TableTransformer.OutputRenderer | TableTransformer.OutputRendererDecorator: """ Reactively render a Pandas data frame object (or similar) as a basic HTML table. @@ -655,78 +469,45 @@ def table( -------- ~shiny.ui.output_table for the corresponding UI component to this render function. """ - - def wrapper(fn: RenderTableFunc | RenderTableFuncAsync) -> RenderTable: - if _utils.is_async_callable(fn): - return RenderTableAsync( - fn, index=index, classes=classes, border=border, **kwargs - ) - else: - return RenderTable( - cast(RenderTableFunc, fn), - index=index, - classes=classes, - border=border, - **kwargs, - ) - - if fn is None: - return wrapper - else: - return wrapper(fn) + return TableTransformer( + _fn, + TableTransformer.params( + index=index, + classes=classes, + border=border, + **kwargs, + ), + ) # ====================================================================================== # RenderUI # ====================================================================================== -RenderUIFunc = Callable[[], TagChild] -RenderUIFuncAsync = Callable[[], Awaitable[TagChild]] - - -class RenderUI(RenderFunction[TagChild, "RenderedDeps | None"]): - def __init__(self, fn: RenderUIFunc) -> None: - super().__init__(fn) - # The Render*Async subclass will pass in an async function, but it tells the - # static type checker that it's synchronous. wrap_async() is smart -- if is - # passed an async function, it will not change it. - self._fn: RenderUIFuncAsync = _utils.wrap_async(fn) - - def __call__(self) -> RenderedDeps | None: - return _utils.run_coro_sync(self._run()) - - async def _run(self) -> RenderedDeps | None: - ui: TagChild = await self._fn() - if ui is None: - return None +@output_transformer +async def UiTransformer( + _meta: TransformerMetadata, + _fn: ValueFn[TagChild], +) -> RenderedDeps | None: + ui = await resolve_value_fn(_fn) + if ui is None: + return None - return self._session._process_ui(ui) - - -class RenderUIAsync(RenderUI, RenderFunctionAsync[TagChild, "RenderedDeps| None"]): - def __init__(self, fn: RenderUIFuncAsync) -> None: - if not _utils.is_async_callable(fn): - raise TypeError(self.__class__.__name__ + " requires an async function") - super().__init__(typing.cast(RenderUIFunc, fn)) - - async def __call__( # pyright: ignore[reportIncompatibleMethodOverride] - self, - ) -> RenderedDeps | None: - return await self._run() + return _meta.session._process_ui(ui) @overload -def ui(fn: RenderUIFunc | RenderUIFuncAsync) -> RenderUI: +def ui() -> UiTransformer.OutputRendererDecorator: ... @overload -def ui() -> Callable[[RenderUIFunc | RenderUIFuncAsync], RenderUI]: +def ui(_fn: UiTransformer.ValueFn) -> UiTransformer.OutputRenderer: ... def ui( - fn: Optional[RenderUIFunc | RenderUIFuncAsync] = None, -) -> RenderUI | Callable[[RenderUIFunc | RenderUIFuncAsync], RenderUI]: + _fn: UiTransformer.ValueFn | None = None, +) -> UiTransformer.OutputRenderer | UiTransformer.OutputRendererDecorator: """ Reactively render HTML content. @@ -746,17 +527,4 @@ def ui( -------- ~shiny.ui.output_ui """ - - def wrapper( - fn: Callable[[], TagChild] | Callable[[], Awaitable[TagChild]] - ) -> RenderUI: - if _utils.is_async_callable(fn): - return RenderUIAsync(fn) - else: - fn = typing.cast(RenderUIFunc, fn) - return RenderUI(fn) - - if fn is None: - return wrapper - else: - return wrapper(fn) + return UiTransformer(_fn) diff --git a/shiny/render/_try_render_plot.py b/shiny/render/_try_render_plot.py index 3c9b6723e..ea214cdeb 100644 --- a/shiny/render/_try_render_plot.py +++ b/shiny/render/_try_render_plot.py @@ -1,5 +1,3 @@ -# Needed for types imported only during TYPE_CHECKING with Python 3.7 - 3.9 -# See https://www.python.org/dev/peps/pep-0655/#usage-in-python-3-11 from __future__ import annotations import base64 diff --git a/shiny/render/transformer/__init__.py b/shiny/render/transformer/__init__.py new file mode 100644 index 000000000..f28d91f1a --- /dev/null +++ b/shiny/render/transformer/__init__.py @@ -0,0 +1,25 @@ +from ._transformer import ( # noqa: F401 + TransformerMetadata, + TransformerParams, + OutputRenderer, + output_transformer, + is_async_callable, + resolve_value_fn, + ValueFn, + ValueFnSync, # pyright: ignore[reportUnusedImport] + ValueFnAsync, # pyright: ignore[reportUnusedImport] + TransformFn, # pyright: ignore[reportUnusedImport] + OutputTransformer, # pyright: ignore[reportUnusedImport] + OutputRendererSync, # pyright: ignore[reportUnusedImport] + OutputRendererAsync, # pyright: ignore[reportUnusedImport] +) + +__all__ = ( + "TransformerMetadata", + "TransformerParams", + "OutputRenderer", + "ValueFn", + "output_transformer", + "is_async_callable", + "resolve_value_fn", +) diff --git a/shiny/render/transformer/_transformer.py b/shiny/render/transformer/_transformer.py new file mode 100644 index 000000000..f206a8221 --- /dev/null +++ b/shiny/render/transformer/_transformer.py @@ -0,0 +1,675 @@ +from __future__ import annotations + +# TODO-future: docs; missing first paragraph from some classes: Example: TransformerMetadata. +# No init method for TransformerParams. This is because the `DocClass` object does not +# display methods that start with `_`. THerefore no `__init__` or `__call__` methods are +# displayed. Even if they have docs. + +__all__ = ( + "TransformerMetadata", + "TransformerParams", + "OutputRenderer", + "OutputTransformer", + "ValueFn", + # "ValueFnSync", + # "ValueFnAsync", + # "TransformFn", + "output_transformer", + "is_async_callable", + # "IT", + # "OT", + # "P", +) + +import inspect +from abc import ABC, abstractmethod +from typing import ( + TYPE_CHECKING, + Awaitable, + Callable, + Generic, + NamedTuple, + Optional, + TypeVar, + Union, + cast, +) + +if TYPE_CHECKING: + from ... import Session + +from ... import _utils +from ..._docstring import add_example +from ..._typing_extensions import Concatenate, ParamSpec +from ..._utils import is_async_callable + +# Input type for the user-spplied function that is passed to a render.xx +IT = TypeVar("IT") +# Output type after the Renderer.__call__ method is called on the IT object. +OT = TypeVar("OT") +# Param specification for value_fn function +P = ParamSpec("P") + + +# ====================================================================================== +# Helper classes +# ====================================================================================== + + +# Meta information to give `hander()` some context +class TransformerMetadata(NamedTuple): + """ + Transformer metadata + + This class is used to hold meta information for a transformer function. + + Attributes + ---------- + session + The :class:`~shiny.Session` object of the current output value function. + name + The name of the output being rendered. + """ + + session: Session + name: str + + +# Motivation for using this class: +# * https://peps.python.org/pep-0612/ does allow for prepending an arg (e.g. +# `value_fn`). +# * However, the overload is not happy when both a positional arg (e.g. `value_fn`) is +# dropped and the variadic positional args (`*args`) are kept. +# * The variadic positional args (`*args`) CAN NOT be dropped as PEP612 states that both +# components of the `ParamSpec` must be used in the same function signature. +# * By making assertions on `P.args` to only allow for `*`, we _can_ make overloads that +# use either the single positional arg (e.g. `value_fn`) or the `P.kwargs` (as +# `P.args` == `*`) +class TransformerParams(Generic[P]): + """ + Parameters for a transformer function + + This class is used to isolate the transformer function parameters away from + internal implementation parameters used by Shiny. + + """ + + def __init__(self, *args: P.args, **kwargs: P.kwargs) -> None: + """ + Parameters + ---------- + *args + No positional arguments should be supplied. Only keyword arguments should be + supplied. (`*args` is required when using :class:`~typing.ParamSpec` even if + transformer is only leveraging `**kwargs`.) + **kwargs + Keyword arguments for the corresponding renderer function. + """ + + # Make sure there no `args` at run time! + # This check is related to `_assert_transform_fn` not accepting any `args` + if len(args) > 0: + raise RuntimeError("`args` should not be supplied") + + # `*args` must be defined with `**kwargs` (as per PEP612) + # (even when expanded later when calling the transform function) + # We need to store (and later retrieve) them, even if we know they are empty + self.args = args + self.kwargs = kwargs + + @staticmethod + def empty_params() -> TransformerParams[P]: + """ + Return `TransformerParams` definition with no parameters. + """ + + def inner(*args: P.args, **kwargs: P.kwargs) -> TransformerParams[P]: + return TransformerParams[P](*args, **kwargs) + + return inner() + + +# ====================================================================================== +# Renderer / RendererSync / RendererAsync base class +# ====================================================================================== + +# A `ValueFn` function is an app-supplied function which returns an IT. +# It can be either synchronous or asynchronous +ValueFnSync = Callable[[], IT] +""" +App-supplied output value function which returns type `IT`. This function is +synchronous. +""" +ValueFnAsync = Callable[[], Awaitable[IT]] +""" +App-supplied output value function which returns type `IT`. This function is +asynchronous. +""" +ValueFn = Union[ValueFnSync[IT], ValueFnAsync[IT]] +""" +App-supplied output value function which returns type `IT`. This function can be +synchronous or asynchronous. +""" + +# `TransformFn` is a package author function that transforms an object of type `IT` into +# type `OT`. +TransformFn = Callable[Concatenate[TransformerMetadata, ValueFn[IT], P], Awaitable[OT]] +""" +Package author function that transforms an object of type `IT` into type `OT`. It should +be defined as an asynchronous function but should only asynchronously yield when the +second parameter (of type `ValueFn[IT]`) is awaitable. If the second function argument +is not awaitable (a _synchronous_ function), then the execution of the transform +function should also be synchronous. +""" + + +class OutputRenderer(Generic[OT], ABC): + """ + Output Renderer + + Transforms the output (of type `IT`) of an app-supplied output value function + (`value_fn`) into type (`OT`). This transformed value is then sent to be an + :class:`~shiny.Outputs` output value. + + When the `.__call__` method is invoked, the transform function (`transform_fn`) + (typically defined by package authors) is invoked. The wrapping classes + (:class:`~shiny.render.transformer.OutputRendererSync` and + :class:`~shiny.render.transformer.OutputRendererAsync`) will enforce whether the + transform function is synchronous or asynchronous independent of the awaitable + syntax. + + The transform function (`transform_fn`) is given `meta` information + (:class:`~shiny.render.transformer.TranformerMetadata`), the (app-supplied) value + function (`ValueFn[IT]`), and any keyword arguments supplied to the render decorator + (`P`). For consistency, the first two parameters have been (arbitrarily) implemented + as `_meta` and `_fn`. + + Typing + ------ + * `IT` + * The type returned by the app-supplied output value function (`value_fn`). This + value should contain a `None` value to conform to the convention of app authors + being able to return `None` to display nothing in the rendered output. Note that + in many cases but not all, `IT` and `OT` will be the same. + * `OT` + * The type of the object returned by the transform function (`transform_fn`). This + value should contain a `None` value to conform to display nothing in the + rendered output. + * `P` + * The parameter specification defined by the transform function (`transform_fn`). + It should **not** contain any `*args`. All keyword arguments should have a type + and default value. + + + See Also + -------- + * :class:`~shiny.render.transformer.OutputRendererSync` + * :class:`~shiny.render.transformer.OutputRendererAsync` + """ + + @abstractmethod + def __call__(self) -> OT: + """ + Executes the output renderer as a function. Must be implemented by subclasses. + """ + ... + + def __init__( + self, + *, + value_fn: ValueFn[IT], + transform_fn: TransformFn[IT, P, OT], + params: TransformerParams[P], + ) -> None: + """ + Parameters + ---------- + value_fn + App-provided output value function. It should return an object of type `IT`. + transform_fn + Package author function that transforms an object of type `IT` into type + `OT`. The `params` will used as variadic keyword arguments. This method + should only use `await` syntax when the value function (`ValueFn[IT]`) is + awaitable. If the value function is not awaitable (a _synchronous_ + function), then the function should execute synchronously. + params + App-provided parameters for the transform function (`transform_fn`). + + """ + + # Copy over function name as it is consistent with how Session and Output + # retrieve function names + self.__name__ = value_fn.__name__ + + if not is_async_callable(transform_fn): + raise TypeError( + self.__class__.__name__ + + " requires an async tranformer function (`transform_fn`)" + ) + + self._value_fn = value_fn + self._transformer = transform_fn + self._params = params + + def _set_metadata(self, session: Session, name: str) -> None: + """ + When `Renderer`s are assigned to Output object slots, this method is used to + pass along Session and name information. + """ + self._session: Session = session + self._name: str = name + + def _meta(self) -> TransformerMetadata: + """ + Returns a named tuple of values: `session` (the :class:`~shiny.Session` object), + and `name` (the name of the output being rendered) + """ + return TransformerMetadata( + session=self._session, + name=self._name, + ) + + async def _run(self) -> OT: + """ + Executes the (async) tranform function + + The transform function will receive the following arguments: meta information of + type :class:`~shiny.render.transformer.TransformerMetadata`, an app-defined + render function of type :class:`~shiny.render.RenderFnAsync`, and `*args` and + `**kwargs` of type `P`. + + Note: `*args` will always be empty as it is an expansion of + :class:`~shiny.render.transformer.TransformerParams` which does not allow positional arguments. + `*args` is required to use with `**kwargs` when using + `typing.ParamSpec`. + """ + ret = await self._transformer( + # TransformerMetadata + self._meta(), + # Callable[[], Awaitable[IT]] | Callable[[], IT] + self._value_fn, + # P + *self._params.args, + **self._params.kwargs, + ) + return ret + + +# Using a second class to help clarify that it is of a particular type +class OutputRendererSync(OutputRenderer[OT]): + """ + Output Renderer (Synchronous) + + This class is used to define a synchronous renderer. The `.__call__` method is + implemented to call the `._run` method synchronously. + + See Also + -------- + * :class:`~shiny.render.transformer.OutputRenderer` + * :class:`~shiny.render.transformer.OutputRendererAsync` + """ + + def __init__( + self, + value_fn: ValueFnSync[IT], + transform_fn: TransformFn[IT, P, OT], + params: TransformerParams[P], + ) -> None: + if is_async_callable(value_fn): + raise TypeError( + self.__class__.__name__ + " requires a synchronous render function" + ) + # super == Renderer + super().__init__( + value_fn=value_fn, + transform_fn=transform_fn, + params=params, + ) + + def __call__(self) -> OT: + """ + Synchronously executes the output renderer as a function. + """ + return _utils.run_coro_sync(self._run()) + + +# The reason for having a separate RendererAsync class is because the __call__ +# method is marked here as async; you can't have a single class where one method could +# be either sync or async. +class OutputRendererAsync(OutputRenderer[OT]): + """ + Output Renderer (Asynchronous) + + This class is used to define an asynchronous renderer. The `.__call__` method is + implemented to call the `._run` method asynchronously. + + See Also + -------- + * :class:`~shiny.render.transformer.OutputRenderer` + * :class:`~shiny.render.transformer.OutputRendererSync` + """ + + def __init__( + self, + value_fn: ValueFnAsync[IT], + transform_fn: TransformFn[IT, P, OT], + params: TransformerParams[P], + ) -> None: + if not is_async_callable(value_fn): + raise TypeError( + self.__class__.__name__ + " requires an asynchronous render function" + ) + # super == Renderer + super().__init__( + value_fn=value_fn, + transform_fn=transform_fn, + params=params, + ) + + async def __call__(self) -> OT: # pyright: ignore[reportIncompatibleMethodOverride] + """ + Asynchronously executes the output renderer as a function. + """ + return await self._run() + + +# ====================================================================================== +# Restrict the transformer function +# ====================================================================================== + + +# assert: No variable length positional values; +# * We need a way to distinguish between a plain function and args supplied to the next +# function. This is done by not allowing `*args`. +# assert: All `**kwargs` of transformer should have a default value +# * This makes calling the method with both `()` and without `()` possible / consistent. +def _assert_transformer(transform_fn: TransformFn[IT, P, OT]) -> None: + params = inspect.Signature.from_callable(transform_fn).parameters + + if len(params) < 2: + raise TypeError( + "`transformer=` must have 2 positional parameters which have type " + "`TransformerMetadata` and `RenderFnAsync` respectively" + ) + + for i, param in zip(range(len(params)), params.values()): + # # Not a good test as `param.annotation` has type `str` and the type could + # # have been renamed. We need to do an `isinstance` check but do not have + # # access to the objects + # if i == 0: + # assert param.annotation == "TransformerMetadata" + # if i == 1: + # assert (param.annotation or "").startswith("RenderFnAsync") + if i < 2 and not ( + param.kind == inspect.Parameter.POSITIONAL_OR_KEYWORD + or param.kind == inspect.Parameter.POSITIONAL_ONLY + ): + raise TypeError( + "`transformer=` must have 2 positional parameters which have type " + "`TransformerMetadata` and `RenderFnAsync` respectively" + ) + + # Make sure there are no more than 2 positional args + if i >= 2 and param.kind == inspect.Parameter.POSITIONAL_OR_KEYWORD: + raise TypeError( + "`transformer=` must not contain more than 2 positional parameters" + ) + # Make sure there are no `*args` + if param.kind == inspect.Parameter.VAR_POSITIONAL: + raise TypeError( + "No variadic positional parameters (e.g. `*args`) can be supplied to " + f"`transformer=`. Received: `{param.name}`. Please only use `*`." + ) + # Make sure kwargs have default values + if ( + param.kind == inspect.Parameter.KEYWORD_ONLY + and param.default is inspect.Parameter.empty + ): + raise TypeError( + f"In `transformer=`, parameter `{param.name}` did not have a default value" + ) + + +# ====================================================================================== +# OutputTransformer +# ====================================================================================== + + +# Signature of a renderer decorator function +OutputRendererDecorator = Callable[[ValueFn[IT]], OutputRenderer[OT]] +""" +Decorator function that takes the output value function (then calls it and transforms +the value) and returns an :class:`~shiny.render.transformer.OutputRenderer`. +""" + +# Signature of a decorator that can be called with and without parentheses +# With parens returns a `OutputRenderer[OT]` +# Without parens returns a `OutputRendererDeco[IT, OT]` +OutputTransformerFn = Callable[ + [ + Optional[ValueFn[IT]], + TransformerParams[P], + ], + Union[OutputRenderer[OT], OutputRendererDecorator[IT, OT]], +] +""" +Generic overload definition for a decorator that can be called with and without +parentheses. If called with parentheses, it returns an decorator which returns an +:class:`~shiny.render.transformer.OutputRenderer`. If called without parentheses, it +returns an :class:`~shiny.render.transformer.OutputRenderer`. +""" + + +class OutputTransformer(Generic[IT, OT, P]): + """ + Output Transformer class + + This class creates helper types and methods for defining an overloaded renderer + decorator. By manually defining the overloads locally, the function signatures are + as clean as possible (and therefore easier to read and understand). + + When called, an `OutputTransfomer` takes the value returned from the app-supplied + output value function and any app-supplied paramters and passes them through the + component author's transformer function, and returns the transformed result. + + Attributes + ---------- + ValueFn + The function type for the app-supplied output value function. This function may + be both synchronous or asynchronous. + OutputRenderer + The return type for the overload that accepts the app-supplied output value + function and returns an object of + :class:`~shiny.render.transformer.OutputRenderer`. + OutputRendererDecorator + The return type for the overload that accepts app-supplied parameters for the + transform function. The return value is a decorator that accepts the + app-supplied output value function and returns an object of + :class:`~shiny.render.transformer.OutputRenderer`. + + See Also + -------- + * :func:`~shiny.render.transformer.output_transformer` + * :class:`~shiny.render.transformer.TransformerParams` + * :class:`~shiny.render.transformer.OutputRenderer` + """ + + def params( + self, + *args: P.args, + **kwargs: P.kwargs, + ) -> TransformerParams[P]: + return TransformerParams(*args, **kwargs) + + def __call__( + self, + value_fn: ValueFn[IT] | None, + params: TransformerParams[P] | None = None, + ) -> OutputRenderer[OT] | OutputRendererDecorator[IT, OT]: + if params is None: + params = self.params() + if not isinstance(params, TransformerParams): + raise TypeError( + "Expected `params` to be of type `TransformerParams` but received " + f"`{type(params)}`. Please use `.params()` to create a " + "`TransformerParams` object." + ) + return self._fn(value_fn, params) + + def __init__( + self, + fn: OutputTransformerFn[IT, P, OT], + ) -> None: + self._fn = fn + self.ValueFn = ValueFn[IT] + self.OutputRenderer = OutputRenderer[OT] + self.OutputRendererDecorator = OutputRendererDecorator[IT, OT] + + +@add_example() +def output_transformer( + transform_fn: TransformFn[IT, P, OT], +) -> OutputTransformer[IT, OT, P]: + """ + Output transformer decorator + + This decorator method is a convenience method to generate the appropriate types and + internal implementation for an overloaded renderer method. This method will provide + you with all the necessary types to define two different overloads: one for when the + decorator is called without parentheses and another for when it is called with + parentheses where app authors can pass in parameters to the renderer. + + Transform function + ------------------ + + The output renderer's transform function (`transform_fn`) is the key building block + for `output_transformer`. It is a package author function that calls the app-defined + output value function (`value_fn`) transforms the result of type `IT` into type + `OT`. + + The transform function is supplied meta output information, the (app-supplied) value + function, and any keyword arguments supplied to the output tranformer decorator: + + * The first parameter to the handler function has the class + :class:`~shiny.render.transformer.TransformerMetadata` and is typically called + `_meta`. This information gives context the to the handler while trying to + resolve the app-supplied value function (typically called `_fn`). + * The second parameter is the app-defined output value function (e.g. `_fn`). It's + return type (`IT`) determines what types can be returned by the app-supplied + output value function. For example, if `_fn` has the type `ValueFn[str | None]`, + both the `str` and `None` types are allowed to be returned from the app-supplied + output value function. + * The remaining parameters are the keyword arguments (e.g. `alt:Optional[str] = + None` or `**kwargs: object`) that app authors may supply to the renderer (when the + renderer decorator is called with parentheses). Variadic positional parameters + (e.g. `*args`) are not allowed. All keyword arguments should have a type and + default value. No default value is needed for keyword arguments that are passed + through (e.g. `**kwargs: Any`). + + The tranform function's return type (`OT`) determines the output type of the + :class:`~shiny.render.transformer.OutputRenderer`. Note that in many cases (but not + all!) `IT` and `OT` will be the same. The `None` type should typically be defined in + both `IT` and `OT`. If `IT` allows for `None` values, it (typically) signals that + nothing should be rendered. If `OT` allows for `None` and returns a `None` value, + shiny will not render the output. + + Notes + ----- + + * When defining the renderer decorator overloads, if you have extra parameters of + `**kwargs: object`, you may get a type error about incompatible signatures. To fix + this, you can use `**kwargs: Any` instead or add `_fn: None = None` as the first + parameter in the overload containing the `**kwargs: object`. + + * The `transform_fn` should be defined as an asynchronous function but should only + asynchronously yield (i.e. use `await` syntax) when the value function (the second + parameter of type `ValueFn[IT]`) is awaitable. If the value function is not + awaitable (i.e. it is a _synchronous_ function), then the execution of the + transform function should also be synchronous. + + + Parameters + ---------- + transform_fn + Asynchronous function used to determine the app-supplied output value function + return type (`IT`), the transformed type (`OT`), and the keyword arguments (`P`) + app authors can supply to the renderer decorator. + + Returns + ------- + : + An :class:`~shiny.render.transformer.OutputTransformer` object that can be used to + define two overloads for your renderer function. One overload is for when the + renderer is called without parentheses and the other is for when the renderer is + called with parentheses. + """ + _assert_transformer(transform_fn) + + def renderer_decorator( + value_fn: ValueFn[IT] | None, + params: TransformerParams[P], + ) -> OutputRenderer[OT] | OutputRendererDecorator[IT, OT]: + def as_value_fn( + fn: ValueFn[IT], + ) -> OutputRenderer[OT]: + if is_async_callable(fn): + return OutputRendererAsync(fn, transform_fn, params) + else: + # To avoid duplicate work just for a typeguard, we cast the function + fn = cast(ValueFnSync[IT], fn) + return OutputRendererSync(fn, transform_fn, params) + + if value_fn is None: + return as_value_fn + val = as_value_fn(value_fn) + return val + + return OutputTransformer(renderer_decorator) + + +async def resolve_value_fn(value_fn: ValueFn[IT]) -> IT: + """ + Resolve the value function + + This function is used to resolve the value function (`value_fn`) to an object of + type `IT`. If the value function is asynchronous, it will be awaited. If the value + function is synchronous, it will be called. + + While always using an async method within an output transform function is not + appropriate, this method may be safely used to avoid boilerplate. + + Replace this: + ```python + if is_async_callable(_fn): + x = await _fn() + else: + x = cast(ValueFnSync[IT], _fn)() + ``` + + With this: + ```python + x = await resolve_value_fn(_fn) + ``` + + This code substitution is safe as the implementation does not _actually_ + asynchronously yield to another process if the `value_fn` is synchronous. The + `__call__` method of the :class:`~shiny.render.transformer.OutputRendererSync` is + built to execute asynchronously defined methods that execute synchronously. + + Parameters + ---------- + value_fn + App-supplied output value function which returns type `IT`. This function can be + synchronous or asynchronous. + + Returns + ------- + : + The resolved value from `value_fn`. + """ + if is_async_callable(value_fn): + return await value_fn() + else: + # To avoid duplicate work just for a typeguard, we cast the function + value_fn = cast(ValueFnSync[IT], value_fn) + return value_fn() diff --git a/shiny/session/_session.py b/shiny/session/_session.py index d1ade535b..d14cec0dd 100644 --- a/shiny/session/_session.py +++ b/shiny/session/_session.py @@ -47,11 +47,10 @@ from ..input_handler import input_handlers from ..reactive import Effect, Effect_, Value, flush, isolate from ..reactive._core import lock, on_flushed -from ..render import RenderFunction +from ..render.transformer import OutputRenderer from ..types import SafeException, SilentCancelOutputException, SilentException from ._utils import RenderedDeps, read_thunk_opt, session_context -IT = TypeVar("IT") OT = TypeVar("OT") @@ -956,7 +955,7 @@ def __init__( self._suspend_when_hidden = suspend_when_hidden @overload - def __call__(self, fn: RenderFunction[Any, Any]) -> None: + def __call__(self, renderer_fn: OutputRenderer[Any]) -> None: ... @overload @@ -967,18 +966,18 @@ def __call__( suspend_when_hidden: bool = True, priority: int = 0, name: Optional[str] = None, - ) -> Callable[[RenderFunction[Any, Any]], None]: + ) -> Callable[[OutputRenderer[Any]], None]: ... def __call__( self, - fn: Optional[RenderFunction[IT, OT]] = None, + renderer_fn: Optional[OutputRenderer[OT]] = None, *, id: Optional[str] = None, suspend_when_hidden: bool = True, priority: int = 0, name: Optional[str] = None, - ) -> None | Callable[[RenderFunction[IT, OT]], None]: + ) -> None | Callable[[OutputRenderer[OT]], None]: if name is not None: from .. import _deprecated @@ -987,18 +986,18 @@ def __call__( ) id = name - def set_fn(fn: RenderFunction[IT, OT]) -> None: + def set_renderer(renderer_fn: OutputRenderer[OT]) -> None: # Get the (possibly namespaced) output id - output_name = self._ns(id or fn.__name__) + output_name = self._ns(id or renderer_fn.__name__) - if not isinstance(fn, RenderFunction): + if not isinstance(renderer_fn, OutputRenderer): raise TypeError( "`@output` must be applied to a `@render.xx` function.\n" + "In other words, `@output` must be above `@render.xx`." ) - # fn is a RenderFunction object. Give it a bit of metadata. - fn.set_metadata(self._session, output_name) + # renderer_fn is a Renderer object. Give it a bit of metadata. + renderer_fn._set_metadata(self._session, output_name) if output_name in self._effects: self._effects[output_name].destroy() @@ -1016,10 +1015,10 @@ async def output_obs(): message: dict[str, Optional[OT]] = {} try: - if _utils.is_async_callable(fn): - message[output_name] = await fn() + if _utils.is_async_callable(renderer_fn): + message[output_name] = await renderer_fn() else: - message[output_name] = fn() + message[output_name] = renderer_fn() except SilentCancelOutputException: return except SilentException: @@ -1060,10 +1059,10 @@ async def output_obs(): return None - if fn is None: - return set_fn + if renderer_fn is None: + return set_renderer else: - return set_fn(fn) + return set_renderer(renderer_fn) def _manage_hidden(self) -> None: "Suspends execution of hidden outputs and resumes execution of visible outputs." diff --git a/tests/test_output_transformer.py b/tests/test_output_transformer.py new file mode 100644 index 000000000..04506b8e8 --- /dev/null +++ b/tests/test_output_transformer.py @@ -0,0 +1,337 @@ +from __future__ import annotations + +import asyncio +from typing import Any, overload + +import pytest + +from shiny._utils import is_async_callable +from shiny.render.transformer import ( + TransformerMetadata, + ValueFn, + output_transformer, + resolve_value_fn, +) + + +def test_output_transformer_works(): + # No args works + @output_transformer + async def TestTransformer( + _meta: TransformerMetadata, + _fn: ValueFn[str], + ): + ... + + @overload + def test_renderer() -> TestTransformer.OutputRendererDecorator: + ... + + @overload + def test_renderer( + _fn: TestTransformer.ValueFn, + ) -> TestTransformer.OutputRenderer: + ... + + def test_renderer( + _fn: TestTransformer.ValueFn | None = None, + ) -> TestTransformer.OutputRenderer | TestTransformer.OutputRendererDecorator: + return TestTransformer(_fn) + + +def test_output_transformer_kwargs_are_allowed(): + # Test that kwargs can be allowed + @output_transformer + async def TestTransformer( + _meta: TransformerMetadata, + _fn: ValueFn[str], + *, + y: str = "42", + ): + ... + + @overload + def test_renderer(*, y: str = "42") -> TestTransformer.OutputRendererDecorator: + ... + + @overload + def test_renderer( + _fn: TestTransformer.ValueFn, + ) -> TestTransformer.OutputRenderer: + ... + + def test_renderer( + _fn: TestTransformer.ValueFn | None = None, + *, + y: str = "42", + ) -> TestTransformer.OutputRenderer | TestTransformer.OutputRendererDecorator: + return TestTransformer( + _fn, + TestTransformer.params(y=y), + ) + + +def test_output_transformer_with_pass_through_kwargs(): + # No args works + @output_transformer + async def TestTransformer( + _meta: TransformerMetadata, + _fn: ValueFn[str], + *, + y: str = "42", + **kwargs: float, + ): + ... + + @overload + def test_renderer( + *, y: str = "42", **kwargs: Any + ) -> TestTransformer.OutputRendererDecorator: + ... + + @overload + def test_renderer( + _fn: TestTransformer.ValueFn, + ) -> TestTransformer.OutputRenderer: + ... + + def test_renderer( + _fn: TestTransformer.ValueFn | None = None, + *, + y: str = "42", + **kwargs: Any, + ) -> TestTransformer.OutputRenderer | TestTransformer.OutputRendererDecorator: + return TestTransformer( + _fn, + TestTransformer.params(y=y, **kwargs), + ) + + +def test_output_transformer_pos_args(): + try: + + @output_transformer # pyright: ignore[reportGeneralTypeIssues] + async def TestTransformer( + _meta: TransformerMetadata, + ): + ... + + raise RuntimeError() + except TypeError as e: + assert "must have 2 positional parameters" in str(e) + + +def test_output_transformer_limits_positional_arg_count(): + try: + + @output_transformer + async def TestTransformer( + _meta: TransformerMetadata, + _fn: ValueFn[str], + y: str, + ): + ... + + raise RuntimeError() + except TypeError as e: + assert "more than 2 positional" in str(e) + + +def test_output_transformer_does_not_allow_args(): + try: + + @output_transformer + async def TestTransformer( + _meta: TransformerMetadata, + _fn: ValueFn[str], + *args: str, + ): + ... + + raise RuntimeError() + + except TypeError as e: + assert "No variadic positional parameters" in str(e) + + +def test_output_transformer_kwargs_have_defaults(): + try: + + @output_transformer + async def TestTransformer( + _meta: TransformerMetadata, + _fn: ValueFn[str], + *, + y: str, + ): + ... + + raise RuntimeError() + + except TypeError as e: + assert "did not have a default value" in str(e) + + +def test_output_transformer_result_does_not_allow_args(): + @output_transformer + async def TestTransformer( + _meta: TransformerMetadata, + _fn: ValueFn[str], + ): + ... + + # Test that args can **not** be supplied + def render_fn_sync(*args: str): + return " ".join(args) + + try: + TestTransformer( + render_fn_sync, + "X", # pyright: ignore[reportGeneralTypeIssues] + ) + raise RuntimeError() + except TypeError as e: + assert "Expected `params` to be of type `TransformerParams`" in str(e) + + +# "Currently, `ValueFn` can not be truly async and "support sync render methods" +@pytest.mark.asyncio +async def test_renderer_handler_fn_can_be_async(): + @output_transformer + async def AsyncTransformer( + _meta: TransformerMetadata, + _fn: ValueFn[str], + ) -> str: + # Actually sleep to test that the handler is truly async + await asyncio.sleep(0) + ret = await resolve_value_fn(_fn) + return ret + + # ## Setup overloads ============================================= + + @overload + def async_renderer() -> AsyncTransformer.OutputRendererDecorator: + ... + + @overload + def async_renderer( + _fn: AsyncTransformer.ValueFn, + ) -> AsyncTransformer.OutputRenderer: + ... + + def async_renderer( + _fn: AsyncTransformer.ValueFn | None = None, + ) -> AsyncTransformer.OutputRenderer | AsyncTransformer.OutputRendererDecorator: + return AsyncTransformer(_fn) + + test_val = "Test: Hello World!" + + def app_render_fn() -> str: + return test_val + + # ## Test Sync: X ============================================= + + renderer_sync = async_renderer(app_render_fn) + renderer_sync._set_metadata( + None, # pyright: ignore[reportGeneralTypeIssues] + "renderer_sync", + ) + if is_async_callable(renderer_sync): + raise RuntimeError("Expected `renderer_sync` to be a sync function") + + # !! This line is currently not possible !! + try: + ret = renderer_sync() + raise Exception("Expected an exception to occur while calling `renderer_sync`") + assert ret == test_val + except RuntimeError as e: + assert "async function yielded control" in str(e) + + # ## Test Async: √ ============================================= + + async_test_val = "Async: Hello World!" + + async def async_app_render_fn() -> str: + await asyncio.sleep(0) + return async_test_val + + renderer_async = async_renderer(async_app_render_fn) + renderer_async._set_metadata( + None, # pyright: ignore[reportGeneralTypeIssues] + "renderer_async", + ) + if not is_async_callable(renderer_async): + raise RuntimeError("Expected `renderer_async` to be a coro function") + + ret = await renderer_async() + assert ret == async_test_val + + +# "Currently, `ValueFnA` can not be truly async and "support sync render methods". +# Test that conditionally calling async works. +@pytest.mark.asyncio +async def test_renderer_handler_fn_can_be_yield_while_async(): + @output_transformer + async def YieldTransformer( + _meta: TransformerMetadata, + _fn: ValueFn[str], + ) -> str: + if is_async_callable(_fn): + # Actually sleep to test that the handler is truly async + await asyncio.sleep(0) + ret = await resolve_value_fn(_fn) + return ret + + # ## Setup overloads ============================================= + + @overload + def yield_renderer() -> YieldTransformer.OutputRendererDecorator: + ... + + @overload + def yield_renderer( + _fn: YieldTransformer.ValueFn, + ) -> YieldTransformer.OutputRenderer: + ... + + def yield_renderer( + _fn: YieldTransformer.ValueFn | None = None, + ) -> YieldTransformer.OutputRenderer | YieldTransformer.OutputRendererDecorator: + return YieldTransformer(_fn) + + test_val = "Test: Hello World!" + + def app_render_fn() -> str: + return test_val + + # ## Test Sync: √ ============================================= + + renderer_sync = yield_renderer(app_render_fn) + renderer_sync._set_metadata( + None, # pyright: ignore[reportGeneralTypeIssues] + "renderer_sync", + ) + if is_async_callable(renderer_sync): + raise RuntimeError("Expected `renderer_sync` to be a sync function") + + ret = renderer_sync() + assert ret == test_val + + # ## Test Async: √ ============================================= + + async_test_val = "Async: Hello World!" + + async def async_app_render_fn() -> str: + await asyncio.sleep(0) + return async_test_val + + renderer_async = yield_renderer(async_app_render_fn) + renderer_async._set_metadata( + None, # pyright: ignore[reportGeneralTypeIssues] + "renderer_async", + ) + if not is_async_callable(renderer_async): + raise RuntimeError("Expected `renderer_async` to be a coro function") + + ret = await renderer_async() + assert ret == async_test_val diff --git a/tests/test_utils.py b/tests/test_utils.py index 66e638a78..8f7d4b8dc 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -138,6 +138,7 @@ async def mutate_registrations(): # Timeout within 2 seconds @pytest.mark.timeout(2) +@pytest.mark.flaky(reruns=3, reruns_delay=1) def test_random_port(): assert random_port(9000, 9000) == 9000