diff --git a/CHANGELOG.md b/CHANGELOG.md index c56dae64b..2d50d213a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * prompt users to install `requirements.txt` * Fixed `js-react` template build error. (#965) +### Developer features + +* Output renderers should now be created with the `shiny.render.renderer.Renderer` class. This class should contain either a `.transform(self, value)` method (common) or a `.render(self)` (rare). These two methods should return something can be converted to JSON. In addition, `.default_ui(self, id)` should be implemented by returning `htmltools.Tag`-like content for use within Shiny Express. To make your own output renderer, please inherit from the `Renderer[IT]` class where `IT` is the type (excluding `None`) required to be returned from the App author. (#964) + +* `shiny.render.RenderFunction` and `shiny.render.RenderFunctionAsync` have been removed. They were deprecated in v0.6.0. Instead, please use `shiny.render.renderer.Renderer`. (#964) + +* When transforming values within `shiny.render.transformer.output_transformer` transform function, `shiny.render.transformer.resolve_value_fn` is no longer needed as the value function given to the output transformer is now **always** an asynchronous function. `resolve_value_fn(fn)` method has been deprecated. Please change your code from `value = await resolve_value_fn(_fn)` to `value = await _fn()`. (#964) + +* `shiny.render.OutputRendererSync` and `shiny.render.OutputRendererAsync` helper classes have been removed in favor of an updated `shiny.render.OutputRenderer` class. Now, the app's output value function will be transformed into an asynchronous function for simplified, consistent execution behavior. If redesigning your code, instead please create a new renderer that inherits from `shiny.render.renderer.Renderer`. (#964) ## [0.6.1.1] - 2023-12-22 diff --git a/docs/_quartodoc.yml b/docs/_quartodoc.yml index 247d75979..967abf63c 100644 --- a/docs/_quartodoc.yml +++ b/docs/_quartodoc.yml @@ -175,17 +175,11 @@ quartodoc: 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 + - render.renderer.Renderer + - render.renderer.RendererBase + - render.renderer.Jsonifiable + - render.renderer.ValueFn + - render.renderer.AsyncValueFn - title: Reactive programming desc: "" contents: @@ -330,6 +324,7 @@ quartodoc: - ui.panel_main - ui.panel_sidebar - ui.nav + - render.transformer.resolve_value_fn - title: Experimental desc: "These methods are under consideration and are considered unstable. However, if there is a method you are excited about, please let us know!" contents: diff --git a/shiny/_typing_extensions.py b/shiny/_typing_extensions.py index 93fe5647b..39cc1ac29 100644 --- a/shiny/_typing_extensions.py +++ b/shiny/_typing_extensions.py @@ -23,13 +23,13 @@ # they should both come from the same typing module. # https://peps.python.org/pep-0655/#usage-in-python-3-11 if sys.version_info >= (3, 11): - from typing import NotRequired, TypedDict, assert_type + from typing import NotRequired, Self, TypedDict, assert_type else: - from typing_extensions import NotRequired, TypedDict, assert_type + from typing_extensions import NotRequired, Self, TypedDict, assert_type # The only purpose of the following line is so that pyright will put all of the # conditional imports into the .pyi file when generating type stubs. Without this line, # pyright will not include the above imports in the generated .pyi file, and it will # result in a lot of red squiggles in user code. -_: 'Concatenate[str, ParamSpec("P")] | ParamSpec | TypeGuard | NotRequired | TypedDict | assert_type' # type:ignore +_: 'Concatenate[str, ParamSpec("P")] | ParamSpec | TypeGuard | NotRequired | TypedDict | assert_type | Self' # type:ignore diff --git a/shiny/_utils.py b/shiny/_utils.py index 3aa6a5517..23e67af6e 100644 --- a/shiny/_utils.py +++ b/shiny/_utils.py @@ -221,35 +221,89 @@ def private_seed() -> Generator[None, None, None]: # Async-related functions # ============================================================================== -T = TypeVar("T") +R = TypeVar("R") # Return type P = ParamSpec("P") def wrap_async( - fn: Callable[P, T] | Callable[P, Awaitable[T]] -) -> Callable[P, Awaitable[T]]: + fn: Callable[P, R] | Callable[P, Awaitable[R]] +) -> Callable[P, Awaitable[R]]: """ - Given a synchronous function that returns T, return an async function that wraps the + Given a synchronous function that returns R, return an async function that wraps the original function. If the input function is already async, then return it unchanged. """ if is_async_callable(fn): return fn - fn = cast(Callable[P, T], fn) + fn = cast(Callable[P, R], fn) @functools.wraps(fn) - async def fn_async(*args: P.args, **kwargs: P.kwargs) -> T: + async def fn_async(*args: P.args, **kwargs: P.kwargs) -> R: return fn(*args, **kwargs) return fn_async +# # TODO-barret-future; Q: Keep code? +# class WrapAsync(Generic[P, R]): +# """ +# Make a function asynchronous. + +# Parameters +# ---------- +# fn +# Function to make asynchronous. + +# Returns +# ------- +# : +# Asynchronous function (within the `WrapAsync` instance) +# """ + +# def __init__(self, fn: Callable[P, R] | Callable[P, Awaitable[R]]): +# if isinstance(fn, WrapAsync): +# fn = cast(WrapAsync[P, R], fn) +# return fn +# self._is_async = is_async_callable(fn) +# self._fn = wrap_async(fn) + +# async def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R: +# """ +# Call the asynchronous function. +# """ +# return await self._fn(*args, **kwargs) + +# @property +# def is_async(self) -> bool: +# """ +# Was the original function asynchronous? + +# Returns +# ------- +# : +# Whether the original function is asynchronous. +# """ +# return self._is_async + +# @property +# def fn(self) -> Callable[P, R] | Callable[P, Awaitable[R]]: +# """ +# Retrieve the original function + +# Returns +# ------- +# : +# Original function supplied to the `WrapAsync` constructor. +# """ +# return self._fn + + # 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]]]: + obj: Callable[P, R] | Callable[P, Awaitable[R]] +) -> TypeGuard[Callable[P, Awaitable[R]]]: """ Determine if an object is an async function. @@ -282,7 +336,7 @@ def is_async_callable( # of how this stuff works. # For a more in-depth explanation, see # https://snarky.ca/how-the-heck-does-async-await-work-in-python-3-5/. -def run_coro_sync(coro: Awaitable[T]) -> T: +def run_coro_sync(coro: Awaitable[R]) -> R: """ Run a coroutine that is in fact synchronous. Given a coroutine (which is returned by calling an `async def` function), this function will run the @@ -310,7 +364,7 @@ def run_coro_sync(coro: Awaitable[T]) -> T: ) -def run_coro_hybrid(coro: Awaitable[T]) -> "asyncio.Future[T]": +def run_coro_hybrid(coro: Awaitable[R]) -> "asyncio.Future[R]": """ Synchronously runs the given coro up to its first yield, then runs the rest of the coro by scheduling it on the current event loop, as per normal. You can think of @@ -325,7 +379,7 @@ def run_coro_hybrid(coro: Awaitable[T]) -> "asyncio.Future[T]": asyncio Task implementation, this is a hastily assembled hack job; who knows what unknown unknowns lurk here. """ - result_future: asyncio.Future[T] = asyncio.Future() + result_future: asyncio.Future[R] = asyncio.Future() if not inspect.iscoroutine(coro): raise TypeError("run_coro_hybrid requires a Coroutine object.") diff --git a/shiny/api-examples/Renderer/app.py b/shiny/api-examples/Renderer/app.py new file mode 100644 index 000000000..39caefc31 --- /dev/null +++ b/shiny/api-examples/Renderer/app.py @@ -0,0 +1,182 @@ +from __future__ import annotations + +from typing import Literal + +from shiny import App, Inputs, Outputs, Session, ui +from shiny.render.renderer import Renderer, ValueFn + +####### +# Start of package author code +####### + + +class render_capitalize(Renderer[str]): + # The documentation for the class will be displayed when the user hovers over the + # decorator when **no** parenthesis are used. Ex: `@render_capitalize` + # If no documentation is supplied to the `__init__()` method, then this + # documentation will be displayed when parenthesis are used on the decorator. + """ + Render capitalize class documentation goes here. + """ + + to_case: Literal["upper", "lower", "ignore"] + """ + The case to render the value in. + """ + placeholder: bool + """ + Whether to render a placeholder value. (Defaults to `True`) + """ + + def default_ui(self, id: str): + """ + Express UI for the renderer + """ + return ui.output_text_verbatim(id, placeholder=self.placeholder) + + def __init__( + self, + _fn: ValueFn[str | None] | None = None, + *, + to_case: Literal["upper", "lower", "ignore"] = "upper", + placeholder: bool = True, + ) -> None: + # If a different set of documentation is supplied to the `__init__` method, + # then this documentation will be displayed when parenthesis are used on the decorator. + # Ex: `@render_capitalize()` + """ + Render capitalize documentation goes here. + + It is a good idea to talk about parameters here! + + Parameters + ---------- + to_case + The case to render the value. (`"upper"`) + + Options: + - `"upper"`: Render the value in upper case. + - `"lower"`: Render the value in lower case. + - `"ignore"`: Do not alter the case of the value. + + placeholder + Whether to render a placeholder value. (`True`) + """ + # Do not pass params + super().__init__(_fn) + self.widget = None + self.to_case = to_case + + async def render(self) -> str | None: + value = await self.value_fn() + if value is None: + # If `None` is returned, then do not render anything. + return None + + ret = str(value) + if self.to_case == "upper": + return ret.upper() + if self.to_case == "lower": + return ret.lower() + if self.to_case == "ignore": + return ret + raise ValueError(f"Invalid value for `to_case`: {self.to_case}") + + +class render_upper(Renderer[str]): + """ + Minimal capitalize string transformation renderer. + + No parameters are supplied to this renderer. This allows us to skip the `__init__()` + method and `__init__()` documentation. If you hover over this decorator with and + without parenthesis, you will see this documentation in both situations. + + Note: This renderer is equivalent to `render_capitalize(to="upper")`. + """ + + def default_ui(self, id: str): + """ + Express UI for the renderer + """ + return ui.output_text_verbatim(id, placeholder=True) + + async def transform(self, value: str) -> str: + """ + Transform the value to upper case. + + This method is shorthand for the default `render()` method. It is useful to + transform non-`None` values. (Any `None` value returned by the app author will + be forwarded to the browser.) + + Parameters + ---------- + value + The a non-`None` value to transform. + + Returns + ------- + str + The transformed value. (Must be a subset of `Jsonifiable`.) + """ + + return str(value).upper() + + +####### +# End of package author code +####### + + +####### +# Start of app author code +####### + + +def text_row(id: str, label: str): + return ui.tags.tr( + ui.tags.td(f"{label}:"), + ui.tags.td(ui.output_text_verbatim(id, placeholder=True)), + ) + return ui.row( + ui.column(6, f"{id}:"), + ui.column(6, ui.output_text_verbatim(id, placeholder=True)), + ) + + +app_ui = ui.page_fluid( + ui.h1("Capitalization renderer"), + ui.input_text("caption", "Caption:", "Data summary"), + ui.tags.table( + text_row("upper", "@render_upper"), + text_row("upper_with_paren", "@render_upper()"), + # + text_row("cap_upper", "@render_capitalize"), + text_row("cap_lower", "@render_capitalize(to='lower')"), + ), +) + + +def server(input: Inputs, output: Outputs, session: Session): + # Hovering over `@render_upper` will display the class documentation + @render_upper + def upper(): + return input.caption() + + # Hovering over `@render_upper` will display the class documentation as there is no + # `__init__()` documentation + @render_upper() + def upper_with_paren(): + return input.caption() + + # Hovering over `@render_capitalize` will display the class documentation + @render_capitalize + def cap_upper(): + return input.caption() + + # Hovering over `@render_capitalize` will display the `__init__()` documentation + @render_capitalize(to_case="lower") + def cap_lower(): + return input.caption() + + +app = App(app_ui, server) diff --git a/shiny/api-examples/output_transformer/app.py b/shiny/api-examples/output_transformer/app.py index 568b102b2..b707fcb8e 100644 --- a/shiny/api-examples/output_transformer/app.py +++ b/shiny/api-examples/output_transformer/app.py @@ -11,6 +11,10 @@ ) ####### +# DEPRECATED. Please see `shiny.render.renderer.Renderer` for the latest API. +# This example is kept for backwards compatibility. +# +# # Package authors can create their own output transformer methods by leveraging # `output_transformer` decorator. # diff --git a/shiny/express/__init__.py b/shiny/express/__init__.py index 062a94c94..1c430267f 100644 --- a/shiny/express/__init__.py +++ b/shiny/express/__init__.py @@ -6,7 +6,11 @@ from ..session import _utils as _session_utils from . import ui from ._is_express import is_express_app -from ._output import output_args, suspend_display +from ._output import ( # noqa: F401 + ui_kwargs, + suspend_display, + output_args, # pyright: ignore[reportUnusedImport] +) from ._run import wrap_express_app from .display_decorator import display_body @@ -15,7 +19,7 @@ "output", "session", "is_express_app", - "output_args", + "ui_kwargs", "suspend_display", "wrap_express_app", "ui", diff --git a/shiny/express/_output.py b/shiny/express/_output.py index 6c72e6f7e..6ab1362d8 100644 --- a/shiny/express/_output.py +++ b/shiny/express/_output.py @@ -3,27 +3,63 @@ import contextlib import sys from contextlib import AbstractContextManager -from typing import Callable, Generator, TypeVar, cast, overload +from typing import Callable, Generator, TypeVar, overload from .. import ui from .._typing_extensions import ParamSpec +from ..render.renderer import RendererBase, RendererBaseT from ..render.transformer import OutputRenderer +from ..render.transformer._transformer import OT __all__ = ( - "output_args", + "ui_kwargs", "suspend_display", ) -OT = TypeVar("OT") P = ParamSpec("P") R = TypeVar("R") CallableT = TypeVar("CallableT", bound=Callable[..., object]) +# TODO-barret-future; quartodoc entry? +def ui_kwargs( + **kwargs: object, +) -> Callable[[RendererBaseT], RendererBaseT]: + """ + Sets default UI arguments for a Shiny rendering function. + + Each Shiny render function (like :func:`~shiny.render.plot`) can display itself when + declared within a Shiny inline-style application. In the case of + :func:`~shiny.render.plot`, the :func:`~shiny.ui.output_plot` function is called + implicitly to display the plot. Use the `@ui_kwargs` decorator to specify + arguments to be passed to `output_plot` (or whatever the corresponding UI function + is) when the render function displays itself. + + Parameters + ---------- + **kwargs + Keyword arguments to be passed to the UI function. + + Returns + ------- + : + A decorator that sets the default UI arguments for a Shiny rendering function. + """ + + def wrapper(renderer: RendererBaseT) -> RendererBaseT: + # renderer._default_ui_args = args + renderer._default_ui_kwargs = kwargs + return renderer + + return wrapper + + def output_args( - *args: object, **kwargs: object + *args: object, + **kwargs: object, ) -> Callable[[OutputRenderer[OT]], OutputRenderer[OT]]: - """Sets default UI arguments for a Shiny rendering function. + """ + Sets default UI arguments for a Shiny rendering function. Each Shiny render function (like :func:`~shiny.render.plot`) can display itself when declared within a Shiny inline-style application. In the case of @@ -32,6 +68,7 @@ def output_args( arguments to be passed to `output_plot` (or whatever the corresponding UI function is) when the render function displays itself. + Parameters ---------- *args @@ -46,8 +83,15 @@ def output_args( """ def wrapper(renderer: OutputRenderer[OT]) -> OutputRenderer[OT]: - renderer.default_ui_args = args - renderer.default_ui_kwargs = kwargs + if not isinstance(renderer, OutputRenderer): + raise TypeError( + f"Expected an OutputRenderer, but got {type(renderer).__name__}." + "\nIf you are trying to set default UI arguments for a `Renderer`, use" + " `@ui_kwargs` instead." + ) + renderer._default_ui_args = args + renderer._default_ui_kwargs = kwargs + return renderer return wrapper @@ -58,14 +102,19 @@ def suspend_display(fn: CallableT) -> CallableT: ... +@overload +def suspend_display(fn: RendererBaseT) -> RendererBaseT: + ... + + @overload def suspend_display() -> AbstractContextManager[None]: ... def suspend_display( - fn: Callable[P, R] | OutputRenderer[OT] | None = None -) -> Callable[P, R] | OutputRenderer[OT] | AbstractContextManager[None]: + fn: Callable[P, R] | RendererBaseT | None = None +) -> Callable[P, R] | RendererBaseT | AbstractContextManager[None]: """Suppresses the display of UI elements in various ways. If used as a context manager (`with suspend_display():`), it suppresses the display @@ -99,11 +148,12 @@ def suspend_display( if fn is None: return suspend_display_ctxmgr() - # Special case for OutputRenderer; when we decorate those, we just mean "don't + # Special case for RendererBase; when we decorate those, we just mean "don't # display yourself" - if isinstance(fn, OutputRenderer): + if isinstance(fn, RendererBase): + # By setting the class value, the `self` arg will be auto added. fn.default_ui = null_ui - return cast(Callable[P, R], fn) + return fn return suspend_display_ctxmgr()(fn) @@ -118,7 +168,11 @@ def suspend_display_ctxmgr() -> Generator[None, None, None]: sys.displayhook = oldhook -def null_ui(id: str, *args: object, **kwargs: object) -> ui.TagList: +def null_ui( + id: str, + *args: object, + **kwargs: object, +) -> ui.TagList: return ui.TagList() diff --git a/shiny/reactive/_reactives.py b/shiny/reactive/_reactives.py index c2da6c232..54c855579 100644 --- a/shiny/reactive/_reactives.py +++ b/shiny/reactive/_reactives.py @@ -811,9 +811,9 @@ def decorator(user_fn: Callable[[], T]) -> Callable[[], T]: # This is here instead of at the top of the .py file in order to avoid a # circular dependency. - from ..render.transformer import OutputRenderer + from ..render.renderer import RendererBase - if isinstance(user_fn, OutputRenderer): + if isinstance(user_fn, RendererBase): # 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 8e9d32551..ffd4460a5 100644 --- a/shiny/render/__init__.py +++ b/shiny/render/__init__.py @@ -11,10 +11,6 @@ DataTable, data_frame, ) -from ._deprecated import ( # noqa: F401 - RenderFunction, # pyright: ignore[reportUnusedImport] - RenderFunctionAsync, # pyright: ignore[reportUnusedImport] -) from ._display import ( display, ) diff --git a/shiny/render/_dataframe.py b/shiny/render/_dataframe.py index dc351588e..246aab89f 100644 --- a/shiny/render/_dataframe.py +++ b/shiny/render/_dataframe.py @@ -2,26 +2,14 @@ import abc import json -from typing import ( - TYPE_CHECKING, - Any, - Literal, - Protocol, - Union, - cast, - overload, - runtime_checkable, -) +from typing import TYPE_CHECKING, Any, Literal, Protocol, Union, cast, runtime_checkable + +from htmltools import Tag from .. import ui from .._docstring import add_example from ._dataframe_unsafe import serialize_numpy_dtypes -from .transformer import ( - TransformerMetadata, - ValueFn, - output_transformer, - resolve_value_fn, -) +from .renderer import Jsonifiable, Renderer if TYPE_CHECKING: import pandas as pd @@ -29,7 +17,7 @@ class AbstractTabularData(abc.ABC): @abc.abstractmethod - def to_payload(self) -> object: + def to_payload(self) -> Jsonifiable: ... @@ -106,7 +94,7 @@ def __init__( self.filters = filters self.row_selection_mode = row_selection_mode - def to_payload(self) -> object: + def to_payload(self) -> Jsonifiable: res = serialize_pandas_df(self.data) res["options"] = dict( width=self.width, @@ -194,7 +182,7 @@ def __init__( self.filters = filters self.row_selection_mode = row_selection_mode - def to_payload(self) -> object: + def to_payload(self) -> Jsonifiable: res = serialize_pandas_df(self.data) res["options"] = dict( width=self.width, @@ -225,44 +213,12 @@ def serialize_pandas_df(df: "pd.DataFrame") -> dict[str, Any]: DataFrameResult = Union[None, "pd.DataFrame", DataGrid, DataTable] -@output_transformer(default_ui=ui.output_data_frame) -async def DataFrameTransformer( - _meta: TransformerMetadata, - _fn: ValueFn[DataFrameResult | None], -) -> object | None: - x = await resolve_value_fn(_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() - - -@overload -def data_frame() -> DataFrameTransformer.OutputRendererDecorator: - ... - - -@overload -def data_frame( - _fn: DataFrameTransformer.ValueFn, -) -> DataFrameTransformer.OutputRenderer: - ... - - @add_example() -def data_frame( - _fn: DataFrameTransformer.ValueFn | None = None, -) -> DataFrameTransformer.OutputRenderer | DataFrameTransformer.OutputRendererDecorator: +class data_frame(Renderer[DataFrameResult]): """ - Reactively render a pandas `DataFrame` object (or similar) as an interactive table or - grid. Features fast virtualized scrolling, sorting, filtering, and row selection - (single or multiple). + Decorator for a function that returns a pandas `DataFrame` object (or similar) to + render as an interactive table or grid. Features fast virtualized scrolling, sorting, + filtering, and row selection (single or multiple). Returns ------- @@ -299,7 +255,19 @@ def data_frame( * :class:`~shiny.render.DataGrid` and :class:`~shiny.render.DataTable` are the objects you can return from the rendering function to specify options. """ - return DataFrameTransformer(_fn) + + def default_ui(self, id: str) -> Tag: + return ui.output_data_frame(id=id) + + async def transform(self, value: DataFrameResult) -> Jsonifiable: + if not isinstance(value, AbstractTabularData): + value = DataGrid( + cast_to_pandas( + value, + "@render.data_frame doesn't know how to render objects of type", + ) + ) + return value.to_payload() @runtime_checkable diff --git a/shiny/render/_deprecated.py b/shiny/render/_deprecated.py deleted file mode 100644 index 01b957e49..000000000 --- a/shiny/render/_deprecated.py +++ /dev/null @@ -1,79 +0,0 @@ -from __future__ import annotations - -from abc import ABC, abstractmethod -from typing import Generic - -from .transformer._transformer import ( - IT, - OT, - OutputRendererAsync, - OutputRendererSync, - TransformerMetadata, - ValueFn, - ValueFnAsync, - ValueFnSync, - empty_params, -) - -# ====================================================================================== -# 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=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=empty_params(), - ) - self._fn = fn diff --git a/shiny/render/_display.py b/shiny/render/_display.py index b9eb002ee..ca19efd2f 100644 --- a/shiny/render/_display.py +++ b/shiny/render/_display.py @@ -1,137 +1,109 @@ from __future__ import annotations -import inspect import sys -from typing import Any, Callable, Optional, Union, overload +from typing import Optional -from htmltools import TagAttrValue, TagFunction, TagList, wrap_displayhook_handler +from htmltools import Tag, TagAttrValue, TagFunction, TagList, wrap_displayhook_handler from .. import ui as _ui -from ..session._utils import RenderedDeps -from .transformer import ( - OutputRendererSync, - TransformerMetadata, - TransformerParams, - ValueFn, - ValueFnSync, +from .._typing_extensions import Self +from ..session._utils import require_active_session +from ..types import MISSING, MISSING_TYPE +from .renderer import AsyncValueFn, Renderer, ValueFn +from .renderer._utils import ( + JsonifiableDict, + rendered_deps_to_jsonifiable, + set_kwargs_value, ) -async def DisplayTransformer( - _meta: TransformerMetadata, - _fn: ValueFn[None], - *, - inline: bool = False, - container: Optional[TagFunction] = None, - fill: bool = False, - fillable: bool = False, - **kwargs: TagAttrValue, -) -> RenderedDeps | None: - results: list[object] = [] - orig_displayhook = sys.displayhook - sys.displayhook = wrap_displayhook_handler(results.append) - try: - x = _fn() - if inspect.iscoroutine(x): +class display(Renderer[None]): + def default_ui( + self, + id: str, + *, + inline: bool | MISSING_TYPE = MISSING, + container: TagFunction | MISSING_TYPE = MISSING, + fill: bool | MISSING_TYPE = MISSING, + fillable: bool | MISSING_TYPE = MISSING, + **kwargs: TagAttrValue, + ) -> Tag: + # Only set the arg if it is available. (Prevents duplicating default values) + set_kwargs_value(kwargs, "inline", inline, self.inline) + set_kwargs_value(kwargs, "container", container, self.container) + set_kwargs_value(kwargs, "fill", fill, self.fill) + set_kwargs_value(kwargs, "fillable", fillable, self.fillable) + + return _ui.output_ui( + id, + # (possibly) contains `inline`, `container`, `fill`, and `fillable` keys! + **kwargs, # pyright: ignore[reportGeneralTypeIssues] + ) + + def __call__(self, fn: ValueFn[None]) -> Self: + if fn is None: + raise TypeError("@render.display requires a function when called") + + async_fn = AsyncValueFn(fn) + if async_fn.is_async(): raise TypeError( "@render.display does not support async functions. Use @render.ui instead." ) - finally: - sys.displayhook = orig_displayhook - if len(results) == 0: - return None - return _meta.session._process_ui( - TagList(*results) # pyright: ignore[reportGeneralTypeIssues] - ) - - -DisplayRenderer = OutputRendererSync[Union[RenderedDeps, None]] - - -@overload -def display( - *, - inline: bool = False, - container: Optional[TagFunction] = None, - fill: bool = False, - fillable: bool = False, - **kwargs: Any, -) -> Callable[[ValueFnSync[None]], DisplayRenderer]: - ... - - -@overload -def display( - _fn: ValueFnSync[None], -) -> DisplayRenderer: - ... - - -def display( - _fn: ValueFnSync[None] | None = None, - *, - inline: bool = False, - container: Optional[TagFunction] = None, - fill: bool = False, - fillable: bool = False, - **kwargs: Any, -) -> DisplayRenderer | Callable[[ValueFnSync[None]], DisplayRenderer]: - """ - Reactively render UI content, emitting each top-level expression of the function - body, in the same way as a Shiny Express top-level or Jupyter notebook cell. - - Parameters - ---------- - inline - If ``True``, the rendered content will be displayed inline with the surrounding - text. If ``False``, the rendered content will be displayed on its own line. If - the ``container`` argument is not ``None``, this argument is ignored. - container - A function that returns a container for the rendered content. If ``None``, a - default container will be chosen according to the ``inline`` argument. - fill - Whether or not to allow the UI output to grow/shrink to fit a fillable container - with an opinionated height (e.g., :func:`~shiny.ui.page_fillable`). - fillable - Whether or not the UI output area should be considered a fillable (i.e., - flexbox) container. - **kwargs - Attributes to be applied to the output container. - - - Returns - ------- - : - A decorator for a function whose top-level expressions will be displayed as UI. - """ - - def impl(fn: ValueFnSync[None]) -> OutputRendererSync[RenderedDeps | None]: + from shiny.express.display_decorator._display_body import ( display_body_unwrap_inplace, ) fn = display_body_unwrap_inplace()(fn) - return OutputRendererSync( - fn, - DisplayTransformer, - TransformerParams( - inline=inline, - container=container, - fill=fill, - fillable=fillable, - **kwargs, - ), - default_ui=_ui.output_ui, - default_ui_passthrough_args=( - "inline", - "container", - "fill", - "fillable", - *[k for k in kwargs.keys()], - ), - ) - if _fn is not None: - return impl(_fn) - else: - return impl + # Call the superclass method with upgraded `fn` value + super().__call__(fn) + + return self + + def __init__( + self, + _fn: ValueFn[None] = None, + *, + inline: bool = False, + container: Optional[TagFunction] = None, + fill: bool = False, + fillable: bool = False, + **kwargs: TagAttrValue, + ): + super().__init__(_fn) + self.inline: bool = inline + self.container: Optional[TagFunction] = container + self.fill: bool = fill + self.fillable: bool = fillable + self.kwargs: dict[str, TagAttrValue] = kwargs + + async def render(self) -> JsonifiableDict | None: + results: list[object] = [] + orig_displayhook = sys.displayhook + sys.displayhook = wrap_displayhook_handler(results.append) + + if self.value_fn.is_async(): + raise TypeError( + "@render.display does not support async functions. Use @render.ui instead." + ) + + try: + # Run synchronously + sync_value_fn = self.value_fn.get_sync_fn() + ret = sync_value_fn() + if ret is not None: + raise RuntimeError( + "@render.display functions should not return values. (`None` is allowed)." + ) + finally: + sys.displayhook = orig_displayhook + if len(results) == 0: + return None + + session = require_active_session(None) + return rendered_deps_to_jsonifiable( + session._process_ui( + TagList(*results) # pyright: ignore[reportGeneralTypeIssues] + ) + ) diff --git a/shiny/render/_render.py b/shiny/render/_render.py index 9c0d986e0..e574535f2 100644 --- a/shiny/render/_render.py +++ b/shiny/render/_render.py @@ -1,30 +1,23 @@ from __future__ import annotations -__all__ = ( - "text", - "plot", - "image", - "table", - "ui", -) - import base64 import os import sys import typing + +# `typing.Dict` sed for python 3.8 compatibility +# Can use `dict` in python >= 3.9 from typing import ( TYPE_CHECKING, - Any, Literal, Optional, Protocol, Union, cast, - overload, runtime_checkable, ) -from htmltools import TagChild +from htmltools import Tag, TagAttrValue, TagChild if TYPE_CHECKING: from ..session._utils import RenderedDeps @@ -33,6 +26,7 @@ from .. import _utils from .. import ui as _ui from .._namespaces import ResolvedId +from ..session import require_active_session from ..types import MISSING, MISSING_TYPE, ImgData from ._try_render_plot import ( PlotSizeInfo, @@ -40,43 +34,26 @@ try_render_pil, try_render_plotnine, ) -from .transformer import ( - TransformerMetadata, - ValueFn, - is_async_callable, - output_transformer, - resolve_value_fn, +from .renderer import Jsonifiable, Renderer, ValueFn +from .renderer._utils import ( + imgdata_to_jsonifiable, + rendered_deps_to_jsonifiable, + set_kwargs_value, ) +__all__ = ( + "text", + "plot", + "image", + "table", + "ui", +) # ====================================================================================== # RenderText # ====================================================================================== -@output_transformer(default_ui=_ui.output_text_verbatim) -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() -> TextTransformer.OutputRendererDecorator: - ... - - -@overload -def text(_fn: TextTransformer.ValueFn) -> TextTransformer.OutputRenderer: - ... - - -def text( - _fn: TextTransformer.ValueFn | None = None, -) -> TextTransformer.OutputRenderer | TextTransformer.OutputRendererDecorator: +class text(Renderer[str]): """ Reactively render text. @@ -95,7 +72,14 @@ def text( -------- ~shiny.ui.output_text """ - return TextTransformer(_fn) + + def default_ui(self, id: str, placeholder: bool | MISSING_TYPE = MISSING) -> Tag: + kwargs: dict[str, bool] = {} + set_kwargs_value(kwargs, "placeholder", placeholder, None) + return _ui.output_text_verbatim(id, **kwargs) + + async def transform(self, value: str) -> Jsonifiable: + return str(value) # ====================================================================================== @@ -103,145 +87,13 @@ def text( # ====================================================================================== -# It would be nice to specify the return type of RenderPlotFunc to be something like: +# It would be nice to specify the return type of ValueFn to be something like: # 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`. -@output_transformer( - default_ui=_ui.output_plot, default_ui_passthrough_args=("width", "height") -) -async def PlotTransformer( - _meta: TransformerMetadata, - _fn: ValueFn[object], - *, - alt: Optional[str] = None, - width: float | None | MISSING_TYPE = MISSING, - height: float | None | MISSING_TYPE = MISSING, - **kwargs: object, -) -> ImgData | None: - is_userfn_async = is_async_callable(_fn) - name = _meta.name - session = _meta.session - - inputs = session.root_scope().input - - # We don't have enough information at this point to decide what size the plot should - # be. This is because the user's plotting code itself may express an opinion about - # the plot size. We'll take the information we will need and stash it in - # PlotSizeInfo, which then gets passed into the various plotting strategies. - - # Reactively read some information about the plot. - pixelratio: float = typing.cast( - float, inputs[ResolvedId(".clientdata_pixelratio")]() - ) - - # Do NOT call this unless you actually are going to respect the container dimension - # you're asking for. It takes a reactive dependency. If the client hasn't reported - # the requested dimension, you'll get a SilentException. - def container_size(dimension: Literal["width", "height"]) -> float: - result = inputs[ResolvedId(f".clientdata_output_{name}_{dimension}")]() - return typing.cast(float, result) - - non_missing_size = ( - cast(Union[float, None], width) if width is not MISSING else None, - cast(Union[float, None], height) if height is not MISSING else None, - ) - plot_size_info = PlotSizeInfo( - container_size_px_fn=( - lambda: container_size("width"), - lambda: container_size("height"), - ), - user_specified_size_px=non_missing_size, - pixelratio=pixelratio, - ) - - # 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, - plot_size_info=plot_size_info, - alt=alt, - **kwargs, - ) - if ok: - return result - - if "matplotlib" in sys.modules: - ok, result = try_render_matplotlib( - x, - plot_size_info=plot_size_info, - allow_global=not is_userfn_async, - alt=alt, - **kwargs, - ) - if ok: - return result - - if "PIL" in sys.modules: - ok, result = try_render_pil( - x, - plot_size_info=plot_size_info, - alt=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 - - 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 -def plot( - *, - alt: Optional[str] = None, - width: float | None | MISSING_TYPE = MISSING, - height: float | None | MISSING_TYPE = MISSING, - **kwargs: Any, -) -> PlotTransformer.OutputRendererDecorator: - ... - - -@overload -def plot(_fn: PlotTransformer.ValueFn) -> PlotTransformer.OutputRenderer: - ... - - -def plot( - _fn: PlotTransformer.ValueFn | None = None, - *, - alt: Optional[str] = None, - width: float | None | MISSING_TYPE = MISSING, - height: float | None | MISSING_TYPE = MISSING, - **kwargs: Any, -) -> PlotTransformer.OutputRenderer | PlotTransformer.OutputRendererDecorator: + + +class plot(Renderer[object]): """ Reactively render a plot object as an HTML image. @@ -293,56 +145,152 @@ def plot( -------- ~shiny.ui.output_plot ~shiny.render.image """ - return PlotTransformer( - _fn, PlotTransformer.params(alt=alt, width=width, height=height, **kwargs) - ) + + def default_ui( + self, + id: str, + *, + width: str | float | int | MISSING_TYPE = MISSING, + height: str | float | int | MISSING_TYPE = MISSING, + **kwargs: object, + ) -> Tag: + # Only set the arg if it is available. (Prevents duplicating default values) + set_kwargs_value(kwargs, "width", width, self.width) + set_kwargs_value(kwargs, "height", height, self.height) + return _ui.output_plot( + id, + # (possibly) contains `width` and `height` keys! + **kwargs, # pyright: ignore[reportGeneralTypeIssues] + ) + + def __init__( + self, + fn: Optional[ValueFn[object]] = None, + *, + alt: Optional[str] = None, + width: float | None | MISSING_TYPE = MISSING, + height: float | None | MISSING_TYPE = MISSING, + **kwargs: object, + ) -> None: + super().__init__(fn) + self.alt = alt + self.width = width + self.height = height + self.kwargs = kwargs + + async def render(self) -> dict[str, Jsonifiable] | Jsonifiable | None: + is_userfn_async = self.value_fn.is_async() + name = self.output_id + session = require_active_session(None) + width = self.width + height = self.height + alt = self.alt + kwargs = self.kwargs + + inputs = session.root_scope().input + + # We don't have enough information at this point to decide what size the plot should + # be. This is because the user's plotting code itself may express an opinion about + # the plot size. We'll take the information we will need and stash it in + # PlotSizeInfo, which then gets passed into the various plotting strategies. + + # Reactively read some information about the plot. + pixelratio: float = typing.cast( + float, inputs[ResolvedId(".clientdata_pixelratio")]() + ) + + # Do NOT call this unless you actually are going to respect the container dimension + # you're asking for. It takes a reactive dependency. If the client hasn't reported + # the requested dimension, you'll get a SilentException. + def container_size(dimension: Literal["width", "height"]) -> float: + result = inputs[ResolvedId(f".clientdata_output_{name}_{dimension}")]() + return typing.cast(float, result) + + non_missing_size = ( + cast(Union[float, None], width) if width is not MISSING else None, + cast(Union[float, None], height) if height is not MISSING else None, + ) + plot_size_info = PlotSizeInfo( + container_size_px_fn=( + lambda: container_size("width"), + lambda: container_size("height"), + ), + user_specified_size_px=non_missing_size, + pixelratio=pixelratio, + ) + + # Call the user function to get the plot object. + x = await self.value_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 + + def cast_result(result: ImgData | None) -> dict[str, Jsonifiable] | None: + if result is None: + return None + return imgdata_to_jsonifiable(result) + + if "plotnine" in sys.modules: + ok, result = try_render_plotnine( + x, + plot_size_info=plot_size_info, + alt=alt, + **kwargs, + ) + if ok: + return cast_result(result) + + if "matplotlib" in sys.modules: + ok, result = try_render_matplotlib( + x, + plot_size_info=plot_size_info, + allow_global=not is_userfn_async, + alt=alt, + **kwargs, + ) + if ok: + return cast_result(result) + + if "PIL" in sys.modules: + ok, result = try_render_pil( + x, + plot_size_info=plot_size_info, + alt=alt, + **kwargs, + ) + if ok: + return cast_result(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." + ) # ====================================================================================== # RenderImage # ====================================================================================== -@output_transformer(default_ui=_ui.output_image) -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( - *, - delete_file: bool = False, -) -> ImageTransformer.OutputRendererDecorator: - ... - - -@overload -def image(_fn: ImageTransformer.ValueFn) -> ImageTransformer.OutputRenderer: - ... - - -def image( - _fn: ImageTransformer.ValueFn | None = None, - *, - delete_file: bool = False, -) -> ImageTransformer.OutputRendererDecorator | ImageTransformer.OutputRenderer: +class image(Renderer[ImgData]): """ Reactively render a image file as an HTML image. @@ -368,7 +316,34 @@ def image( ~shiny.types.ImgData ~shiny.render.plot """ - return ImageTransformer(_fn, ImageTransformer.params(delete_file=delete_file)) + + def default_ui(self, id: str, **kwargs: object): + return _ui.output_image( + id, + **kwargs, # pyright: ignore[reportGeneralTypeIssues] + ) + + def __init__( + self, + fn: Optional[ValueFn[ImgData]] = None, + *, + delete_file: bool = False, + ) -> None: + super().__init__(fn) + self.delete_file: bool = delete_file + + async def transform(self, value: ImgData) -> dict[str, Jsonifiable] | None: + src: str = value.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) + value["src"] = f"data:{content_type};base64,{data_str}" + return imgdata_to_jsonifiable(value) + finally: + if self.delete_file: + os.remove(src) # ====================================================================================== @@ -386,76 +361,7 @@ def to_pandas(self) -> "pd.DataFrame": TableResult = Union["pd.DataFrame", PandasCompatible, None] -@output_transformer(default_ui=_ui.output_table) -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(**kwargs), # pyright: ignore - ) - 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 - index=index, - classes=classes, - border=border, - **kwargs, # pyright: ignore[reportGeneralTypeIssues] - ), - ) - return {"deps": [], "html": html} - - -@overload -def table( - *, - index: bool = False, - classes: str = "table shiny-table w-auto", - border: int = 0, - **kwargs: Any, -) -> TableTransformer.OutputRendererDecorator: - ... - - -@overload -def table(_fn: TableTransformer.ValueFn) -> TableTransformer.OutputRenderer: - ... - - -def table( - _fn: TableTransformer.ValueFn | None = None, - *, - index: bool = False, - classes: str = "table shiny-table w-auto", - border: int = 0, - **kwargs: object, -) -> TableTransformer.OutputRenderer | TableTransformer.OutputRendererDecorator: +class table(Renderer[TableResult]): """ Reactively render a pandas ``DataFrame`` object (or similar) as a basic HTML table. @@ -501,45 +407,63 @@ def table( -------- ~shiny.ui.output_table for the corresponding UI component to this render function. """ - return TableTransformer( - _fn, - TableTransformer.params( - index=index, - classes=classes, - border=border, - **kwargs, - ), - ) + + def default_ui(self, id: str, **kwargs: TagAttrValue) -> Tag: + return _ui.output_table(id, **kwargs) + + def __init__( + self, + fn: Optional[ValueFn[TableResult]] = None, + *, + index: bool = False, + classes: str = "table shiny-table w-auto", + border: int = 0, + **kwargs: object, + ) -> None: + super().__init__(fn) + self.index: bool = index + self.classes: str = classes + self.border: int = border + self.kwargs: dict[str, object] = kwargs + + async def transform(self, value: TableResult) -> dict[str, Jsonifiable]: + import pandas + import pandas.io.formats.style + + html: str + if isinstance(value, pandas.io.formats.style.Styler): + html = cast( # pyright: ignore[reportUnnecessaryCast] + str, + value.to_html(**self.kwargs), # pyright: ignore + ) + else: + if not isinstance(value, pandas.DataFrame): + if not isinstance(value, PandasCompatible): + raise TypeError( + "@render.table doesn't know how to render objects of type " + f"'{str(type(value))}'. Return either a pandas.DataFrame, or an object " + "that has a .to_pandas() method." + ) + value = value.to_pandas() + + html = cast( # pyright: ignore[reportUnnecessaryCast] + str, + value.to_html( # pyright: ignore + index=self.index, + classes=self.classes, + border=self.border, + **self.kwargs, # pyright: ignore[reportGeneralTypeIssues] + ), + ) + # Use typing to make sure the return shape matches + ret: RenderedDeps = {"deps": [], "html": html} + return rendered_deps_to_jsonifiable(ret) # ====================================================================================== # RenderUI # ====================================================================================== -@output_transformer(default_ui=_ui.output_ui) -async def UiTransformer( - _meta: TransformerMetadata, - _fn: ValueFn[TagChild], -) -> RenderedDeps | None: - ui = await resolve_value_fn(_fn) - if ui is None: - return None - - return _meta.session._process_ui(ui) - - -@overload -def ui() -> UiTransformer.OutputRendererDecorator: - ... - - -@overload -def ui(_fn: UiTransformer.ValueFn) -> UiTransformer.OutputRenderer: - ... - - -def ui( - _fn: UiTransformer.ValueFn | None = None, -) -> UiTransformer.OutputRenderer | UiTransformer.OutputRendererDecorator: +class ui(Renderer[TagChild]): """ Reactively render HTML content. @@ -559,4 +483,12 @@ def ui( -------- ~shiny.ui.output_ui """ - return UiTransformer(_fn) + + def default_ui(self, id: str) -> Tag: + return _ui.output_ui(id) + + async def transform(self, value: TagChild) -> Jsonifiable: + session = require_active_session(None) + return rendered_deps_to_jsonifiable( + session._process_ui(value), + ) diff --git a/shiny/render/renderer/__init__.py b/shiny/render/renderer/__init__.py new file mode 100644 index 000000000..2afc8fe4e --- /dev/null +++ b/shiny/render/renderer/__init__.py @@ -0,0 +1,21 @@ +from ._renderer import ( # noqa: F401 + RendererBase, + Renderer, + ValueFn, + Jsonifiable, + RendererBaseT, # pyright: ignore[reportUnusedImport] + ValueFnApp, # pyright: ignore[reportUnusedImport] + ValueFnSync, # pyright: ignore[reportUnusedImport] + ValueFnAsync, # pyright: ignore[reportUnusedImport] + # WrapAsync, # pyright: ignore[reportUnusedImport] + AsyncValueFn, + # IT, # pyright: ignore[reportUnusedImport] +) + +__all__ = ( + "RendererBase", + "Renderer", + "ValueFn", + "Jsonifiable", + "AsyncValueFn", +) diff --git a/shiny/render/renderer/_renderer.py b/shiny/render/renderer/_renderer.py new file mode 100644 index 000000000..ade9db1cf --- /dev/null +++ b/shiny/render/renderer/_renderer.py @@ -0,0 +1,363 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import ( + Any, + Awaitable, + Callable, + Dict, + Generic, + List, + Optional, + Tuple, + TypeVar, + Union, + cast, +) + +from htmltools import MetadataNode, Tag, TagList + +from ..._typing_extensions import Self +from ..._utils import is_async_callable, wrap_async + +# TODO-barret; POST-merge; Update shinywidgets + + +# 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__ = ( + "Renderer", + "RendererBase", + "ValueFn", + "Jsonifiable", + "AsyncValueFn", +) + +RendererBaseT = TypeVar("RendererBaseT", bound="RendererBase") +""" +Generic class to pass the Renderer class through a decorator. + +When accepting and returning a `RendererBase` class, utilize this TypeVar as to not reduce the variable type to `RendererBase` +""" + +# Input type for the user-spplied function that is passed to a render.xx +IT = TypeVar("IT") + + +# https://github.com/python/cpython/blob/df1eec3dae3b1eddff819fd70f58b03b3fbd0eda/Lib/json/encoder.py#L77-L95 +# +-------------------+---------------+ +# | Python | JSON | +# +===================+===============+ +# | dict | object | +# +-------------------+---------------+ +# | list, tuple | array | +# +-------------------+---------------+ +# | str | string | +# +-------------------+---------------+ +# | int, float | number | +# +-------------------+---------------+ +# | True | true | +# +-------------------+---------------+ +# | False | false | +# +-------------------+---------------+ +# | None | null | +# +-------------------+---------------+ +Jsonifiable = Union[ + str, + int, + float, + bool, + None, + List["Jsonifiable"], + Tuple["Jsonifiable"], + Dict[str, "Jsonifiable"], +] + + +DefaultUIFnResult = Union[TagList, Tag, MetadataNode, str] +DefaultUIFnResultOrNone = Union[DefaultUIFnResult, None] +DefaultUIFn = Callable[[str], DefaultUIFnResultOrNone] + +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. +""" +ValueFnApp = Union[Callable[[], IT], Callable[[], Awaitable[IT]]] +""" +App-supplied output value function which returns type `IT`. This function can be +synchronous or asynchronous. +""" +ValueFn = Optional[ValueFnApp[Union[IT, None]]] + + +class RendererBase(ABC): + """ + Base class for all renderers. + + TODO-barret-docs + """ + + # Q: Could we do this with typing without putting `P` in the Generic? + # A: No. Even if we had a `P` in the Generic, the calling decorator would not have access to it. + # Idea: Possibly use a chained method of `.ui_kwargs()`? https://github.com/posit-dev/py-shiny/issues/971 + _default_ui_kwargs: dict[str, Any] = dict() + # _default_ui_args: tuple[Any, ...] = tuple() + + __name__: str + """ + Name of output function supplied. (The value will not contain any module prefix.) + + Set within `.__call__()` method. + """ + + # Meta + output_id: str + """ + Output function name or ID (provided to `@output(id=)`). This value will contain any module prefix. + + Set when the output is registered with the session. + """ + + def _set_output_metadata( + self, + *, + output_name: str, + ) -> None: + """ + Method to be called within `@output` to set the renderer's metadata. + + Parameters + ---------- + output_name : str + Output function name or ID (provided to `@output(id=)`). This value will contain any module prefix. + """ + self.output_id = output_name + + def default_ui( + self, + id: str, + # *args: object, + # **kwargs: object, + ) -> DefaultUIFnResultOrNone: + return None + + @abstractmethod + async def render(self) -> Jsonifiable: + ... + + def __init__(self) -> None: + super().__init__() + self._auto_registered: bool = False + + # ###### + # Tagify-like methods + # ###### + def _repr_html_(self) -> str | None: + rendered_ui = self._render_default_ui() + if rendered_ui is None: + return None + return TagList(rendered_ui)._repr_html_() + + def tagify(self) -> DefaultUIFnResult: + rendered_ui = self._render_default_ui() + if rendered_ui is None: + raise TypeError( + "No default UI exists for this type of render function: ", + self.__class__.__name__, + ) + return rendered_ui + + def _render_default_ui(self) -> DefaultUIFnResultOrNone: + return self.default_ui( + self.__name__, + # Pass the `@ui_kwargs(foo="bar")` kwargs through to the default_ui function. + **self._default_ui_kwargs, + ) + + # ###### + # Auto registering output + # ###### + """ + Auto registers the rendering method then the renderer is called. + + When `@output` is called on the renderer, the renderer is automatically un-registered via `._on_register()`. + """ + + def _on_register(self) -> None: + if self._auto_registered: + # We're being explicitly registered now. Undo the auto-registration. + # (w/ module support) + from ...session import require_active_session + + session = require_active_session(None) + ns_name = session.output._ns(self.__name__) + session.output.remove(ns_name) + self._auto_registered = False + + def _auto_register(self) -> None: + # If in Express mode, register the output + if not self._auto_registered: + from ...session import get_current_session + + s = get_current_session() + if s is not None: + from ._renderer import RendererBase + + # Cast to avoid circular import as this mixin is ONLY used within RendererBase + renderer_self = cast(RendererBase, self) + s.output(renderer_self) + # We mark the fact that we're auto-registered so that, if an explicit + # registration now occurs, we can undo this auto-registration. + self._auto_registered = True + + +# Not inheriting from `WrapAsync[[], IT]` as python 3.8 needs typing extensions that doesn't support `[]` for a ParamSpec definition. :-( +# Would be minimal/clean if we could do `class AsyncValueFn(WrapAsync[[], IT]):` +class AsyncValueFn(Generic[IT]): + """ + App-supplied output value function which returns type `IT`. + asynchronous. + + Type definition: `Callable[[], Awaitable[IT]]` + """ + + def __init__(self, fn: Callable[[], IT] | Callable[[], Awaitable[IT]]): + if isinstance(fn, AsyncValueFn): + fn = cast(AsyncValueFn[IT], fn) + return fn + self._is_async = is_async_callable(fn) + self._fn = wrap_async(fn) + self._orig_fn = fn + + async def __call__(self) -> IT: + """ + Call the asynchronous function. + """ + return await self._fn() + + def is_async(self) -> bool: + """ + Was the original function asynchronous? + + Returns + ------- + : + Whether the original function is asynchronous. + """ + return self._is_async + + def get_async_fn(self) -> Callable[[], Awaitable[IT]]: + """ + Return the async value function. + + Returns + ------- + : + Async wrapped value function supplied to the `AsyncValueFn` constructor. + """ + return self._fn + + def get_sync_fn(self) -> Callable[[], IT]: + """ + Retrieve the original, synchronous value function function. + + If the original function was asynchronous, a runtime error will be thrown. + + Returns + ------- + : + Original, synchronous function supplied to the `AsyncValueFn` constructor. + """ + if self._is_async: + raise RuntimeError( + "The original function was asynchronous. Use `async_fn` instead." + ) + sync_fn = cast(Callable[[], IT], self._orig_fn) + return sync_fn + + +class Renderer(RendererBase, Generic[IT]): + """ + Renderer cls docs here + + TODO-barret-docs + """ + + value_fn: AsyncValueFn[IT | None] + """ + App-supplied output value function which returns type `IT`. This function is always + asyncronous as the original app-supplied function possibly wrapped to execute + asynchonously. + """ + + def __call__(self, value_fn: ValueFnApp[IT | None]) -> Self: + """ + Renderer __call__ docs here; Sets app's value function + + TODO-barret-docs + """ + + if not callable(value_fn): + raise TypeError("Value function must be callable") + + # Copy over function name as it is consistent with how Session and Output + # retrieve function names + self.__name__: str = value_fn.__name__ + + # Set value function with extra meta information + self.value_fn: AsyncValueFn[IT | None] = AsyncValueFn(value_fn) + + # Allow for App authors to not require `@output` + self._auto_register() + + return self + + def __init__( + self, + value_fn: ValueFn[IT | None] = None, + ): + # Do not display docs here. If docs are present, it could highjack the docs of + # the subclass's `__init__` method. + # """ + # Renderer - init docs here + # """ + super().__init__() + if callable(value_fn): + # Register the value function + self(value_fn) + + async def transform(self, value: IT) -> Jsonifiable: + """ + Renderer - transform docs here + + TODO-barret-docs + """ + raise NotImplementedError( + "Please implement either the `transform(self, value: IT)` or `render(self)` method.\n" + "* `transform(self, value: IT)` should transform the `value` (of type `IT`) into Jsonifiable object. Ex: `dict`, `None`, `str`. (standard)\n" + "* `render(self)` method has full control of how an App author's value is retrieved (`self.value_fn()`) and utilized. (rare)\n" + "By default, the `render` retrieves the value and then calls `transform` method on non-`None` values." + ) + + async def render(self) -> Jsonifiable: + """ + Renderer - render docs here + + TODO-barret-docs + """ + value = await self.value_fn() + if value is None: + return None + + rendered = await self.transform(value) + return rendered diff --git a/shiny/render/renderer/_utils.py b/shiny/render/renderer/_utils.py new file mode 100644 index 000000000..8bed1c415 --- /dev/null +++ b/shiny/render/renderer/_utils.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from typing import Any, Dict, cast + +from htmltools import TagFunction + +from ...session._utils import RenderedDeps +from ...types import MISSING_TYPE, ImgData +from ._renderer import Jsonifiable + +JsonifiableDict = Dict[str, Jsonifiable] + + +def rendered_deps_to_jsonifiable(rendered_deps: RenderedDeps) -> JsonifiableDict: + return cast(JsonifiableDict, dict(rendered_deps)) + + +def imgdata_to_jsonifiable(imgdata: ImgData) -> JsonifiableDict: + return cast(JsonifiableDict, dict(imgdata)) + + +def set_kwargs_value( + kwargs: dict[str, Any], + key: str, + ui_val: TagFunction | str | float | int | MISSING_TYPE, + self_val: TagFunction | str | float | int | None | MISSING_TYPE, +): + """ + Set kwarg value with fallback value. + + * If `ui_val` is not `MISSING`, set `kwargs[key] = ui_val`. + * If `self_val` is not `MISSING` and is not `None`, set `kwargs[key] = self_val`. + * Otherwise, do nothing. + """ + if not isinstance(ui_val, MISSING_TYPE): + kwargs[key] = ui_val + return + if not (isinstance(self_val, MISSING_TYPE) or self_val is None): + kwargs[key] = self_val + return + # Do nothing as we don't want to override the default value (that could change in the future) + return diff --git a/shiny/render/transformer/__init__.py b/shiny/render/transformer/__init__.py index f28d91f1a..9df846c2d 100644 --- a/shiny/render/transformer/__init__.py +++ b/shiny/render/transformer/__init__.py @@ -4,14 +4,13 @@ OutputRenderer, output_transformer, is_async_callable, - resolve_value_fn, ValueFn, + ValueFnApp, # pyright: ignore[reportUnusedImport] ValueFnSync, # pyright: ignore[reportUnusedImport] ValueFnAsync, # pyright: ignore[reportUnusedImport] TransformFn, # pyright: ignore[reportUnusedImport] OutputTransformer, # pyright: ignore[reportUnusedImport] - OutputRendererSync, # pyright: ignore[reportUnusedImport] - OutputRendererAsync, # pyright: ignore[reportUnusedImport] + resolve_value_fn, # pyright: ignore[reportUnusedImport] ) __all__ = ( @@ -21,5 +20,4 @@ "ValueFn", "output_transformer", "is_async_callable", - "resolve_value_fn", ) diff --git a/shiny/render/transformer/_transformer.py b/shiny/render/transformer/_transformer.py index 31528859c..889aab53a 100644 --- a/shiny/render/transformer/_transformer.py +++ b/shiny/render/transformer/_transformer.py @@ -1,9 +1,7 @@ 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. +# TODO-future; When `OutputRenderer` is removed, remove `output_args()` + __all__ = ( "TransformerMetadata", @@ -16,18 +14,13 @@ # "TransformFn", "output_transformer", "is_async_callable", - # "IT", - # "OT", - # "P", ) import inspect -from abc import ABC, abstractmethod from typing import ( TYPE_CHECKING, Awaitable, Callable, - Dict, Generic, NamedTuple, Optional, @@ -38,14 +31,16 @@ overload, ) -from htmltools import MetadataNode, Tag, TagList +from ..renderer import AsyncValueFn, Jsonifiable, RendererBase +from ..renderer._renderer import DefaultUIFn, DefaultUIFnResultOrNone if TYPE_CHECKING: from ...session import Session +from ..._deprecated import warn_deprecated from ..._docstring import add_example from ..._typing_extensions import Concatenate, ParamSpec -from ..._utils import is_async_callable, run_coro_sync +from ..._utils import is_async_callable from ...types import MISSING # Input type for the user-spplied function that is passed to a render.xx @@ -114,6 +109,7 @@ def __init__(self, *args: P.args, **kwargs: P.kwargs) -> None: # Make sure there no `args` at run time! # This check is related to `_assert_transform_fn` not accepting any `args` if len(args) > 0: + print(args) raise RuntimeError("`args` should not be supplied") # `*args` must be defined with `**kwargs` (as per PEP612) @@ -150,7 +146,13 @@ def inner(*args: P.args, **kwargs: P.kwargs) -> TransformerParams[P]: App-supplied output value function which returns type `IT`. This function is asynchronous. """ -ValueFn = Union[ValueFnSync[IT], ValueFnAsync[IT]] +ValueFn = ValueFnAsync[IT] +""" +App-supplied output value function which returns type `IT`. This function is always +asyncronous as the original app-supplied function possibly wrapped to execute +asynchonously. +""" +ValueFnApp = Union[ValueFnSync[IT], ValueFnAsync[IT]] """ App-supplied output value function which returns type `IT`. This function can be synchronous or asynchronous. @@ -161,20 +163,11 @@ def inner(*args: P.args, **kwargs: P.kwargs) -> TransformerParams[P]: 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. +be defined as an asynchronous function. """ -DefaultUIFn = Callable[[str], Union[TagList, Tag, MetadataNode, str]] -DefaultUIFnImpl = Union[ - DefaultUIFn, - Callable[[Dict[str, object], str], Union[TagList, Tag, MetadataNode, str]], -] - -class OutputRenderer(Generic[OT], ABC): +class OutputRenderer(RendererBase, Generic[OT]): """ Output Renderer @@ -183,11 +176,7 @@ class OutputRenderer(Generic[OT], ABC): :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. + (typically defined by package authors) is invoked. The transform function (`transform_fn`) is given `meta` information (:class:`~shiny.render.transformer.TranformerMetadata`), the (app-supplied) value @@ -210,28 +199,26 @@ class OutputRenderer(Generic[OT], ABC): * 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: + async def __call__(self) -> OT: """ - Executes the output renderer as a function. Must be implemented by subclasses. + Asynchronously executes the output renderer (both the app's output value function and transformer). + + All output renderers are asynchronous to accomodate that users can supply + asyncronous output value functions and package authors can supply asynchronous + transformer functions. To handle both possible situations cleanly, the + `.__call__` method is executed as asynchronous. """ - ... + return await self._run() def __init__( self, *, - value_fn: ValueFn[IT], + value_fn: ValueFnApp[IT], transform_fn: TransformFn[IT, P, OT], params: TransformerParams[P], - default_ui: Optional[DefaultUIFnImpl] = None, + default_ui: Optional[DefaultUIFn] = None, default_ui_passthrough_args: Optional[tuple[str, ...]] = None, ) -> None: """ @@ -241,10 +228,7 @@ def __init__( 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. + `OT`. The `params` will used as variadic keyword arguments. params App-provided parameters for the transform function (`transform_fn`). default_ui @@ -252,6 +236,11 @@ def __init__( object that can be used to display the output. This allows render functions to respond to `_repr_html_` method calls in environments like Jupyter. """ + super().__init__() + + warn_deprecated( + "`shiny.render.transformer.output_transformer()` and `shiny.render.transformer.OutputRenderer()` output render function utilities have been superseded by `shiny.render.renderer.Renderer` and will be removed in a near future release." + ) # Copy over function name as it is consistent with how Session and Output # retrieve function names @@ -259,51 +248,42 @@ def __init__( if not is_async_callable(transform_fn): raise TypeError( - self.__class__.__name__ - + " requires an async tranformer function (`transform_fn`)" + "OutputRenderer requires an async tranformer function (`transform_fn`)." + " Please define your transform function as asynchronous." + " Ex `async def my_transformer(....`" ) - self._value_fn = value_fn + # Upgrade value function to be async; + # Calling an async function has a ~35ns overhead (barret's machine) + # Checking if a function is async has a 180+ns overhead (barret's machine) + # -> It is faster to always call an async function than to always check if it is async + # Always being async simplifies the execution + self._value_fn = AsyncValueFn(value_fn) + self._value_fn_is_async = self._value_fn.is_async() # legacy key + self.__name__ = value_fn.__name__ + self._transformer = transform_fn self._params = params - self.default_ui = default_ui - self.default_ui_passthrough_args = default_ui_passthrough_args - self.default_ui_args: tuple[object, ...] = tuple() - self.default_ui_kwargs: dict[str, object] = dict() + self._default_ui = default_ui + self._default_ui_passthrough_args = default_ui_passthrough_args - self._auto_registered = False + self._default_ui_args: tuple[object, ...] = tuple() + self._default_ui_kwargs: dict[str, object] = dict() - from ...session import get_current_session - - s = get_current_session() - if s is not None: - s.output(self) - # We mark the fact that we're auto-registered so that, if an explicit - # registration now occurs, we can undo this auto-registration. - self._auto_registered = True - - def on_register(self) -> None: - if self._auto_registered: - # We're being explicitly registered now. Undo the auto-registration. - self._session.output.remove(self.__name__) - self._auto_registered = False - - 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 + # Allow for App authors to not require `@output` + self._auto_register() 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) """ + from ...session import require_active_session + + session = require_active_session(None) return TransformerMetadata( - session=self._session, - name=self._name, + session=session, + name=self.output_id, ) async def _run(self) -> OT: @@ -323,7 +303,7 @@ async def _run(self) -> OT: ret = await self._transformer( # TransformerMetadata self._meta(), - # Callable[[], Awaitable[IT]] | Callable[[], IT] + # Callable[[], Awaitable[IT]] self._value_fn, # P *self._params.args, @@ -331,125 +311,34 @@ async def _run(self) -> OT: ) return ret - def _repr_html_(self) -> str | None: - import htmltools + # # Shims for Renderer class ############################# - if self.default_ui is None: + def default_ui( + self, + id: str, + **kwargs: object, + ) -> DefaultUIFnResultOrNone: + if self._default_ui is None: return None - return htmltools.TagList(self._render_default())._repr_html_() - - def tagify(self) -> TagList | Tag | MetadataNode | str: - if self.default_ui is None: - raise TypeError("No default UI exists for this type of render function") - return self._render_default() - - def _render_default(self) -> TagList | Tag | MetadataNode | str: - if self.default_ui is None: - raise TypeError("No default UI exists for this type of render function") - # Merge the kwargs from the render function passthrough, with the kwargs from - # explicit @output_args call. The latter take priority. - kwargs: dict[str, object] = dict() - if self.default_ui_passthrough_args is not None: + if self._default_ui_passthrough_args is not None: kwargs.update( { k: v for k, v in self._params.kwargs.items() - if k in self.default_ui_passthrough_args and v is not MISSING + if k in self._default_ui_passthrough_args and v is not MISSING } ) - kwargs.update( - {k: v for k, v in self.default_ui_kwargs.items() if v is not MISSING} - ) - return cast(DefaultUIFn, self.default_ui)( - self.__name__, *self.default_ui_args, **kwargs - ) - - -# Using a second class to help clarify that it is of a particular type -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], - default_ui: Optional[DefaultUIFnImpl] = None, - default_ui_passthrough_args: Optional[tuple[str, ...]] = None, - ) -> 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, - default_ui=default_ui, - default_ui_passthrough_args=default_ui_passthrough_args, - ) - def __call__(self) -> OT: - """ - Synchronously executes the output renderer as a function. - """ - return run_coro_sync(self._run()) + return self._default_ui(id, *self._default_ui_args, **kwargs) - -# 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], - default_ui: Optional[DefaultUIFnImpl] = None, - default_ui_passthrough_args: Optional[tuple[str, ...]] = None, - ) -> 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, - default_ui=default_ui, - default_ui_passthrough_args=default_ui_passthrough_args, - ) - - async def __call__(self) -> OT: # pyright: ignore[reportIncompatibleMethodOverride] - """ - Asynchronously executes the output renderer as a function. - """ - return await self._run() + async def render(self) -> Jsonifiable: + ret = await self._run() + # Really, OT should be bound by Jsonifiable. + # But we can't do that now as types like TypedDict break on Jsonifiable + # (We also don't really care as we're moving to `Renderer` class) + jsonifiable_ret = cast(Jsonifiable, ret) + return jsonifiable_ret # ====================================================================================== @@ -468,7 +357,7 @@ def _assert_transformer(transform_fn: TransformFn[IT, P, OT]) -> None: if len(params) < 2: raise TypeError( "`transformer=` must have 2 positional parameters which have type " - "`TransformerMetadata` and `RenderFnAsync` respectively" + "`TransformerMetadata` and `ValueFn` respectively" ) for i, param in zip(range(len(params)), params.values()): @@ -515,7 +404,7 @@ def _assert_transformer(transform_fn: TransformFn[IT, P, OT]) -> None: # Signature of a renderer decorator function -OutputRendererDecorator = Callable[[ValueFn[IT]], OutputRenderer[OT]] +OutputRendererDecorator = Callable[[ValueFnApp[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`. @@ -526,7 +415,7 @@ def _assert_transformer(transform_fn: TransformFn[IT, P, OT]) -> None: # Without parens returns a `OutputRendererDeco[IT, OT]` OutputTransformerFn = Callable[ [ - Optional[ValueFn[IT]], + Optional[ValueFnApp[IT]], TransformerParams[P], ], Union[OutputRenderer[OT], OutputRendererDecorator[IT, OT]], @@ -574,7 +463,7 @@ class OutputTransformer(Generic[IT, OT, P]): """ fn: OutputTransformerFn[IT, P, OT] - ValueFn: Type[ValueFn[IT]] + ValueFn: Type[ValueFnApp[IT]] OutputRenderer: Type[OutputRenderer[OT]] OutputRendererDecorator: Type[OutputRendererDecorator[IT, OT]] @@ -587,7 +476,7 @@ def params( def __call__( self, - value_fn: ValueFn[IT] | None, + value_fn: ValueFnApp[IT] | None, params: TransformerParams[P] | None = None, ) -> OutputRenderer[OT] | OutputRendererDecorator[IT, OT]: if params is None: @@ -605,7 +494,7 @@ def __init__( fn: OutputTransformerFn[IT, P, OT], ) -> None: self._fn = fn - self.ValueFn = ValueFn[IT] + self.ValueFn = ValueFnApp[IT] self.OutputRenderer = OutputRenderer[OT] self.OutputRendererDecorator = OutputRendererDecorator[IT, OT] @@ -687,12 +576,6 @@ def output_transformer( 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 ---------- @@ -722,30 +605,19 @@ def output_transformer_impl( _assert_transformer(transform_fn) def renderer_decorator( - value_fn: ValueFn[IT] | None, + value_fn: ValueFnApp[IT] | None, params: TransformerParams[P], ) -> OutputRenderer[OT] | OutputRendererDecorator[IT, OT]: def as_value_fn( - fn: ValueFn[IT], + fn: ValueFnApp[IT], ) -> OutputRenderer[OT]: - if is_async_callable(fn): - return OutputRendererAsync( - fn, - transform_fn, - params, - default_ui, - default_ui_passthrough_args, - ) - else: - # To avoid duplicate work just for a typeguard, we cast the function - fn = cast(ValueFnSync[IT], fn) - return OutputRendererSync( - fn, - transform_fn, - params, - default_ui, - default_ui_passthrough_args, - ) + return OutputRenderer( + value_fn=fn, + transform_fn=transform_fn, + params=params, + default_ui=default_ui, + default_ui_passthrough_args=default_ui_passthrough_args, + ) if value_fn is None: return as_value_fn @@ -760,9 +632,12 @@ def as_value_fn( return output_transformer_impl -async def resolve_value_fn(value_fn: ValueFn[IT]) -> IT: +async def resolve_value_fn(value_fn: ValueFnApp[IT]) -> IT: """ - Resolve the value function + Soft deprecated. Resolve the value function + + Deprecated: v0.7.0 - This function is no longer needed as all value functions are + now async for consistency and speed. 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 @@ -784,11 +659,6 @@ async def resolve_value_fn(value_fn: ValueFn[IT]) -> IT: 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 @@ -800,12 +670,12 @@ async def resolve_value_fn(value_fn: ValueFn[IT]) -> IT: : The resolved value from `value_fn`. """ + warn_deprecated( + "`resolve_value_fn()` is unnecessary when resolving the value function in a custom render method. Now, the value function is always async. `resolve_value_fn()` will be removed in a future release." + ) 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() - - -R = TypeVar("R") diff --git a/shiny/session/_session.py b/shiny/session/_session.py index 1bfeaf453..79ffdd8b7 100644 --- a/shiny/session/_session.py +++ b/shiny/session/_session.py @@ -24,7 +24,6 @@ Callable, Iterable, Optional, - TypeVar, Union, cast, overload, @@ -49,12 +48,10 @@ from ..input_handler import input_handlers from ..reactive import Effect_, Value, effect, flush, isolate from ..reactive._core import lock, on_flushed -from ..render.transformer import OutputRenderer +from ..render.renderer import Jsonifiable, RendererBase, RendererBaseT from ..types import SafeException, SilentCancelOutputException, SilentException from ._utils import RenderedDeps, read_thunk_opt, session_context -OT = TypeVar("OT") - class ConnectionState(enum.Enum): Start = 0 @@ -951,6 +948,8 @@ def __contains__(self, key: str) -> bool: # ====================================================================================== # Outputs # ====================================================================================== + + class Outputs: """ A class representing Shiny output definitions. @@ -969,7 +968,7 @@ def __init__( self._suspend_when_hidden = suspend_when_hidden @overload - def __call__(self, renderer_fn: OutputRenderer[OT]) -> OutputRenderer[OT]: + def __call__(self, renderer: RendererBaseT) -> RendererBaseT: ... @overload @@ -979,32 +978,31 @@ def __call__( id: Optional[str] = None, suspend_when_hidden: bool = True, priority: int = 0, - ) -> Callable[[OutputRenderer[OT]], OutputRenderer[OT]]: + ) -> Callable[[RendererBaseT], RendererBaseT]: ... def __call__( self, - renderer_fn: Optional[OutputRenderer[OT]] = None, + renderer: Optional[RendererBaseT] = None, *, id: Optional[str] = None, suspend_when_hidden: bool = True, priority: int = 0, - ) -> OutputRenderer[OT] | Callable[[OutputRenderer[OT]], OutputRenderer[OT]]: - def set_renderer(renderer_fn: OutputRenderer[OT]) -> OutputRenderer[OT]: - if hasattr(renderer_fn, "on_register"): - renderer_fn.on_register() - - # Get the (possibly namespaced) output id - output_name = self._ns(id or renderer_fn.__name__) - - if not isinstance(renderer_fn, OutputRenderer): + ) -> RendererBaseT | Callable[[RendererBaseT], RendererBaseT]: + def set_renderer(renderer: RendererBaseT) -> RendererBaseT: + if not isinstance(renderer, RendererBase): raise TypeError( "`@output` must be applied to a `@render.xx` function.\n" + "In other words, `@output` must be above `@render.xx`." ) - # renderer_fn is a Renderer object. Give it a bit of metadata. - renderer_fn._set_metadata(self._session, output_name) + # Get the (possibly namespaced) output id + output_name = self._ns(id or renderer.__name__) + + # renderer is a Renderer object. Give it a bit of metadata. + renderer._set_output_metadata(output_name=output_name) + + renderer._on_register() self.remove(output_name) @@ -1019,12 +1017,9 @@ async def output_obs(): {"recalculating": {"name": output_name, "status": "recalculating"}} ) - message: dict[str, Optional[OT]] = {} + message: dict[str, Jsonifiable] = {} try: - if _utils.is_async_callable(renderer_fn): - message[output_name] = await renderer_fn() - else: - message[output_name] = renderer_fn() + message[output_name] = await renderer.render() except SilentCancelOutputException: return except SilentException: @@ -1063,12 +1058,12 @@ async def output_obs(): self._effects[output_name] = output_obs - return renderer_fn + return renderer - if renderer_fn is None: + if renderer is None: return set_renderer else: - return set_renderer(renderer_fn) + return set_renderer(renderer) def remove(self, id: Id) -> None: output_name = self._ns(id) diff --git a/shiny/templates/package-templates/js-output/custom_component/custom_component.py b/shiny/templates/package-templates/js-output/custom_component/custom_component.py index 789e73371..a19e41ed5 100644 --- a/shiny/templates/package-templates/js-output/custom_component/custom_component.py +++ b/shiny/templates/package-templates/js-output/custom_component/custom_component.py @@ -4,12 +4,7 @@ from htmltools import HTMLDependency, Tag from shiny.module import resolve_id -from shiny.render.transformer import ( - TransformerMetadata, - ValueFn, - output_transformer, - resolve_value_fn, -) +from shiny.render.renderer import Jsonifiable, Renderer, ValueFn # This object is used to let Shiny know where the dependencies needed to run # our component all live. In this case, we're just using a single javascript @@ -25,28 +20,35 @@ ) -@output_transformer() -async def render_custom_component( - _meta: TransformerMetadata, - _fn: ValueFn[int | None], -): - res = await resolve_value_fn(_fn) - if res is None: - return None +class render_custom_component(Renderer[int]): + """ + Render a value in a custom component. + """ - if not isinstance(res, int): - # Throw an error if the value is not an integer. - raise TypeError(f"Expected a integer, got {type(res)}. ") + # The UI used within Shiny Express mode + def default_ui(self, id: str) -> Tag: + return custom_component(id, height=self.height) - # Send the results to the client. Make sure that this is a serializable - # object and matches what is expected in the javascript code. - return {"value": res} + # The init method is used to set up the renderer's parameters. + # If no parameters are needed, then the `__init__()` method can be omitted. + def __init__(self, _value_fn: ValueFn[int] = None, *, height: str = "200px"): + super().__init__(_value_fn) + self.height: str = height + # Transforms non-`None` values into a `Jsonifiable` object. + # If you'd like more control on when and how the value is resolved, + # please use the `async def resolve(self)` method. + async def transform(self, value: int) -> Jsonifiable: + # Send the results to the client. Make sure that this is a serializable + # object and matches what is expected in the javascript code. + return {"value": int(value)} -def custom_component(id: str, height: str = "200px"): + +def custom_component(id: str, height: str = "200px") -> Tag: """ - A shiny output. To be paired with - `render_custom_component` decorator. + A shiny UI output. + + To be paired with `render_custom_component` decorator within the Shiny server. """ return Tag( "custom-component", diff --git a/shiny/templates/package-templates/js-output/package-lock.json b/shiny/templates/package-templates/js-output/package-lock.json index b14fe37f3..3f87e6495 100644 --- a/shiny/templates/package-templates/js-output/package-lock.json +++ b/shiny/templates/package-templates/js-output/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.0", "license": "MIT", "dependencies": { - "@posit-dev/shiny-bindings-core": "^0.0.3", + "@posit-dev/shiny-bindings-core": "^0.1.0", "lit": "^3.0.2" }, "devDependencies": { @@ -383,9 +383,9 @@ } }, "node_modules/@posit-dev/shiny-bindings-core": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/@posit-dev/shiny-bindings-core/-/shiny-bindings-core-0.0.3.tgz", - "integrity": "sha512-G4Zd916Y9YkvuQHRJtRceQBwJD51pBsEyYZFpkIwHiyR56nGGbX0POqHSE39ZQMxa+ewhiBhd4FvK5RgGOoVCA==" + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@posit-dev/shiny-bindings-core/-/shiny-bindings-core-0.1.0.tgz", + "integrity": "sha512-va6csvzr1XTyaaW15Ak6aNhv9Lp/iQ6gxSRCqnRjkFeAjJkUXmlYV6nYAnywegkbnGfhqDkft7AXQK8POZAuWA==" }, "node_modules/@types/trusted-types": { "version": "2.0.7", diff --git a/shiny/templates/package-templates/js-output/package.json b/shiny/templates/package-templates/js-output/package.json index a3cf5f842..bd3bb563e 100644 --- a/shiny/templates/package-templates/js-output/package.json +++ b/shiny/templates/package-templates/js-output/package.json @@ -14,7 +14,7 @@ "typescript": "^5.2.2" }, "dependencies": { - "@posit-dev/shiny-bindings-core": "^0.0.3", + "@posit-dev/shiny-bindings-core": "^0.1.0", "lit": "^3.0.2" } } diff --git a/shiny/templates/package-templates/js-output/srcts/index.ts b/shiny/templates/package-templates/js-output/srcts/index.ts index 6e0c00b97..7a2acd0cc 100644 --- a/shiny/templates/package-templates/js-output/srcts/index.ts +++ b/shiny/templates/package-templates/js-output/srcts/index.ts @@ -1,7 +1,7 @@ import { LitElement, html, css } from "lit"; import { property } from "lit/decorators.js"; -import { makeOutputBinding } from "@posit-dev/shiny-bindings-core"; +import { makeOutputBindingWebComponent } from "@posit-dev/shiny-bindings-core"; // What the server-side output binding will send to the client. It's important // to make sure this matches what the python code is sending. @@ -41,4 +41,5 @@ export class CustomComponentEl extends LitElement { } // Setup output binding. This also registers the custom element. -makeOutputBinding("custom-component", CustomComponentEl); + +makeOutputBindingWebComponent("custom-component", CustomComponentEl); diff --git a/shiny/templates/package-templates/js-react/custom_component/custom_component.py b/shiny/templates/package-templates/js-react/custom_component/custom_component.py index 91529e1f6..3a7e1b0d5 100644 --- a/shiny/templates/package-templates/js-react/custom_component/custom_component.py +++ b/shiny/templates/package-templates/js-react/custom_component/custom_component.py @@ -3,12 +3,7 @@ from htmltools import HTMLDependency, Tag from shiny.module import resolve_id -from shiny.render.transformer import ( - TransformerMetadata, - ValueFn, - output_transformer, - resolve_value_fn, -) +from shiny.render.renderer import Jsonifiable, Renderer # This object is used to let Shiny know where the dependencies needed to run # our component all live. In this case, we're just using a single javascript @@ -38,24 +33,28 @@ def input_custom_component(id: str): # Output component +class render_custom_component(Renderer[str]): + """ + Render a value in a custom component. + """ + # The UI used within Shiny Express mode + def default_ui(self, id: str) -> Tag: + return output_custom_component(id) -@output_transformer() -async def render_custom_component( - _meta: TransformerMetadata, - _fn: ValueFn[str | None], -): - res = await resolve_value_fn(_fn) - if res is None: - return None - - if not isinstance(res, str): - # Throw an error if the value is not a string - raise TypeError(f"Expected a string, got {type(res)}. ") + # # There are no parameters being supplied to the `output_custom_component` rendering function. + # # Therefore, we can omit the `__init__()` method. + # def __init__(self, _value_fn: ValueFn[int] = None, *, extra_arg: str = "bar"): + # super().__init__(_value_fn) + # self.extra_arg: str = extra_arg - # Send the results to the client. Make sure that this is a serializable - # object and matches what is expected in the javascript code. - return {"value": res} + # Transforms non-`None` values into a `Jsonifiable` object. + # If you'd like more control on when and how the value is resolved, + # please use the `async def resolve(self)` method. + async def transform(self, value: str) -> Jsonifiable: + # Send the results to the client. Make sure that this is a serializable + # object and matches what is expected in the javascript code. + return {"value": str(value)} def output_custom_component(id: str): diff --git a/shiny/templates/package-templates/js-react/example-app/app.py b/shiny/templates/package-templates/js-react/example-app/app.py index a87660408..a2464da98 100644 --- a/shiny/templates/package-templates/js-react/example-app/app.py +++ b/shiny/templates/package-templates/js-react/example-app/app.py @@ -9,15 +9,19 @@ from shiny import App, ui app_ui = ui.page_fluid( + ui.h2("Color picker"), input_custom_component("color"), - output_custom_component("valueOut"), + ui.br(), + ui.h2("Output color"), + output_custom_component("value"), ) def server(input, output, session): @render_custom_component - def valueOut(): + def value(): + print("Calculating value") return input.color() -app = App(app_ui, server) +app = App(app_ui, server, debug=True) diff --git a/shiny/templates/package-templates/js-react/package-lock.json b/shiny/templates/package-templates/js-react/package-lock.json index dc7eb9191..f42f1b17a 100644 --- a/shiny/templates/package-templates/js-react/package-lock.json +++ b/shiny/templates/package-templates/js-react/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.0", "license": "MIT", "dependencies": { - "@posit-dev/shiny-bindings-react": "^0.0.3", + "@posit-dev/shiny-bindings-react": "^0.1.0", "react": "^18.2.0", "react-color": "^2.19.3", "react-dom": "^18.2.0" @@ -382,14 +382,14 @@ } }, "node_modules/@posit-dev/shiny-bindings-core": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/@posit-dev/shiny-bindings-core/-/shiny-bindings-core-0.0.2.tgz", - "integrity": "sha512-uJ1cUAjtIZVFqU7bXqjZm8HX72FrM3BVfCtReppTUrtqE2SJnOQNlUZpc+xkV+3WkPeqnpn0NS7SH880mkcrPQ==" + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@posit-dev/shiny-bindings-core/-/shiny-bindings-core-0.1.0.tgz", + "integrity": "sha512-va6csvzr1XTyaaW15Ak6aNhv9Lp/iQ6gxSRCqnRjkFeAjJkUXmlYV6nYAnywegkbnGfhqDkft7AXQK8POZAuWA==" }, "node_modules/@posit-dev/shiny-bindings-react": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/@posit-dev/shiny-bindings-react/-/shiny-bindings-react-0.0.3.tgz", - "integrity": "sha512-zarfRZ3/dUFBf11Vc2fUM4grpp6xaJQAkqZRj9W/Xbtax3LJ4PElzPzjZicN9PdDBL72cux8XJ+U+0G3b6G8Nw==", + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@posit-dev/shiny-bindings-react/-/shiny-bindings-react-0.1.0.tgz", + "integrity": "sha512-zmGY/H8aLVODr2NiaqUBgB+YJURtiUHqY8+x/QRlcmD9T3eWrdVboNd8PdZJyX9IO3qDJUSEq01npuGzdEFOQA==", "dependencies": { "@posit-dev/shiny-bindings-core": "*", "@types/react": "^18.2.38", diff --git a/shiny/templates/package-templates/js-react/package.json b/shiny/templates/package-templates/js-react/package.json index 6ac2fbb2c..2af72278a 100644 --- a/shiny/templates/package-templates/js-react/package.json +++ b/shiny/templates/package-templates/js-react/package.json @@ -16,7 +16,7 @@ "typescript": "^5.2.2" }, "dependencies": { - "@posit-dev/shiny-bindings-react": "^0.0.3", + "@posit-dev/shiny-bindings-react": "^0.1.0", "react": "^18.2.0", "react-color": "^2.19.3", "react-dom": "^18.2.0" diff --git a/shiny/templates/package-templates/js-react/srcts/index.tsx b/shiny/templates/package-templates/js-react/srcts/index.tsx index 1b35e7257..88f0b7095 100644 --- a/shiny/templates/package-templates/js-react/srcts/index.tsx +++ b/shiny/templates/package-templates/js-react/srcts/index.tsx @@ -10,11 +10,12 @@ import { // into the root of the webcomponent. makeReactInput({ name: "custom-component-input", + selector: "custom-component-input", initialValue: "#fff", - renderComp: ({ initialValue, onNewValue }) => ( + renderComp: ({ initialValue, updateValue }) => ( onNewValue(color)} + updateValue={(color) => updateValue(color)} /> ), }); @@ -22,10 +23,10 @@ makeReactInput({ // Color Picker React component function ColorPickerReact({ initialValue, - onNewValue, + updateValue, }: { initialValue: string; - onNewValue: (x: string) => void; + updateValue: (x: string) => void; }) { const [currentColor, setCurrentColor] = React.useState(initialValue); @@ -34,7 +35,7 @@ function ColorPickerReact({ color={currentColor} onChange={(color) => { setCurrentColor(color.hex); - onNewValue(color.hex); + updateValue(color.hex); }} /> ); @@ -42,6 +43,7 @@ function ColorPickerReact({ makeReactOutput<{ value: string }>({ name: "custom-component-output", + selector: "custom-component-output", renderComp: ({ value }) => (
typing.List[str]: "brownian": 250, "ui-func": 250, } +output_transformer_errors = [ + "ShinyDeprecationWarning: `shiny.render.transformer.output_transformer()`", + " return OutputRenderer", + "ShinyDeprecationWarning: `resolve_value_fn()`", + "ShinyDeprecationWarning:", + "`resolve_value_fn()`", + "value = await resolve_value_fn(_fn)", + # brownian example app + "shiny.render.transformer.output_transformer()", +] +express_warnings = ["Detected Shiny Express app. "] app_allow_shiny_errors: typing.Dict[ str, typing.Union[Literal[True], typing.List[str]] ] = { @@ -50,6 +61,14 @@ def get_apps(path: str) -> typing.List[str]: "RuntimeWarning: divide by zero encountered", "UserWarning: This figure includes Axes that are not compatible with tight_layout", ], + # Remove after shinywidgets accepts `resolve_value_fn()` PR + "airmass": [*output_transformer_errors], + "brownian": [*output_transformer_errors], + "multi-page": [*output_transformer_errors], + "model-score": [*output_transformer_errors], + "data_frame": [*output_transformer_errors], + "output_transformer": [*output_transformer_errors], + "render_display": [*express_warnings], } app_allow_external_errors: typing.List[str] = [ # if shiny express app detected @@ -178,7 +197,9 @@ def on_console_msg(msg: ConsoleMessage) -> None: app_allowable_errors = [] # If all errors are not allowed, check for unexpected errors - if isinstance(app_allowable_errors, list): + if app_allowable_errors is not True: + if isinstance(app_allowable_errors, str): + app_allowable_errors = [app_allowable_errors] app_allowable_errors = ( # Remove ^INFO lines ["INFO:"] @@ -192,9 +213,13 @@ def on_console_msg(msg: ConsoleMessage) -> None: error_lines = [ line for line in error_lines - if not any([error_txt in line for error_txt in app_allowable_errors]) + if len(line.strip()) > 0 + and not any([error_txt in line for error_txt in app_allowable_errors]) ] if len(error_lines) > 0: + print("\napp_allowable_errors :") + print("\n".join(app_allowable_errors)) + print("\nError lines remaining:") print("\n".join(error_lines)) assert len(error_lines) == 0 diff --git a/tests/playwright/shiny/async/app.py b/tests/playwright/shiny/async/app.py index c129abc86..cafa7dbc8 100644 --- a/tests/playwright/shiny/async/app.py +++ b/tests/playwright/shiny/async/app.py @@ -2,8 +2,7 @@ import hashlib import time -import shiny as s -from shiny import reactive, ui +from shiny import App, Inputs, Outputs, Session, reactive, render, ui def calc(value: str) -> str: @@ -23,8 +22,8 @@ def calc(value: str) -> str: ) -def server(input: s.Inputs, output: s.Outputs, session: s.Session): - @s.render.text() +def server(input: Inputs, output: Outputs, session: Session): + @render.text() @reactive.event(input.go) async def hash_output(): content = await hash_result() @@ -39,4 +38,4 @@ async def hash_result() -> str: return await asyncio.get_running_loop().run_in_executor(None, calc, value) -app = s.App(app_ui, server) +app = App(app_ui, server) diff --git a/tests/playwright/shiny/deprecated/output_transformer/app.py b/tests/playwright/shiny/deprecated/output_transformer/app.py new file mode 100644 index 000000000..d716f9d39 --- /dev/null +++ b/tests/playwright/shiny/deprecated/output_transformer/app.py @@ -0,0 +1,148 @@ +from __future__ import annotations + +import warnings +from typing import Literal, overload + +from shiny import App, Inputs, Outputs, Session, ui +from shiny._deprecated import ShinyDeprecationWarning +from shiny.render.transformer import ( + TransformerMetadata, + ValueFn, + output_transformer, + resolve_value_fn, +) + +warnings.filterwarnings("ignore", category=ShinyDeprecationWarning) + +####### +# 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) +####### + + +@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: +# ``` +# @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: +# ``` +# @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"), + "Renderer called with out parentheses:", + ui.output_text_verbatim("no_output", placeholder=True), + ui.output_text_verbatim("no_parens", placeholder=True), + "To upper:", + ui.output_text_verbatim("to_upper", placeholder=True), + "To lower:", + ui.output_text_verbatim("to_lower", placeholder=True), +) + + +def server(input: Inputs, output: Outputs, session: Session): + # Without parentheses + @render_capitalize + def no_output(): + return input.caption() + + @output + # Without parentheses + @render_capitalize + def no_parens(): + return input.caption() + + # @output # Do not include to make sure auto registration works + # With parentheses. Equivalent to `@render_capitalize()` + @render_capitalize(to="upper") + def to_upper(): + return input.caption() + + # provide a custom name to make sure the name can be overridden + @output(id="to_lower") + @render_capitalize(to="lower") + # Works with async output value functions + async def _(): + return input.caption() + + +app = App(app_ui, server) diff --git a/tests/playwright/shiny/deprecated/output_transformer/test_output_transformer_example.py b/tests/playwright/shiny/deprecated/output_transformer/test_output_transformer_example.py new file mode 100644 index 000000000..ca16acb10 --- /dev/null +++ b/tests/playwright/shiny/deprecated/output_transformer/test_output_transformer_example.py @@ -0,0 +1,12 @@ +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, "no_output").expect_value("DATA SUMMARY") + OutputTextVerbatim(page, "no_parens").expect_value("DATA SUMMARY") + OutputTextVerbatim(page, "to_upper").expect_value("DATA SUMMARY") + OutputTextVerbatim(page, "to_lower").expect_value("data summary") diff --git a/tests/playwright/shiny/server/output_transformer/app.py b/tests/playwright/shiny/server/output_transformer/app.py index 7548312e8..83c83797e 100644 --- a/tests/playwright/shiny/server/output_transformer/app.py +++ b/tests/playwright/shiny/server/output_transformer/app.py @@ -8,7 +8,6 @@ ValueFn, is_async_callable, output_transformer, - resolve_value_fn, ) @@ -19,7 +18,7 @@ async def TestTextTransformer( *, extra_txt: Optional[str] = None, ) -> str | None: - value = await resolve_value_fn(_fn) + value = await _fn() value = str(value) value += "; " value += "async" if is_async_callable(_fn) else "sync" diff --git a/tests/playwright/shiny/server/output_transformer/test_output_transformer.py b/tests/playwright/shiny/server/output_transformer/test_output_transformer_async.py similarity index 66% rename from tests/playwright/shiny/server/output_transformer/test_output_transformer.py rename to tests/playwright/shiny/server/output_transformer/test_output_transformer_async.py index e2c1fc5f7..858ecd2af 100644 --- a/tests/playwright/shiny/server/output_transformer/test_output_transformer.py +++ b/tests/playwright/shiny/server/output_transformer/test_output_transformer_async.py @@ -6,9 +6,9 @@ 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, "t1").expect_value("t1; no call; async") OutputTextVerbatim(page, "t2").expect_value("t2; no call; async") - OutputTextVerbatim(page, "t3").expect_value("t3; call; sync") + OutputTextVerbatim(page, "t3").expect_value("t3; call; async") OutputTextVerbatim(page, "t4").expect_value("t4; call; async") - OutputTextVerbatim(page, "t5").expect_value("t5; call; sync; w/ extra_txt") + OutputTextVerbatim(page, "t5").expect_value("t5; call; async; w/ extra_txt") OutputTextVerbatim(page, "t6").expect_value("t6; call; async; w/ extra_txt") diff --git a/tests/playwright/shiny/server/reactive_event/app.py b/tests/playwright/shiny/server/reactive_event/app.py new file mode 100644 index 000000000..8fc83359f --- /dev/null +++ b/tests/playwright/shiny/server/reactive_event/app.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from shiny import App, Inputs, Outputs, Session, reactive, render, ui + +app_ui = ui.page_fluid( + ui.h2(ui.code("@reactive.event")), + ui.input_action_button("btn_count", "Immediate Count"), + ui.tags.br(), + ui.tags.label("Rendered on click:"), + ui.output_text_verbatim("txt_immediate", placeholder=True), + ui.input_action_button("btn_trigger", "Update Count"), + ui.tags.br(), + ui.tags.label("Reactive event on renderer:"), + ui.output_text_verbatim("txt_render_delayed", placeholder=True), + ui.tags.label("Reactive event on reactive calc:"), + ui.output_text_verbatim("txt_reactive_delayed", placeholder=True), +) + + +def server(input: Inputs, output: Outputs, session: Session): + @render.text + def txt_immediate(): + return input.btn_count() + + @render.text + @reactive.event(input.btn_trigger) + def txt_render_delayed(): + return input.btn_count() + + @reactive.calc() + @reactive.event(input.btn_trigger) + def delayed_btn_count() -> int: + return input.btn_count() + + @render.text + def txt_reactive_delayed(): + return str(delayed_btn_count()) + + +app = App(app_ui, server) diff --git a/tests/playwright/shiny/server/reactive_event/test_reactive_event.py b/tests/playwright/shiny/server/reactive_event/test_reactive_event.py new file mode 100644 index 000000000..4036bba5d --- /dev/null +++ b/tests/playwright/shiny/server/reactive_event/test_reactive_event.py @@ -0,0 +1,40 @@ +from conftest import ShinyAppProc +from controls import InputActionButton, OutputTextVerbatim +from playwright.sync_api import Page + + +def test_output_image_kitchen(page: Page, local_app: ShinyAppProc) -> None: + page.goto(local_app.url) + + btn_count = InputActionButton(page, "btn_count") + btn_trigger = InputActionButton(page, "btn_trigger") + txt_immediate = OutputTextVerbatim(page, "txt_immediate") + txt_render_delayed = OutputTextVerbatim(page, "txt_render_delayed") + txt_reactive_delayed = OutputTextVerbatim(page, "txt_reactive_delayed") + + txt_immediate.expect_value("0") + txt_render_delayed.expect_value("") + txt_reactive_delayed.expect_value("") + + btn_count.click() + btn_count.click() + btn_count.click() + txt_immediate.expect_value("3") + txt_render_delayed.expect_value("") + txt_reactive_delayed.expect_value("") + + btn_trigger.click() + txt_immediate.expect_value("3") + txt_render_delayed.expect_value("3") + txt_reactive_delayed.expect_value("3") + + btn_count.click() + btn_count.click() + txt_immediate.expect_value("5") + txt_render_delayed.expect_value("3") + txt_reactive_delayed.expect_value("3") + + btn_trigger.click() + txt_immediate.expect_value("5") + txt_render_delayed.expect_value("5") + txt_reactive_delayed.expect_value("5") diff --git a/tests/playwright/shiny/shiny-express/folium/app.py b/tests/playwright/shiny/shiny-express/folium/app.py index 15dac58e2..a6d5e8ad1 100644 --- a/tests/playwright/shiny/shiny-express/folium/app.py +++ b/tests/playwright/shiny/shiny-express/folium/app.py @@ -12,7 +12,7 @@ with ui.card(id="card"): "Static Map" - folium.Map( + folium.Map( # pyright: ignore[reportUnknownMemberType,reportGeneralTypeIssues] location=locations_coords["San Francisco"], tiles="USGS.USTopo", zoom_start=12 ) ui.input_radio_buttons( @@ -22,7 +22,7 @@ @render.display def folium_map(): "Map inside of render display call" - folium.Map( + folium.Map( # pyright: ignore[reportUnknownMemberType,reportGeneralTypeIssues] location=locations_coords[input.location()], tiles="cartodb positron", zoom_start=12, diff --git a/tests/playwright/shiny/shiny-express/suspend_display/app.py b/tests/playwright/shiny/shiny-express/suspend_display/app.py new file mode 100644 index 000000000..14baf6df7 --- /dev/null +++ b/tests/playwright/shiny/shiny-express/suspend_display/app.py @@ -0,0 +1,22 @@ +from shiny import render, ui +from shiny.express import input, suspend_display + +with ui.card(id="card"): + ui.input_slider("s1", "A", 1, 100, 20) + + @suspend_display + @render.text + def hidden(): + return input.s1() + + ui.input_slider("s2", "B", 1, 100, 40) + + # from shiny.express import ui_kwargs + # @ui_kwargs(placeholder=False) + # @ui_kwargs(placeholder=True) + @render.text() + def visible(): + # from shiny import req + + # req(False) + return input.s2() diff --git a/tests/playwright/shiny/shiny-express/suspend_display/test_suspend_display.py b/tests/playwright/shiny/shiny-express/suspend_display/test_suspend_display.py new file mode 100644 index 000000000..a609183fa --- /dev/null +++ b/tests/playwright/shiny/shiny-express/suspend_display/test_suspend_display.py @@ -0,0 +1,14 @@ +from conftest import ShinyAppProc +from controls import OutputTextVerbatim +from playwright.sync_api import Page +from playwright.sync_api import expect as playright_expect + + +def test_express_page_fluid(page: Page, local_app: ShinyAppProc) -> None: + page.goto(local_app.url) + + txt = OutputTextVerbatim(page, "visible") + txt.expect_value("40") + + playright_expect(page.locator("#visible")).to_have_count(1) + playright_expect(page.locator("#hidden")).to_have_count(0) diff --git a/tests/pytest/test_express_ui.py b/tests/pytest/test_express_ui.py index eee54b246..48b43aa53 100644 --- a/tests/pytest/test_express_ui.py +++ b/tests/pytest/test_express_ui.py @@ -4,7 +4,7 @@ import pytest from shiny import render, ui -from shiny.express import output_args, suspend_display +from shiny.express import suspend_display, ui_kwargs def test_express_ui_is_complete(): @@ -57,7 +57,7 @@ def text2(): assert ui.TagList(text2.tagify()).get_html_string() == "" - @output_args(placeholder=True) + @ui_kwargs(placeholder=True) @render.text def text3(): return "text" @@ -67,7 +67,7 @@ def text3(): == ui.output_text_verbatim("text3", placeholder=True).get_html_string() ) - @output_args(width=100) + @ui_kwargs(width=100) @render.text def text4(): return "text" diff --git a/tests/pytest/test_output_transformer.py b/tests/pytest/test_output_transformer.py index 04506b8e8..3d493b53f 100644 --- a/tests/pytest/test_output_transformer.py +++ b/tests/pytest/test_output_transformer.py @@ -1,10 +1,12 @@ from __future__ import annotations import asyncio -from typing import Any, overload +from typing import Any, cast, overload import pytest +from shiny._deprecated import ShinyDeprecationWarning +from shiny._namespaces import ResolvedId, Root from shiny._utils import is_async_callable from shiny.render.transformer import ( TransformerMetadata, @@ -12,6 +14,21 @@ output_transformer, resolve_value_fn, ) +from shiny.session import Session, session_context + +# import warnings +# warnings.filterwarnings("ignore", category=ShinyDeprecationWarning) + + +class _MockSession: + ns: ResolvedId = Root + + # This is needed so that Outputs don't throw an error. + def _is_hidden(self, name: str) -> bool: + return False + + +test_session = cast(Session, _MockSession()) def test_output_transformer_works(): @@ -196,15 +213,16 @@ def render_fn_sync(*args: str): # "Currently, `ValueFn` can not be truly async and "support sync render methods" @pytest.mark.asyncio -async def test_renderer_handler_fn_can_be_async(): +async def test_renderer_handler_or_transform_fn_can_be_async(): @output_transformer async def AsyncTransformer( _meta: TransformerMetadata, _fn: ValueFn[str], ) -> str: + assert is_async_callable(_fn) # Actually sleep to test that the handler is truly async await asyncio.sleep(0) - ret = await resolve_value_fn(_fn) + ret = await _fn() return ret # ## Setup overloads ============================================= @@ -222,7 +240,8 @@ def async_renderer( def async_renderer( _fn: AsyncTransformer.ValueFn | None = None, ) -> AsyncTransformer.OutputRenderer | AsyncTransformer.OutputRendererDecorator: - return AsyncTransformer(_fn) + with pytest.warns(ShinyDeprecationWarning): + return AsyncTransformer(_fn) test_val = "Test: Hello World!" @@ -232,20 +251,15 @@ def app_render_fn() -> str: # ## Test Sync: X ============================================= renderer_sync = async_renderer(app_render_fn) - renderer_sync._set_metadata( - None, # pyright: ignore[reportGeneralTypeIssues] - "renderer_sync", + renderer_sync._set_output_metadata( + output_name="renderer_sync", ) - if is_async_callable(renderer_sync): - raise RuntimeError("Expected `renderer_sync` to be a sync function") + # All renderers are async in execution. + assert is_async_callable(renderer_sync) - # !! 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) + with session_context(test_session): + val = await renderer_sync() + assert val == test_val # ## Test Async: √ ============================================= @@ -256,15 +270,15 @@ async def async_app_render_fn() -> str: return async_test_val renderer_async = async_renderer(async_app_render_fn) - renderer_async._set_metadata( - None, # pyright: ignore[reportGeneralTypeIssues] - "renderer_async", + renderer_async._set_output_metadata( + output_name="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 + with session_context(test_session): + ret = await renderer_async() + assert ret == async_test_val # "Currently, `ValueFnA` can not be truly async and "support sync render methods". @@ -279,7 +293,7 @@ async def YieldTransformer( 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) + ret = await _fn() return ret # ## Setup overloads ============================================= @@ -297,7 +311,8 @@ def yield_renderer( def yield_renderer( _fn: YieldTransformer.ValueFn | None = None, ) -> YieldTransformer.OutputRenderer | YieldTransformer.OutputRendererDecorator: - return YieldTransformer(_fn) + with pytest.warns(ShinyDeprecationWarning): + return YieldTransformer(_fn) test_val = "Test: Hello World!" @@ -307,15 +322,14 @@ def app_render_fn() -> str: # ## Test Sync: √ ============================================= renderer_sync = yield_renderer(app_render_fn) - renderer_sync._set_metadata( - None, # pyright: ignore[reportGeneralTypeIssues] - "renderer_sync", + renderer_sync._set_output_metadata( + output_name="renderer_sync", ) - if is_async_callable(renderer_sync): - raise RuntimeError("Expected `renderer_sync` to be a sync function") + assert is_async_callable(renderer_sync) - ret = renderer_sync() - assert ret == test_val + with session_context(test_session): + ret = await renderer_sync() + assert ret == test_val # ## Test Async: √ ============================================= @@ -326,12 +340,23 @@ async def async_app_render_fn() -> str: return async_test_val renderer_async = yield_renderer(async_app_render_fn) - renderer_async._set_metadata( - None, # pyright: ignore[reportGeneralTypeIssues] - "renderer_async", + renderer_async._set_output_metadata( + output_name="renderer_async", ) - if not is_async_callable(renderer_async): - raise RuntimeError("Expected `renderer_async` to be a coro function") + assert is_async_callable(renderer_async) + + with session_context(test_session): + ret = await renderer_async() + assert ret == async_test_val + + +@pytest.mark.asyncio +async def test_resolve_value_fn_is_deprecated(): + with pytest.warns(ShinyDeprecationWarning): + test_val = 42 + + async def value_fn(): + return test_val - ret = await renderer_async() - assert ret == async_test_val + ret = await resolve_value_fn(value_fn) + assert test_val == ret diff --git a/tests/pytest/test_plot_sizing.py b/tests/pytest/test_plot_sizing.py index 4390f85f2..da6a354fc 100644 --- a/tests/pytest/test_plot_sizing.py +++ b/tests/pytest/test_plot_sizing.py @@ -1,5 +1,5 @@ from shiny import render, ui -from shiny.express import output_args +from shiny.express import ui_kwargs from shiny.types import MISSING @@ -27,10 +27,10 @@ def foo(): assert rendered == str(ui.output_plot("foo")) -def test_decorator_output_args(): - """@output_args is respected""" +def test_decorator_ui_kwargs(): + """@ui_kwargs is respected""" - @output_args(width="640px", height="480px") + @ui_kwargs(width="640px", height="480px") @render.plot() def foo(): ... @@ -39,10 +39,10 @@ def foo(): assert rendered == str(ui.output_plot("foo", width="640px", height="480px")) -def test_decorator_output_args_priority(): - """@output_args should override render.plot width/height""" +def test_decorator_ui_kwargs_priority(): + """@ui_kwargs should override render.plot width/height""" - @output_args(width="640px", height=480) + @ui_kwargs(width="640px", height=480) @render.plot(width=1280, height=960) def foo(): ... @@ -52,10 +52,10 @@ def foo(): assert rendered == str(ui.output_plot("foo", width=640, height="480px")) -def test_decorator_output_args_MISSING(): +def test_decorator_ui_kwargs_MISSING(): """Not saying we support this, but test how MISSING interacts""" - @output_args(width=MISSING) + @ui_kwargs(width=MISSING) @render.plot(width=1280, height=MISSING) def foo(): ... diff --git a/tests/pytest/test_reactives.py b/tests/pytest/test_reactives.py index b9439fd3b..093d7aefd 100644 --- a/tests/pytest/test_reactives.py +++ b/tests/pytest/test_reactives.py @@ -1104,7 +1104,7 @@ async def _(): with pytest.raises(TypeError): # Should complain that @event() can't take the result of @Effect (which returns # None). - @event(lambda: 1) # type: ignore + @event(lambda: 1) # pyright: ignore[reportGeneralTypeIssues] @effect() async def _(): ... @@ -1119,14 +1119,14 @@ async def _(): with pytest.raises(TypeError): # Should complain that @event must be applied before @render.text. At some point # in the future, this may be allowed. - @event(lambda: 1) # No static type error, unfortunately. + @event(lambda: 1) # pyright: ignore[reportGeneralTypeIssues] @render.text async def _(): ... with pytest.raises(TypeError): # Should complain that @event must be applied before @output. - @event(lambda: 1) # type: ignore + @event(lambda: 1) # pyright: ignore[reportGeneralTypeIssues] @render.text async def _(): ... @@ -1163,13 +1163,13 @@ async def test_output_type_check(): with pytest.raises(TypeError): # Should complain about bare function - @output # type: ignore + @output # pyright: ignore[reportGeneralTypeIssues,reportUntypedFunctionDecorator] def _(): ... with pytest.raises(TypeError): # Should complain about @event - @output # type: ignore + @output # pyright: ignore[reportGeneralTypeIssues,reportUntypedFunctionDecorator] @event(lambda: 1) def _(): ... @@ -1177,22 +1177,22 @@ def _(): with pytest.raises(TypeError): # Should complain about @event, even with render.text. Although maybe in the # future this will be allowed. - @output # type: ignore - @event(lambda: 1) + @output # pyright: ignore[reportGeneralTypeIssues,reportUntypedFunctionDecorator] + @event(lambda: 1) # pyright: ignore[reportGeneralTypeIssues] @render.text def _(): ... with pytest.raises(TypeError): # Should complain about @Calc - @output # type: ignore + @output # pyright: ignore[reportGeneralTypeIssues,reportUntypedFunctionDecorator] @calc def _(): ... with pytest.raises(TypeError): # Should complain about @Effet - @output # type: ignore + @output # pyright: ignore[reportGeneralTypeIssues,reportUntypedFunctionDecorator] @effect def _(): ... diff --git a/tests/pytest/test_renderer.py b/tests/pytest/test_renderer.py new file mode 100644 index 000000000..a50166552 --- /dev/null +++ b/tests/pytest/test_renderer.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import pytest + +from shiny.render.renderer import Renderer, ValueFn + + +@pytest.mark.asyncio +async def test_renderer_works(): + # No args works + class test_renderer(Renderer[str]): + async def transform(self, value: str) -> str: + return value + " " + value + + @test_renderer() + def txt_paren() -> str: + return "Hello World!" + + val = await txt_paren.render() + assert val == "Hello World! Hello World!" + + @test_renderer + def txt_no_paren() -> str: + return "Hello World!" + + val = await txt_no_paren.render() + assert val == "Hello World! Hello World!" + + +@pytest.mark.asyncio +async def test_renderer_works_with_args(): + # No args works + class test_renderer_with_args(Renderer[str]): + def __init__(self, _fn: ValueFn[str] = None, *, times: int = 2): + super().__init__(_fn) + self.times: int = times + + async def transform(self, value: str) -> str: + values = [value for _ in range(self.times)] + return " ".join(values) + + @test_renderer_with_args + def txt2() -> str: + return "42" + + @test_renderer_with_args(times=4) + def txt4() -> str: + return "42" + + val = await txt2.render() + assert val == "42 42" + val = await txt4.render() + assert val == "42 42 42 42"