diff --git a/CHANGELOG.md b/CHANGELOG.md index b5f0fafb4..8b9c76445 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,10 +27,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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) + * Legacy renderers that will be removed in the near future: + * `shiny.render.RenderFunction` + * `shiny.render.RenderFunctionAsync` + * `shiny.render.transformer.OutputRenderer` + * `shiny.render.transformer.OutputRendererSync` + * `shiny.render.transformer.OutputRendererAsync` -* `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) - -* `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`. `shiny.render.*` will be removed in the near future. (#964) ### Other changes diff --git a/shiny/render/__init__.py b/shiny/render/__init__.py index ce47f4f64..b4cfdae75 100644 --- a/shiny/render/__init__.py +++ b/shiny/render/__init__.py @@ -22,6 +22,10 @@ ui, download, ) +from ._deprecated import ( # noqa: F401 + RenderFunction, # pyright: ignore[reportUnusedImport] + RenderFunctionAsync, # pyright: ignore[reportUnusedImport] +) __all__ = ( # TODO-future: Document which variables are exposed via different import approaches diff --git a/shiny/render/_deprecated.py b/shiny/render/_deprecated.py new file mode 100644 index 000000000..01b957e49 --- /dev/null +++ b/shiny/render/_deprecated.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Generic + +from .transformer._transformer import ( + IT, + OT, + OutputRendererAsync, + OutputRendererSync, + TransformerMetadata, + 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/transformer/__init__.py b/shiny/render/transformer/__init__.py index d92a674ca..3010e1f33 100644 --- a/shiny/render/transformer/__init__.py +++ b/shiny/render/transformer/__init__.py @@ -9,6 +9,8 @@ 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] ) diff --git a/shiny/render/transformer/_transformer.py b/shiny/render/transformer/_transformer.py index bb7315d18..d6a57a9c6 100644 --- a/shiny/render/transformer/_transformer.py +++ b/shiny/render/transformer/_transformer.py @@ -17,6 +17,7 @@ ) import inspect +from abc import ABC, abstractmethod from typing import ( TYPE_CHECKING, Awaitable, @@ -40,7 +41,7 @@ from ..._deprecated import warn_deprecated from ..._docstring import add_example from ..._typing_extensions import Concatenate, ParamSpec -from ..._utils import is_async_callable +from ..._utils import is_async_callable, run_coro_sync from ...types import MISSING # Input type for the user-spplied function that is passed to a render.xx @@ -165,11 +166,14 @@ 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. +be defined as an asynchronous function but should only asynchronously yield when the +second parameter (of type `ValueFn[IT]`) is awaitable. If the second function argument +is not awaitable (a _synchronous_ function), then the execution of the transform +function should also be synchronous. """ -class OutputRenderer(RendererBase, Generic[OT]): +class OutputRenderer(RendererBase, ABC, Generic[OT]): """ Output Renderer @@ -178,7 +182,11 @@ class OutputRenderer(RendererBase, Generic[OT]): :class:`~shiny.Outputs` output value. When the `.__call__` method is invoked, the transform function (`transform_fn`) - (typically defined by package authors) is invoked. + (typically defined by package authors) is invoked. The wrapping classes + (:class:`~shiny.render.transformer.OutputRendererSync` and + :class:`~shiny.render.transformer.OutputRendererAsync`) will enforce whether the + transform function is synchronous or asynchronous independent of the awaitable + syntax. The transform function (`transform_fn`) is given `meta` information (:class:`~shiny.render.transformer.TranformerMetadata`), the (app-supplied) value @@ -201,18 +209,19 @@ class OutputRenderer(RendererBase, Generic[OT]): * 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` """ - async def __call__(self) -> OT: + @abstractmethod + def __call__(self) -> OT: """ - 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. + Executes the output renderer (both the app's output value function and transformer). """ - return await self._run() + ... def __init__( self, @@ -230,7 +239,10 @@ 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. + `OT`. The `params` will used as variadic keyword arguments. This method + should only use `await` syntax when the value function (`ValueFn[IT]`) is + awaitable. If the value function is not awaitable (a _synchronous_ + function), then the function should execute synchronously. params App-provided parameters for the transform function (`transform_fn`). default_ui @@ -250,7 +262,8 @@ def __init__( if not is_async_callable(transform_fn): raise TypeError( - "OutputRenderer requires an async tranformer function (`transform_fn`)." + self.__class__.__name__ + + " requires an async tranformer function (`transform_fn`)." " Please define your transform function as asynchronous." " Ex `async def my_transformer(....`" ) @@ -346,6 +359,93 @@ async def render(self) -> Jsonifiable: return jsonifiable_ret +# Using a second class to help clarify that it is of a particular type +class OutputRendererSync(OutputRenderer[OT]): + """ + Output Renderer (Synchronous) + + This class is used to define a synchronous renderer. The `.__call__` method is + implemented to call the `._run` method synchronously. + + See Also + -------- + * :class:`~shiny.render.transformer.OutputRenderer` + * :class:`~shiny.render.transformer.OutputRendererAsync` + """ + + def __init__( + self, + value_fn: ValueFnSync[IT], + transform_fn: TransformFn[IT, P, OT], + params: TransformerParams[P], + default_ui: Optional[DefaultUIFn] = 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 == OutputRenderer[OT] + 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()) + + +# 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[DefaultUIFn] = 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 == OutputRenderer[OT] + 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() + + # ====================================================================================== # Restrict the transformer function # ====================================================================================== @@ -581,6 +681,11 @@ 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 ---------- @@ -616,13 +721,24 @@ def renderer_decorator( def as_value_fn( fn: ValueFn[IT], ) -> OutputRenderer[OT]: - return OutputRenderer( - value_fn=fn, - transform_fn=transform_fn, - params=params, - default_ui=default_ui, - default_ui_passthrough_args=default_ui_passthrough_args, - ) + 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, + ) if value_fn is None: return as_value_fn @@ -661,6 +777,11 @@ 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 diff --git a/tests/playwright/examples/example_apps.py b/tests/playwright/examples/example_apps.py index 6fff36ad4..0fbbfa6cf 100644 --- a/tests/playwright/examples/example_apps.py +++ b/tests/playwright/examples/example_apps.py @@ -41,6 +41,7 @@ def get_apps(path: str) -> typing.List[str]: } output_transformer_errors = [ "ShinyDeprecationWarning: `shiny.render.transformer.output_transformer()`", + " super().__init__(", " return OutputRenderer", # brownian example app "ShinyDeprecationWarning:", diff --git a/tests/playwright/shiny/server/output_transformer/test_output_transformer_async.py b/tests/playwright/shiny/server/output_transformer/test_output_transformer_async.py index e2c1fc5f7..11230bff1 100644 --- a/tests/playwright/shiny/server/output_transformer/test_output_transformer_async.py +++ b/tests/playwright/shiny/server/output_transformer/test_output_transformer_async.py @@ -3,7 +3,7 @@ from playwright.sync_api import Page -def test_output_image_kitchen(page: Page, local_app: ShinyAppProc) -> None: +def test_output_transformer(page: Page, local_app: ShinyAppProc) -> None: page.goto(local_app.url) OutputTextVerbatim(page, "t1").expect_value("t1; no call; sync") diff --git a/tests/pytest/test_output_transformer.py b/tests/pytest/test_output_transformer.py index 661add665..4033f2197 100644 --- a/tests/pytest/test_output_transformer.py +++ b/tests/pytest/test_output_transformer.py @@ -218,8 +218,9 @@ async def AsyncTransformer( _meta: TransformerMetadata, _fn: ValueFn[str], ) -> str: - # Actually sleep to test that the handler is truly async - await asyncio.sleep(0) + if is_async_callable(_fn): + # Conditionally sleep to test that the handler is truly async + await asyncio.sleep(0) ret = await resolve_value_fn(_fn) return ret @@ -253,10 +254,10 @@ def app_render_fn() -> str: output_name="renderer_sync", ) # All renderers are async in execution. - assert is_async_callable(renderer_sync) + assert not is_async_callable(renderer_sync) with session_context(test_session): - val = await renderer_sync() + val = renderer_sync() assert val == test_val # ## Test Async: √ =============================================