From f802db220dcba42cfdd31e69785c0ddb4004cd4e Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Thu, 13 Jul 2023 15:43:29 -0400 Subject: [PATCH 01/64] First pass at creating a renderer generator to handle boilerplate and to be used as a decorator --- shiny/_utils.py | 6 + shiny/render/__init__.py | 32 +- shiny/render/_dataframe.py | 152 ++---- shiny/render/_render.py | 923 ++++++++++++++++++------------------- 4 files changed, 503 insertions(+), 610 deletions(-) diff --git a/shiny/_utils.py b/shiny/_utils.py index 5c471143f..e8d1adb7e 100644 --- a/shiny/_utils.py +++ b/shiny/_utils.py @@ -262,6 +262,12 @@ def is_async_callable( return False +# def not_is_async_callable( +# obj: Callable[P, T] | Callable[P, Awaitable[T]] +# ) -> TypeGuard[Callable[P, T]]: +# return not is_async_callable(obj) + + # See https://stackoverflow.com/a/59780868/412655 for an excellent explanation # of how this stuff works. # For a more in-depth explanation, see diff --git a/shiny/render/__init__.py b/shiny/render/__init__.py index b530c3e6b..e19f0a457 100644 --- a/shiny/render/__init__.py +++ b/shiny/render/__init__.py @@ -3,41 +3,37 @@ """ from ._render import ( # noqa: F401 - RenderFunction, # pyright: ignore[reportUnusedImport] - RenderFunctionAsync, # pyright: ignore[reportUnusedImport] - RenderText, # pyright: ignore[reportUnusedImport] - RenderTextAsync, # pyright: ignore[reportUnusedImport] + RenderFunctionMeta as RenderFunctionMeta, + RenderFunction as RenderFunction, + RenderFunctionAsync as RenderFunctionAsync, + renderer_gen, text, - RenderPlot, # pyright: ignore[reportUnusedImport] - RenderPlotAsync, # pyright: ignore[reportUnusedImport] plot, - RenderImage, # pyright: ignore[reportUnusedImport] - RenderImageAsync, # pyright: ignore[reportUnusedImport] image, - RenderTable, # pyright: ignore[reportUnusedImport] - RenderTableAsync, # pyright: ignore[reportUnusedImport] table, - RenderUI, # pyright: ignore[reportUnusedImport] - RenderUIAsync, # pyright: ignore[reportUnusedImport] ui, ) from ._dataframe import ( # noqa: F401 - RenderDataFrame, # pyright: ignore[reportUnusedImport] - RenderDataFrameAsync, # pyright: ignore[reportUnusedImport] - DataGrid, - DataTable, + DataGrid as DataGrid, + DataTable as DataTable, data_frame, ) __all__ = ( - "DataGrid", - "DataTable", + # TODO-barret; Q: Remove `DataGrid` and `DataTable` methods from `__all__` + # # Is `DataGrid` and `DataTable` necessary? I don't believe they are _render_ methods. + # # They would be available via `from render import DataGrid`, + # # just wouldn't be available as `render.DataGrid` + # "DataGrid", + # "DataTable", + # # "data_frame", "text", "plot", "image", "table", "ui", + "renderer_gen", ) diff --git a/shiny/render/_dataframe.py b/shiny/render/_dataframe.py index 3eebad1b7..df0e225c0 100644 --- a/shiny/render/_dataframe.py +++ b/shiny/render/_dataframe.py @@ -2,24 +2,10 @@ import abc import json -import typing -from typing import ( - TYPE_CHECKING, - Any, - Awaitable, - Callable, - Literal, - Optional, - Protocol, - Union, - cast, - overload, - runtime_checkable, -) - -from .. import _utils +from typing import TYPE_CHECKING, Any, Literal, Protocol, Union, cast, runtime_checkable + from .._docstring import add_example -from . import RenderFunction, RenderFunctionAsync +from . import RenderFunctionMeta, renderer_gen if TYPE_CHECKING: import pandas as pd @@ -207,99 +193,14 @@ def to_payload(self) -> object: DataFrameResult = Union[None, "pd.DataFrame", DataGrid, DataTable] -RenderDataFrameFunc = Callable[[], DataFrameResult] -RenderDataFrameFuncAsync = Callable[[], Awaitable[DataFrameResult]] - - -@runtime_checkable -class PandasCompatible(Protocol): - # Signature doesn't matter, runtime_checkable won't look at it anyway - def to_pandas(self) -> object: - ... - - -class RenderDataFrame(RenderFunction[DataFrameResult, object]): - def __init__( - self, - fn: RenderDataFrameFunc, - ) -> None: - super().__init__(fn) - # The Render*Async subclass will pass in an async function, but it tells the - # static type checker that it's synchronous. wrap_async() is smart -- if is - # passed an async function, it will not change it. - self._fn: RenderDataFrameFuncAsync = _utils.wrap_async(fn) - - def __call__(self) -> object: - return _utils.run_coro_sync(self._run()) - - async def _run(self) -> object: - x = await self._fn() - - if x is None: - return None - - if not isinstance(x, AbstractTabularData): - x = DataGrid( - cast_to_pandas( - x, "@render.data_frame doesn't know how to render objects of type" - ) - ) - - return x.to_payload() - - -def cast_to_pandas(x: object, error_message_begin: str) -> object: - import pandas as pd - - if not isinstance(x, pd.DataFrame): - if not isinstance(x, PandasCompatible): - raise TypeError( - error_message_begin - + f" '{str(type(x))}'. Use either a pandas.DataFrame, or an object" - " that has a .to_pandas() method." - ) - return x.to_pandas() - return x - - -class RenderDataFrameAsync( - RenderDataFrame, RenderFunctionAsync[DataFrameResult, object] -): - def __init__( - self, - fn: RenderDataFrameFuncAsync, - ) -> None: - if not _utils.is_async_callable(fn): - raise TypeError(self.__class__.__name__ + " requires an async function") - super().__init__( - typing.cast(RenderDataFrameFunc, fn), - ) - - async def __call__( # pyright: ignore[reportIncompatibleMethodOverride] - self, - ) -> object: - return await self._run() - - -@overload -def data_frame(fn: RenderDataFrameFunc | RenderDataFrameFuncAsync) -> RenderDataFrame: - ... - - -@overload -def data_frame() -> ( - Callable[[RenderDataFrameFunc | RenderDataFrameFuncAsync], RenderDataFrame] -): - ... - +# TODO-barret; Double check this is working. May need to port `__name__` and `__docs__` of `value_fn` @add_example() +@renderer_gen def data_frame( - fn: Optional[RenderDataFrameFunc | RenderDataFrameFuncAsync] = None, -) -> ( - RenderDataFrame - | Callable[[RenderDataFrameFunc | RenderDataFrameFuncAsync], RenderDataFrame] -): + meta: RenderFunctionMeta, + x: DataFrameResult, +) -> object | None: """ Reactively render a Pandas data frame object (or similar) as a basic HTML table. @@ -323,14 +224,33 @@ def data_frame( :class:`~shiny.render.DataTable` :func:`~shiny.ui.output_data_frame` """ + 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" + ) + ) - def wrapper(fn: RenderDataFrameFunc | RenderDataFrameFuncAsync) -> RenderDataFrame: - if _utils.is_async_callable(fn): - return RenderDataFrameAsync(fn) - else: - return RenderDataFrame(cast(RenderDataFrameFunc, fn)) - if fn is None: - return wrapper - else: - return wrapper(fn) +@runtime_checkable +class PandasCompatible(Protocol): + # Signature doesn't matter, runtime_checkable won't look at it anyway + def to_pandas(self) -> object: + ... + + +def cast_to_pandas(x: object, error_message_begin: str) -> object: + import pandas as pd + + if not isinstance(x, pd.DataFrame): + if not isinstance(x, PandasCompatible): + raise TypeError( + error_message_begin + + f" '{str(type(x))}'. Use either a pandas.DataFrame, or an object" + " that has a .to_pandas() method." + ) + return x.to_pandas() + return x diff --git a/shiny/render/_render.py b/shiny/render/_render.py index 7915b992c..0e02abf67 100644 --- a/shiny/render/_render.py +++ b/shiny/render/_render.py @@ -2,48 +2,33 @@ # See https://www.python.org/dev/peps/pep-0655/#usage-in-python-3-11 from __future__ import annotations -__all__ = ( - "RenderFunction", - "RenderFunctionAsync", - "RenderText", - "RenderTextAsync", - "text", - "RenderPlot", - "RenderPlotAsync", - "plot", - "RenderImage", - "RenderImageAsync", - "image", - "RenderTable", - "RenderTableAsync", - "table", - "RenderUI", - "RenderUIAsync", - "ui", -) - import base64 +import inspect import os import sys import typing + +# import random from typing import ( TYPE_CHECKING, - Any, Awaitable, Callable, + Concatenate, Generic, Optional, + ParamSpec, + Tuple, TypeVar, Union, cast, overload, ) -# These aren't used directly in this file, but they seem necessary for Sphinx to work -# cleanly. -from htmltools import Tag # pyright: ignore[reportUnusedImport] # noqa: F401 -from htmltools import Tagifiable # pyright: ignore[reportUnusedImport] # noqa: F401 -from htmltools import TagList # pyright: ignore[reportUnusedImport] # noqa: F401 +# # These aren't used directly in this file, but they seem necessary for Sphinx to work +# # cleanly. +# from htmltools import Tag # pyright: ignore[reportUnusedImport] # noqa: F401 +# from htmltools import Tagifiable # pyright: ignore[reportUnusedImport] # noqa: F401 +# from htmltools import TagList # pyright: ignore[reportUnusedImport] # noqa: F401 from htmltools import TagChild if TYPE_CHECKING: @@ -58,28 +43,38 @@ from ..types import ImgData from ._try_render_plot import try_render_matplotlib, try_render_pil, try_render_plotnine +__all__ = ( + "renderer_gen", + "RenderFunction", + "RenderFunctionAsync", + "text", + "plot", + "image", + "table", + "ui", +) + + # Input type for the user-spplied function that is passed to a render.xx IT = TypeVar("IT") # Output type after the RenderFunction.__call__ method is called on the IT object. OT = TypeVar("OT") +# Param specification for value function +P = ParamSpec("P") +# Generic type var +T = TypeVar("T") # ====================================================================================== -# RenderFunction/RenderFunctionAsync base class +# RenderFunctionMeta/RenderFunction/RenderFunctionAsync base class # ====================================================================================== -# A RenderFunction object is given a user-provided function which returns an IT. When -# the .__call___ method is invoked, it calls the user-provided function (which returns -# an IT), then converts the IT to an OT. Note that in many cases but not all, IT and OT -# will be the same. -class RenderFunction(Generic[IT, OT]): - def __init__(self, fn: Callable[[], IT]) -> None: - self.__name__ = fn.__name__ - self.__doc__ = fn.__doc__ - - def __call__(self) -> OT: - raise NotImplementedError +# TODO-barret; Remove `RenderFunctionMeta`. Just use `RenderFunction` +# TODO-barret; Where `RenderFunctionMeta` is supplied to `meta`, instead, use a +# TypedDict of information so users don't leverage `_` values +class RenderFunctionMeta: + is_async: bool def set_metadata(self, session: Session, name: str) -> None: """When RenderFunctions are assigned to Output object slots, this method @@ -89,64 +84,300 @@ def set_metadata(self, session: Session, name: str) -> None: self._name: str = name +# A RenderFunction object is given a user-provided function (`value_fn`) which returns +# an `IT`. When the .__call___ method is invoked, it calls the user-provided function +# (which returns an `IT`), then converts the `IT` to an `OT`. Note that in many cases +# but not all, `IT` and `OT` will be the same. +class RenderFunction(Generic[IT, OT], RenderFunctionMeta): + def __init__(self, render_fn: UserFuncAsync[IT]) -> None: + self.__name__ = render_fn.__name__ # TODO-barret; Set name of async function + self.__doc__ = render_fn.__doc__ + self._fn = render_fn + + def __call__(self) -> OT | None: + raise NotImplementedError + + # The reason for having a separate RenderFunctionAsync class is because the __call__ # method is marked here as async; you can't have a single class where one method could # be either sync or async. class RenderFunctionAsync(RenderFunction[IT, OT]): - async def __call__(self) -> OT: # pyright: ignore[reportIncompatibleMethodOverride] + async def __call__( # pyright: ignore[reportIncompatibleMethodOverride] + self, + ) -> OT | None: raise NotImplementedError # ====================================================================================== -# RenderText +# RenderSync/RenderAsync base classes for Sync/async render functions # ====================================================================================== -RenderTextFunc = Callable[[], "str | None"] -RenderTextFuncAsync = Callable[[], Awaitable["str | None"]] +# TODO-barret; Q: Why are there separate classes between RenderSync and RenderFunction (and RenderAsync / RenderFunctionAsync) +# TODO-barret; Collapse RenderFunction / RenderSync & RenderFunctionAsync / RenderAsync? + + +class RenderSync(Generic[IT, OT, P], RenderFunction[IT, OT]): + def __init__( + self, + # Use single arg to minimize overlap with P.kwargs + _render_args: _RenderArgsSync[IT, P, OT], + *args: P.args, + **kwargs: P.kwargs, + ) -> None: + # Unpack args + _fn, _value_fn = _render_args + + # super == RenderFunction + _awaitable_fn = _utils.wrap_async(_fn) + super().__init__(_awaitable_fn) + self.is_async = False -class RenderText(RenderFunction["str | None", "str | None"]): - def __init__(self, fn: RenderTextFunc) -> None: - super().__init__(fn) # The Render*Async subclass will pass in an async function, but it tells the # static type checker that it's synchronous. wrap_async() is smart -- if is # passed an async function, it will not change it. - self._fn: RenderTextFuncAsync = _utils.wrap_async(fn) + # self._fn: UserFuncSync[IT] = _utils.wrap_async(_fn) + + self._value_fn = _utils.wrap_async(_value_fn) + self._args = args + self._kwargs = kwargs - def __call__(self) -> str | None: + def __call__(self) -> OT | None: return _utils.run_coro_sync(self._run()) - async def _run(self) -> str | None: - res = await self._fn() - if res is None: - return None - return str(res) + async def _run(self) -> OT | None: + fn_val = await self._fn() + # TODO-barret; `self._value_fn()` must handle the `None` case + # TODO-barret; Make `OT` include `| None` + + if fn_val is None: + return fn_val + ret = await self._value_fn( + # RenderFunctionMeta + self, + # IT + fn_val, + # P + *self._args, + **self._kwargs, + ) + return ret + +class RenderAsync(Generic[IT, OT, P], RenderFunctionAsync[IT, OT]): + def __init__( + self, + # Use single arg to minimize overlap with P.kwargs + _render_args: _RenderArgsAsync[IT, P, OT], + *args: P.args, + **kwargs: P.kwargs, + ) -> None: + # Unpack args + _fn, _value_fn = _render_args -class RenderTextAsync(RenderText, RenderFunctionAsync["str | None", "str | None"]): - def __init__(self, fn: RenderTextFuncAsync) -> None: - if not _utils.is_async_callable(fn): + if not _utils.is_async_callable(_fn): raise TypeError(self.__class__.__name__ + " requires an async function") - super().__init__(typing.cast(RenderTextFunc, fn)) + # super == RenderFunctionAsync, RenderFunction + super().__init__(_fn) + self.is_async = True - async def __call__( # pyright: ignore[reportIncompatibleMethodOverride] - self, - ) -> str | None: + self._value_fn = _utils.wrap_async(_value_fn) + self._args = args + self._kwargs = kwargs + + async def __call__(self) -> OT | None: return await self._run() + async def _run(self) -> OT | None: + fn_val = await self._fn() + # If User returned None, quit early and return `None` before being processed + if fn_val is None: + return fn_val + ret = await self._value_fn( + # RenderFunctionMeta + self, + # IT + fn_val, + # P + *self._args, + **self._kwargs, + ) + return ret + + +# ====================================================================================== +# Type definitions +# ====================================================================================== -@overload -def text(fn: RenderTextFunc | RenderTextFuncAsync) -> RenderText: - ... +UserFuncSync = Callable[[], IT | None] +UserFuncAsync = Callable[[], Awaitable[IT | None]] +UserFunc = UserFuncSync[IT] | UserFuncAsync[IT] +ValueFunc = ( + Callable[Concatenate[RenderFunctionMeta, IT, P], OT | None] + | Callable[Concatenate[RenderFunctionMeta, IT, P], Awaitable[OT | None]] +) +# RenderDecoSync = Callable[[UserFuncSync[IT]], RenderSync[IT, OT, P]] +# RenderDecoAsync = Callable[[UserFuncAsync[IT]], RenderAsync[IT, OT, P]] +RenderDeco = Callable[ + [UserFuncSync[IT] | UserFuncAsync[IT]], + RenderSync[IT, OT, P] | RenderAsync[IT, OT, P], +] -@overload -def text() -> Callable[[RenderTextFunc | RenderTextFuncAsync], RenderText]: - ... +_RenderArgsSync = Tuple[UserFuncSync[IT], ValueFunc[IT, P, OT]] +_RenderArgsAsync = Tuple[UserFuncAsync[IT], ValueFunc[IT, P, OT]] +# ====================================================================================== +# Restrict the value function +# ====================================================================================== + + +# assert: No variable length positional values; +# * We need a way to distinguish between a plain function and args supplied to the next function. This is done by not allowing `*args`. +# assert: All kwargs of value_fn should have a default value +# * This makes calling the method with both `()` and without `()` possible / consistent. +def assert_value_fn(value_fn: ValueFunc[IT, P, OT]): + params = inspect.Signature.from_callable(value_fn).parameters + + for i, param in zip(range(len(params)), params.values()): + # # Not a good test as `param.annotation` has type `str`: + # if i == 0: + # print(type(param.annotation)) + # assert isinstance(param.annotation, RenderFunctionMeta) + + # Make sure there are no more than 2 positional args + if i >= 2 and param.kind == inspect.Parameter.POSITIONAL_OR_KEYWORD: + raise TypeError( + "`value_fn=` must not contain more than 2 positional parameters" + ) + # Make sure there are no `*args` + if param.kind == inspect.Parameter.VAR_POSITIONAL: + raise TypeError( + f"No variadic parameters (e.g. `*args`) can be supplied to `value_fn=`. Received: `{param.name}`" + ) + if ( + param.kind == inspect.Parameter.KEYWORD_ONLY + and param.default is inspect.Parameter.empty + ): + raise TypeError( + f"In `value_fn=`, parameter `{param.name}` did not have a default value" + ) + + +def renderer_gen( + value_fn: ValueFunc[IT, P, OT], +): + """\ + Renderer generator + + TODO-barret; Docs go here! + """ + assert_value_fn(value_fn) + + @overload + def render_deco(render_fn: UserFuncSync[IT]) -> RenderSync[IT, OT, P]: + ... + + @overload + def render_deco(render_fn: UserFuncAsync[IT]) -> RenderAsync[IT, OT, P]: + ... + + @overload + def render_deco(*args: P.args, **kwargs: P.kwargs) -> RenderDeco[IT, OT, P]: + ... + + # # If we use `wraps()`, the overloads are lost. + # @functools.wraps(value_fn) + + # Ignoring the type issue on the next line of code as the overloads for + # `render_deco` are not consistent with the function definition. + # Motivation: + # * https://peps.python.org/pep-0612/ does allow for prepending an arg + # (`_render_fn`). + # * However, the overload is not happy when both a positional arg (`_render_fn`) is + # dropped and the variadic args (`*args`) are kept. + # * The variadic args CAN NOT be dropped as PEP612 states that both components of + # the `ParamSpec` must be used in the same function signature. + # * By making assertions on `P.args` to only allow for `*`, we _can_ make overloads + # that use either the single positional arg (`_render_fn`) or + # the `P.kwargs` (as `P.args` == `*`) + def render_deco( # type: ignore[reportGeneralTypeIssues] + _render_fn: Optional[UserFuncSync[IT] | UserFuncAsync[IT]] = None, + *args: P.args, # Equivalent to `*` after assertions in `assert_value_fn()` + **kwargs: P.kwargs, + ) -> ( + Callable[[UserFuncSync[IT]], RenderSync[IT, OT, P]] + | Callable[[UserFuncAsync[IT]], RenderAsync[IT, OT, P]] + | RenderSync[IT, OT, P] + | RenderAsync[IT, OT, P] + ): + # `args` **must** be in `render_deco` definition. + # Make sure there no `args`! + assert len(args) == 0 + + def wrapper_sync( + wrapper_sync_fn: UserFuncSync[IT], + ) -> RenderSync[IT, OT, P]: + return RenderSync( + (wrapper_sync_fn, value_fn), + *args, + **kwargs, + ) + + def wrapper_async( + wrapper_async_fn: UserFuncAsync[IT], + ) -> RenderAsync[IT, OT, P]: + # Make sure there no `args`! + assert len(args) == 0 + + return RenderAsync( + (wrapper_async_fn, value_fn), + *args, + **kwargs, + ) + + @overload + def wrapper( + wrapper_fn: UserFuncSync[IT], + ) -> RenderSync[IT, OT, P]: + ... + + @overload + def wrapper( + wrapper_fn: UserFuncAsync[IT], + ) -> RenderAsync[IT, OT, P]: + ... + + def wrapper( + wrapper_fn: UserFuncSync[IT] | UserFuncAsync[IT], + ) -> RenderSync[IT, OT, P] | RenderAsync[IT, OT, P]: + if _utils.is_async_callable(wrapper_fn): + return wrapper_async(wrapper_fn) + else: + # Is not not `UserFuncAsync[IT]`. Cast `wrapper_fn` + wrapper_fn = cast(UserFuncSync[IT], wrapper_fn) + return wrapper_sync(wrapper_fn) + + if _render_fn is None: + return wrapper + + if _utils.is_async_callable(_render_fn): + return wrapper_async(_render_fn) + else: + _render_fn = cast(UserFuncSync[IT], _render_fn) + return wrapper_sync(_render_fn) + + return render_deco + + +# ====================================================================================== +# RenderText +# ====================================================================================== +@renderer_gen def text( - fn: Optional[RenderTextFunc | RenderTextFuncAsync] = None, -) -> RenderText | Callable[[RenderTextFunc | RenderTextFuncAsync], RenderText]: + meta: RenderFunctionMeta, + value: str, +) -> str: """ Reactively render text. @@ -166,18 +397,7 @@ def text( -------- ~shiny.ui.output_text """ - - def wrapper(fn: RenderTextFunc | RenderTextFuncAsync) -> RenderText: - if _utils.is_async_callable(fn): - return RenderTextAsync(fn) - else: - fn = typing.cast(RenderTextFunc, fn) - return RenderText(fn) - - if fn is None: - return wrapper - else: - return wrapper(fn) + return str(value) # ====================================================================================== @@ -187,138 +407,14 @@ def wrapper(fn: RenderTextFunc | RenderTextFuncAsync) -> RenderText: # Union[matplotlib.figure.Figure, PIL.Image.Image] # However, if we did that, we'd have to import those modules at load time, which adds # a nontrivial amount of overhead. So for now, we're just using `object`. -RenderPlotFunc = Callable[[], object] -RenderPlotFuncAsync = Callable[[], Awaitable[object]] - - -class RenderPlot(RenderFunction[object, "ImgData | None"]): - _ppi: float = 96 - _is_userfn_async = False - - def __init__( - self, fn: RenderPlotFunc, *, alt: Optional[str] = None, **kwargs: object - ) -> None: - super().__init__(fn) - self._alt: Optional[str] = alt - self._kwargs = kwargs - # The Render*Async subclass will pass in an async function, but it tells the - # static type checker that it's synchronous. wrap_async() is smart -- if is - # passed an async function, it will not change it. - self._fn: RenderPlotFuncAsync = _utils.wrap_async(fn) - - def __call__(self) -> ImgData | None: - return _utils.run_coro_sync(self._run()) - - async def _run(self) -> ImgData | None: - inputs = self._session.root_scope().input - - # Reactively read some information about the plot. - pixelratio: float = typing.cast( - float, inputs[ResolvedId(".clientdata_pixelratio")]() - ) - width: float = typing.cast( - float, inputs[ResolvedId(f".clientdata_output_{self._name}_width")]() - ) - height: float = typing.cast( - float, inputs[ResolvedId(f".clientdata_output_{self._name}_height")]() - ) - - x = await self._fn() - - # Note that x might be None; it could be a matplotlib.pyplot - - # Try each type of renderer in turn. The reason we do it this way is to avoid - # importing modules that aren't already loaded. That could slow things down, or - # worse, cause an error if the module isn't installed. - # - # Each try_render function should indicate whether it was able to make sense of - # the x value (or, in the case of matplotlib, possibly it decided to use the - # global pyplot figure) by returning a tuple that starts with True. The second - # tuple element may be None in this case, which means the try_render function - # explicitly wants the plot to be blanked. - # - # If a try_render function returns a tuple that starts with False, then the next - # try_render function should be tried. If none succeed, an error is raised. - ok: bool - result: ImgData | None - - if "plotnine" in sys.modules: - ok, result = try_render_plotnine( - x, width, height, pixelratio, self._ppi, **self._kwargs - ) - if ok: - return result - - if "matplotlib" in sys.modules: - ok, result = try_render_matplotlib( - x, - width, - height, - pixelratio=pixelratio, - ppi=self._ppi, - allow_global=not self._is_userfn_async, - alt=self._alt, - **self._kwargs, - ) - if ok: - return result - - if "PIL" in sys.modules: - ok, result = try_render_pil( - x, width, height, pixelratio, self._ppi, **self._kwargs - ) - if ok: - return result - - # This check must happen last because matplotlib might be able to plot even if - # x is None - if x is None: - return None - - raise Exception( - f"@render.plot doesn't know to render objects of type '{str(type(x))}'. " - + "Consider either requesting support for this type of plot object, and/or " - + " explictly saving the object to a (png) file and using @render.image." - ) - - -class RenderPlotAsync(RenderPlot, RenderFunctionAsync[object, "ImgData | None"]): - _is_userfn_async = True - - def __init__( - self, fn: RenderPlotFuncAsync, alt: Optional[str] = None, **kwargs: Any - ) -> None: - if not _utils.is_async_callable(fn): - raise TypeError(self.__class__.__name__ + " requires an async function") - super().__init__(typing.cast(RenderPlotFunc, fn), alt=alt, **kwargs) - - async def __call__( # pyright: ignore[reportIncompatibleMethodOverride] - self, - ) -> ImgData | None: - return await self._run() - - -@overload -def plot(fn: RenderPlotFunc | RenderPlotFuncAsync) -> RenderPlot: - ... - - -@overload -def plot( - *, - alt: Optional[str] = None, - **kwargs: Any, -) -> Callable[[RenderPlotFunc | RenderPlotFuncAsync], RenderPlot]: - ... - - -# TODO: Use more specific types for render.plot +@renderer_gen def plot( - fn: Optional[RenderPlotFunc | RenderPlotFuncAsync] = None, + meta: RenderFunctionMeta, + x: ImgData, *, alt: Optional[str] = None, - **kwargs: Any, -) -> RenderPlot | Callable[[RenderPlotFunc | RenderPlotFuncAsync], RenderPlot]: + **kwargs: object, +) -> ImgData | None: """ Reactively render a plot object as an HTML image. @@ -362,93 +458,104 @@ def plot( ~shiny.ui.output_plot ~shiny.render.image """ + is_userfn_async = isinstance(meta, RenderFunctionAsync) + ppi: float = 96 + + # TODO-barret; Q: These variable calls are **after** `self._fn()`. Is this ok? + inputs = meta._session.root_scope().input + + # Reactively read some information about the plot. + pixelratio: float = typing.cast( + float, inputs[ResolvedId(".clientdata_pixelratio")]() + ) + width: float = typing.cast( + float, inputs[ResolvedId(f".clientdata_output_{meta._name}_width")]() + ) + height: float = typing.cast( + float, inputs[ResolvedId(f".clientdata_output_{meta._name}_height")]() + ) + + # !! Normal position for `x = await self._fn()` + + # Note that x might be None; it could be a matplotlib.pyplot + + # Try each type of renderer in turn. The reason we do it this way is to avoid + # importing modules that aren't already loaded. That could slow things down, or + # worse, cause an error if the module isn't installed. + # + # Each try_render function should indicate whether it was able to make sense of + # the x value (or, in the case of matplotlib, possibly it decided to use the + # global pyplot figure) by returning a tuple that starts with True. The second + # tuple element may be None in this case, which means the try_render function + # explicitly wants the plot to be blanked. + # + # If a try_render function returns a tuple that starts with False, then the next + # try_render function should be tried. If none succeed, an error is raised. + ok: bool + result: ImgData | None + + if "plotnine" in sys.modules: + ok, result = try_render_plotnine( + x, + width, + height, + pixelratio, + ppi, + alt, + **kwargs, + ) + if ok: + return result + + if "matplotlib" in sys.modules: + ok, result = try_render_matplotlib( + x, + width, + height, + pixelratio=pixelratio, + ppi=ppi, + allow_global=not is_userfn_async, + alt=alt, + **kwargs, + ) + if ok: + return result + + if "PIL" in sys.modules: + ok, result = try_render_pil( + x, + width, + height, + pixelratio, + ppi, + alt, + **kwargs, + ) + if ok: + return result - def wrapper(fn: RenderPlotFunc | RenderPlotFuncAsync) -> RenderPlot: - if _utils.is_async_callable(fn): - return RenderPlotAsync(fn, alt=alt, **kwargs) - else: - return RenderPlot(fn, alt=alt, **kwargs) + # This check must happen last because + # matplotlib might be able to plot even if x is `None` + if x is None: # type: ignore ; TODO-barret remove this check once `value` can be `None` + return None - if fn is None: - return wrapper - else: - return wrapper(fn) + 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 # ====================================================================================== -RenderImageFunc = Callable[[], "ImgData | None"] -RenderImageFuncAsync = Callable[[], Awaitable["ImgData | None"]] - - -class RenderImage(RenderFunction["ImgData | None", "ImgData | None"]): - def __init__( - self, - fn: RenderImageFunc, - *, - delete_file: bool = False, - ) -> None: - super().__init__(fn) - self._delete_file: bool = delete_file - # The Render*Async subclass will pass in an async function, but it tells the - # static type checker that it's synchronous. wrap_async() is smart -- if is - # passed an async function, it will not change it. - self._fn: RenderImageFuncAsync = _utils.wrap_async(fn) - - def __call__(self) -> ImgData | None: - return _utils.run_coro_sync(self._run()) - - async def _run(self) -> ImgData | None: - res: ImgData | None = await self._fn() - if res is None: - return None - - src: str = res.get("src") - try: - with open(src, "rb") as f: - data = base64.b64encode(f.read()) - data_str = data.decode("utf-8") - content_type = _utils.guess_mime_type(src) - res["src"] = f"data:{content_type};base64,{data_str}" - return res - finally: - if self._delete_file: - os.remove(src) - - -class RenderImageAsync( - RenderImage, RenderFunctionAsync["ImgData | None", "ImgData | None"] -): - def __init__(self, fn: RenderImageFuncAsync, delete_file: bool = False) -> None: - if not _utils.is_async_callable(fn): - raise TypeError(self.__class__.__name__ + " requires an async function") - super().__init__(typing.cast(RenderImageFunc, fn), delete_file=delete_file) - - async def __call__( # pyright: ignore[reportIncompatibleMethodOverride] - self, - ) -> ImgData | None: - return await self._run() - - -@overload -def image(fn: RenderImageFunc | RenderImageFuncAsync) -> RenderImage: - ... - - -@overload +@renderer_gen def image( + meta: RenderFunctionMeta, + res: ImgData | None, *, delete_file: bool = False, -) -> Callable[[RenderImageFunc | RenderImageFuncAsync], RenderImage]: - ... - - -def image( - fn: Optional[RenderImageFunc | RenderImageFuncAsync] = None, - *, - delete_file: bool = False, -) -> RenderImage | Callable[[RenderImageFunc | RenderImageFuncAsync], RenderImage]: +) -> ImgData | None: """ Reactively render a image file as an HTML image. @@ -475,18 +582,20 @@ def image( ~shiny.types.ImgData ~shiny.render.plot """ - - def wrapper(fn: RenderImageFunc | RenderImageFuncAsync) -> RenderImage: - if _utils.is_async_callable(fn): - return RenderImageAsync(fn, delete_file=delete_file) - else: - fn = typing.cast(RenderImageFunc, fn) - return RenderImage(fn, delete_file=delete_file) - - if fn is None: - return wrapper - else: - return wrapper(fn) + 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) # ====================================================================================== @@ -501,116 +610,19 @@ def to_pandas(self) -> "pd.DataFrame": ... -TableResult = Union[None, "pd.DataFrame", PandasCompatible] -RenderTableFunc = Callable[[], TableResult] -RenderTableFuncAsync = Callable[[], Awaitable[TableResult]] - - -class RenderTable(RenderFunction[object, "RenderedDeps | None"]): - def __init__( - self, - fn: RenderTableFunc, - *, - index: bool = False, - classes: str = "table shiny-table w-auto", - border: int = 0, - **kwargs: object, - ) -> None: - super().__init__(fn) - self._index = index - self._classes = classes - self._border = border - self._kwargs = kwargs - # The Render*Async subclass will pass in an async function, but it tells the - # static type checker that it's synchronous. wrap_async() is smart -- if is - # passed an async function, it will not change it. - self._fn: RenderTableFuncAsync = _utils.wrap_async(fn) - - def __call__(self) -> RenderedDeps | None: - return _utils.run_coro_sync(self._run()) - - async def _run(self) -> RenderedDeps | None: - x = await self._fn() - - if x is None: - return None - - import pandas - import pandas.io.formats.style - - html: str - if isinstance(x, pandas.io.formats.style.Styler): - html = x.to_html(**self._kwargs) # pyright: ignore[reportUnknownMemberType] - else: - if not isinstance(x, pandas.DataFrame): - if not isinstance(x, PandasCompatible): - raise TypeError( - "@render.table doesn't know how to render objects of type " - f"'{str(type(x))}'. Return either a pandas.DataFrame, or an object " - "that has a .to_pandas() method." - ) - x = x.to_pandas() - - html = x.to_html( # pyright: ignore[reportUnknownMemberType] - index=self._index, - classes=self._classes, - border=self._border, - **self._kwargs, - ) - return {"deps": [], "html": html} - - -class RenderTableAsync(RenderTable, RenderFunctionAsync[object, "ImgData | None"]): - def __init__( - self, - fn: RenderTableFuncAsync, - *, - index: bool = False, - classes: str = "table shiny-table w-auto", - border: int = 0, - **kwargs: Any, - ) -> None: - if not _utils.is_async_callable(fn): - raise TypeError(self.__class__.__name__ + " requires an async function") - super().__init__( - typing.cast(RenderTableFunc, fn), - index=index, - classes=classes, - border=border, - **kwargs, - ) +TableResult = Union["pd.DataFrame", PandasCompatible, None] - async def __call__( # pyright: ignore[reportIncompatibleMethodOverride] - self, - ) -> RenderedDeps | None: - return await self._run() - -@overload -def table(fn: RenderTableFunc | RenderTableFuncAsync) -> RenderTable: - ... - - -@overload +@renderer_gen def table( + meta: RenderFunctionMeta, + x: TableResult, *, index: bool = False, classes: str = "table shiny-table w-auto", border: int = 0, - **kwargs: Any, -) -> Callable[[RenderTableFunc | RenderTableFuncAsync], RenderTable]: - ... - - -# TODO: Use more specific types for render.table -def table( - fn: Optional[RenderTableFunc | RenderTableFuncAsync] = None, - *, - index: bool = False, - classes: str = "table shiny-table w-auto", - border: int = 0, - **kwargs: Any, -) -> RenderTable | Callable[[RenderTableFunc | RenderTableFuncAsync], RenderTable]: + **kwargs: object, +) -> RenderedDeps | None: """ Reactively render a Pandas data frame object (or similar) as a basic HTML table. @@ -650,78 +662,47 @@ def table( -------- ~shiny.ui.output_table """ - - def wrapper(fn: RenderTableFunc | RenderTableFuncAsync) -> RenderTable: - if _utils.is_async_callable(fn): - return RenderTableAsync( - fn, index=index, classes=classes, border=border, **kwargs - ) - else: - return RenderTable( - cast(RenderTableFunc, fn), + import pandas + import pandas.io.formats.style + + html: str + if isinstance(x, pandas.io.formats.style.Styler): + html = cast( # pyright: ignore[reportUnnecessaryCast] + str, + x.to_html( # pyright: ignore[reportUnknownMemberType] + **kwargs # pyright: ignore[reportGeneralTypeIssues] + ), + ) + else: + if not isinstance(x, pandas.DataFrame): + if not isinstance(x, PandasCompatible): + raise TypeError( + "@render.table doesn't know how to render objects of type " + f"'{str(type(x))}'. Return either a pandas.DataFrame, or an object " + "that has a .to_pandas() method." + ) + x = x.to_pandas() + + html = cast( # pyright: ignore[reportUnnecessaryCast] + str, + x.to_html( # pyright: ignore[reportUnknownMemberType] index=index, classes=classes, border=border, - **kwargs, - ) - - if fn is None: - return wrapper - else: - return wrapper(fn) + **kwargs, # pyright: ignore[reportGeneralTypeIssues] + ), + ) + return {"deps": [], "html": html} # ====================================================================================== # RenderUI # ====================================================================================== -RenderUIFunc = Callable[[], TagChild] -RenderUIFuncAsync = Callable[[], Awaitable[TagChild]] - - -class RenderUI(RenderFunction[TagChild, "RenderedDeps | None"]): - def __init__(self, fn: RenderUIFunc) -> None: - super().__init__(fn) - # The Render*Async subclass will pass in an async function, but it tells the - # static type checker that it's synchronous. wrap_async() is smart -- if is - # passed an async function, it will not change it. - self._fn: RenderUIFuncAsync = _utils.wrap_async(fn) - - def __call__(self) -> RenderedDeps | None: - return _utils.run_coro_sync(self._run()) - - async def _run(self) -> RenderedDeps | None: - ui: TagChild = await self._fn() - if ui is None: - return None - - return self._session._process_ui(ui) - - -class RenderUIAsync(RenderUI, RenderFunctionAsync[TagChild, "RenderedDeps| None"]): - def __init__(self, fn: RenderUIFuncAsync) -> None: - if not _utils.is_async_callable(fn): - raise TypeError(self.__class__.__name__ + " requires an async function") - super().__init__(typing.cast(RenderUIFunc, fn)) - - async def __call__( # pyright: ignore[reportIncompatibleMethodOverride] - self, - ) -> RenderedDeps | None: - return await self._run() - - -@overload -def ui(fn: RenderUIFunc | RenderUIFuncAsync) -> RenderUI: - ... - - -@overload -def ui() -> Callable[[RenderUIFunc | RenderUIFuncAsync], RenderUI]: - ... - - +@renderer_gen def ui( - fn: Optional[RenderUIFunc | RenderUIFuncAsync] = None, -) -> RenderUI | Callable[[RenderUIFunc | RenderUIFuncAsync], RenderUI]: + meta: RenderFunctionMeta, + ui: TagChild, +) -> RenderedDeps | None: """ Reactively render HTML content. @@ -741,17 +722,7 @@ def ui( -------- ~shiny.ui.output_ui """ + if ui is None: + return None - def wrapper( - fn: Callable[[], TagChild] | Callable[[], Awaitable[TagChild]] - ) -> RenderUI: - if _utils.is_async_callable(fn): - return RenderUIAsync(fn) - else: - fn = typing.cast(RenderUIFunc, fn) - return RenderUI(fn) - - if fn is None: - return wrapper - else: - return wrapper(fn) + return meta._session._process_ui(ui) From 7ee4db0f5dbb1a4dbc7c75f0e46c2f5c6c281dba Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Thu, 13 Jul 2023 15:43:39 -0400 Subject: [PATCH 02/64] Create test_renderer_gen.py --- tests/test_renderer_gen.py | 66 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 tests/test_renderer_gen.py diff --git a/tests/test_renderer_gen.py b/tests/test_renderer_gen.py new file mode 100644 index 000000000..98c9cff1f --- /dev/null +++ b/tests/test_renderer_gen.py @@ -0,0 +1,66 @@ +from shiny.render._render import RenderFunctionMeta, renderer_gen + + +def test_renderer_gen_assertions(): + @renderer_gen + def test_fn1( + meta: RenderFunctionMeta, + x: str, + ): + ... + + try: + + @renderer_gen + def test_fn2( + meta: RenderFunctionMeta, + x: str, + y: str, + ): + ... + + raise RuntimeError() + except TypeError as e: + assert "more than 2 positional" in str(e) + + try: + + @renderer_gen + def test_fn3( + meta: RenderFunctionMeta, + x: str, + *args: str, + ): + ... + + raise RuntimeError() + + except TypeError as e: + assert "No variadic parameters" in str(e) + + try: + + @renderer_gen + def test_fn4( + meta: RenderFunctionMeta, + x: str, + *, + y: str, + ): + ... + + raise RuntimeError() + + except TypeError as e: + assert "did not have a default value" in str(e) + + # Test that kwargs can be allowed + @renderer_gen + def test_fn5( + meta: RenderFunctionMeta, + x: str, + *, + y: str = "42", + **kwargs: object, + ): + ... From 52b77bdc8c3fe9bae190e3bc06b3c1be81167718 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Thu, 13 Jul 2023 15:51:44 -0400 Subject: [PATCH 03/64] Create e2e test app that tests renderer_gen in sync/async and with/without args --- e2e/server/renderer_gen/app.py | 71 ++++++++++++++++++++ e2e/server/renderer_gen/test_renderer_gen.py | 14 ++++ 2 files changed, 85 insertions(+) create mode 100644 e2e/server/renderer_gen/app.py create mode 100644 e2e/server/renderer_gen/test_renderer_gen.py diff --git a/e2e/server/renderer_gen/app.py b/e2e/server/renderer_gen/app.py new file mode 100644 index 000000000..0db38d416 --- /dev/null +++ b/e2e/server/renderer_gen/app.py @@ -0,0 +1,71 @@ +from typing import Optional + +from shiny import App, Inputs, Outputs, Session, ui +from shiny.render._render import RenderFunctionMeta, renderer_gen + + +@renderer_gen +def render_test_text( + meta: RenderFunctionMeta, + value: str, + *, + extra_txt: Optional[str] = None, +) -> str: + value = str(value) + value += "; " + value += "async" if meta.is_async else "sync" + if extra_txt: + value = value + "; " + str(extra_txt) + return value + + +app_ui = ui.page_fluid( + ui.code("t1:"), + ui.output_text_verbatim("t1"), + ui.code("t2:"), + ui.output_text_verbatim("t2"), + ui.code("t3:"), + ui.output_text_verbatim("t3"), + ui.code("t4:"), + ui.output_text_verbatim("t4"), + ui.code("t5:"), + ui.output_text_verbatim("t5"), + ui.code("t6:"), + ui.output_text_verbatim("t6"), +) + + +def server(input: Inputs, output: Outputs, session: Session): + @output + @render_test_text + def t1(): + return "t1; no call" + # return "hello" + + @output + @render_test_text + async def t2(): + return "t2; no call" + + @output + @render_test_text() + def t3(): + return "t3; call" + + @output + @render_test_text() + async def t4(): + return "t4; call" + + @output + @render_test_text(extra_txt="w/ extra_txt") + def t5(): + return "t5; call" + + @output + @render_test_text(extra_txt="w/ extra_txt") + async def t6(): + return "t6; call" + + +app = App(app_ui, server) diff --git a/e2e/server/renderer_gen/test_renderer_gen.py b/e2e/server/renderer_gen/test_renderer_gen.py new file mode 100644 index 000000000..e2c1fc5f7 --- /dev/null +++ b/e2e/server/renderer_gen/test_renderer_gen.py @@ -0,0 +1,14 @@ +from conftest import ShinyAppProc +from controls import OutputTextVerbatim +from playwright.sync_api import Page + + +def test_output_image_kitchen(page: Page, local_app: ShinyAppProc) -> None: + page.goto(local_app.url) + + OutputTextVerbatim(page, "t1").expect_value("t1; no call; sync") + OutputTextVerbatim(page, "t2").expect_value("t2; no call; async") + OutputTextVerbatim(page, "t3").expect_value("t3; call; sync") + OutputTextVerbatim(page, "t4").expect_value("t4; call; async") + OutputTextVerbatim(page, "t5").expect_value("t5; call; sync; w/ extra_txt") + OutputTextVerbatim(page, "t6").expect_value("t6; call; async; w/ extra_txt") From c0264ca8b4b1c634292efe244e134847bbc27b0a Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Thu, 13 Jul 2023 16:23:45 -0400 Subject: [PATCH 04/64] Add missing return statement --- shiny/render/_dataframe.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/shiny/render/_dataframe.py b/shiny/render/_dataframe.py index df0e225c0..822319114 100644 --- a/shiny/render/_dataframe.py +++ b/shiny/render/_dataframe.py @@ -194,7 +194,7 @@ def to_payload(self) -> object: DataFrameResult = Union[None, "pd.DataFrame", DataGrid, DataTable] -# TODO-barret; Double check this is working. May need to port `__name__` and `__docs__` of `value_fn` +# TODO-barret; Double check this is working. Will need to port `__name__` and `__docs__` of `value_fn` @add_example() @renderer_gen def data_frame( @@ -233,6 +233,7 @@ def data_frame( x, "@render.data_frame doesn't know how to render objects of type" ) ) + return x.to_payload() @runtime_checkable From 40a18f164cb55851489575edc000ed5922ebdf77 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Thu, 13 Jul 2023 16:24:09 -0400 Subject: [PATCH 05/64] Ignore duckdb data files --- examples/duckdb/.gitignore | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 examples/duckdb/.gitignore diff --git a/examples/duckdb/.gitignore b/examples/duckdb/.gitignore new file mode 100644 index 000000000..fda687dc3 --- /dev/null +++ b/examples/duckdb/.gitignore @@ -0,0 +1,3 @@ +cities.csv +weather_forecasts.csv +weather.db From db573523ba061e966ce67379f545923f72a7ef8a Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Thu, 13 Jul 2023 16:46:04 -0400 Subject: [PATCH 06/64] Update TODOs --- e2e/cpuinfo/test_app.py | 2 +- e2e/inputs/test_input_checkbox.py | 4 ++-- e2e/inputs/test_input_file.py | 2 +- shiny/experimental/ui/_fill.py | 2 +- shiny/render/__init__.py | 7 ------- shiny/render/_dataframe.py | 2 +- 6 files changed, 6 insertions(+), 13 deletions(-) diff --git a/e2e/cpuinfo/test_app.py b/e2e/cpuinfo/test_app.py index 141a1add6..8b02adae6 100644 --- a/e2e/cpuinfo/test_app.py +++ b/e2e/cpuinfo/test_app.py @@ -1,6 +1,6 @@ # pyright: reportUnknownMemberType=false -# TODO-barret; Convert test into loop that tests all examples to make sure they load +# TODO-future; Convert test into loop that tests all examples to make sure they load import re diff --git a/e2e/inputs/test_input_checkbox.py b/e2e/inputs/test_input_checkbox.py index 9561136a0..272036851 100644 --- a/e2e/inputs/test_input_checkbox.py +++ b/e2e/inputs/test_input_checkbox.py @@ -16,7 +16,7 @@ def test_input_checkbox_kitchen(page: Page, app: ShinyAppProc) -> None: somevalue.expect_checked(False) somevalue.expect_width(None) - # TODO-barret test output value + # TODO-karan: test output value somevalue.set(True) @@ -28,4 +28,4 @@ def test_input_checkbox_kitchen(page: Page, app: ShinyAppProc) -> None: somevalue.toggle() somevalue.expect_checked(True) - # TODO-barret test output value + # TODO-karan: test output value diff --git a/e2e/inputs/test_input_file.py b/e2e/inputs/test_input_file.py index 7e100dfbb..85b993848 100644 --- a/e2e/inputs/test_input_file.py +++ b/e2e/inputs/test_input_file.py @@ -40,4 +40,4 @@ def test_input_file_kitchen(page: Page, app: ShinyAppProc) -> None: file1.expect_complete() - # TODO-barret; Test UI output to not be empty + # TODO-karan; Test UI output to not be empty diff --git a/shiny/experimental/ui/_fill.py b/shiny/experimental/ui/_fill.py index 8792c6e02..6f13b92b0 100644 --- a/shiny/experimental/ui/_fill.py +++ b/shiny/experimental/ui/_fill.py @@ -476,7 +476,7 @@ def _is_fill_layout( # tag: Tagifiable and not (Tag or FillingLayout) raise TypeError( - f"`_is_fill_layout(tag=)` must be a `Tag` or implement the `FillingLayout` protocol methods TODO-barret expand on method names. Received object of type: `{type(tag).__name__}`" + f"`_is_fill_layout(tag=)` must be a `Tag` or implement the `FillingLayout` protocol methods. Received object of type: `{type(tag).__name__}`" ) diff --git a/shiny/render/__init__.py b/shiny/render/__init__.py index e19f0a457..4ede341c7 100644 --- a/shiny/render/__init__.py +++ b/shiny/render/__init__.py @@ -22,13 +22,6 @@ __all__ = ( - # TODO-barret; Q: Remove `DataGrid` and `DataTable` methods from `__all__` - # # Is `DataGrid` and `DataTable` necessary? I don't believe they are _render_ methods. - # # They would be available via `from render import DataGrid`, - # # just wouldn't be available as `render.DataGrid` - # "DataGrid", - # "DataTable", - # # "data_frame", "text", "plot", diff --git a/shiny/render/_dataframe.py b/shiny/render/_dataframe.py index 822319114..a3888090c 100644 --- a/shiny/render/_dataframe.py +++ b/shiny/render/_dataframe.py @@ -194,7 +194,7 @@ def to_payload(self) -> object: DataFrameResult = Union[None, "pd.DataFrame", DataGrid, DataTable] -# TODO-barret; Double check this is working. Will need to port `__name__` and `__docs__` of `value_fn` +# TODO-barret; Port `__name__` and `__docs__` of `value_fn` @add_example() @renderer_gen def data_frame( From 40b64217c41cf6cc04bc04f8f5afe2b8a9d05ee9 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Thu, 13 Jul 2023 16:46:31 -0400 Subject: [PATCH 07/64] Naturally export RenderFunctionMeta --- shiny/render/_render.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/shiny/render/_render.py b/shiny/render/_render.py index 0e02abf67..920e5645a 100644 --- a/shiny/render/_render.py +++ b/shiny/render/_render.py @@ -2,6 +2,18 @@ # See https://www.python.org/dev/peps/pep-0655/#usage-in-python-3-11 from __future__ import annotations +__all__ = ( + "renderer_gen", + "RenderFunctionMeta", + "RenderFunction", + "RenderFunctionAsync", + "text", + "plot", + "image", + "table", + "ui", +) + import base64 import inspect import os @@ -43,18 +55,6 @@ from ..types import ImgData from ._try_render_plot import try_render_matplotlib, try_render_pil, try_render_plotnine -__all__ = ( - "renderer_gen", - "RenderFunction", - "RenderFunctionAsync", - "text", - "plot", - "image", - "table", - "ui", -) - - # Input type for the user-spplied function that is passed to a render.xx IT = TypeVar("IT") # Output type after the RenderFunction.__call__ method is called on the IT object. From 4a82f0fcd28200444ff3a0feac4065738b2b16a2 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Fri, 14 Jul 2023 10:25:13 -0400 Subject: [PATCH 08/64] Reduce number of Render classes. Have `RendererMeta` class be supplied to `value_fn(meta=)` --- e2e/server/renderer_gen/app.py | 6 +- shiny/render/__init__.py | 6 +- shiny/render/_dataframe.py | 4 +- shiny/render/_render.py | 248 +++++++++++++++++---------------- shiny/session/_session.py | 2 +- tests/test_renderer_gen.py | 12 +- 6 files changed, 144 insertions(+), 134 deletions(-) diff --git a/e2e/server/renderer_gen/app.py b/e2e/server/renderer_gen/app.py index 0db38d416..8375a9b43 100644 --- a/e2e/server/renderer_gen/app.py +++ b/e2e/server/renderer_gen/app.py @@ -1,19 +1,19 @@ from typing import Optional from shiny import App, Inputs, Outputs, Session, ui -from shiny.render._render import RenderFunctionMeta, renderer_gen +from shiny.render._render import RendererMeta, renderer_gen @renderer_gen def render_test_text( - meta: RenderFunctionMeta, + meta: RendererMeta, value: str, *, extra_txt: Optional[str] = None, ) -> str: value = str(value) value += "; " - value += "async" if meta.is_async else "sync" + value += "async" if meta["is_async"] else "sync" if extra_txt: value = value + "; " + str(extra_txt) return value diff --git a/shiny/render/__init__.py b/shiny/render/__init__.py index 4ede341c7..2c98029af 100644 --- a/shiny/render/__init__.py +++ b/shiny/render/__init__.py @@ -3,10 +3,12 @@ """ from ._render import ( # noqa: F401 - RenderFunctionMeta as RenderFunctionMeta, + # Import these values, but do not give autocomplete hints for `shiny.render.FOO` + RendererMeta as RendererMeta, RenderFunction as RenderFunction, + RenderFunctionSync as RenderFunctionSync, RenderFunctionAsync as RenderFunctionAsync, - renderer_gen, + renderer_gen as renderer_gen, text, plot, image, diff --git a/shiny/render/_dataframe.py b/shiny/render/_dataframe.py index a3888090c..2de0ba8aa 100644 --- a/shiny/render/_dataframe.py +++ b/shiny/render/_dataframe.py @@ -5,7 +5,7 @@ from typing import TYPE_CHECKING, Any, Literal, Protocol, Union, cast, runtime_checkable from .._docstring import add_example -from . import RenderFunctionMeta, renderer_gen +from . import RendererMeta, renderer_gen if TYPE_CHECKING: import pandas as pd @@ -198,7 +198,7 @@ def to_payload(self) -> object: @add_example() @renderer_gen def data_frame( - meta: RenderFunctionMeta, + meta: RendererMeta, x: DataFrameResult, ) -> object | None: """ diff --git a/shiny/render/_render.py b/shiny/render/_render.py index 920e5645a..0d599ec03 100644 --- a/shiny/render/_render.py +++ b/shiny/render/_render.py @@ -3,10 +3,11 @@ from __future__ import annotations __all__ = ( - "renderer_gen", - "RenderFunctionMeta", - "RenderFunction", - "RenderFunctionAsync", + # "renderer_gen", + # "RendererMeta", + # "RenderFunction", + # "RenderFunctionSync", + # "RenderFunctionAsync", "text", "plot", "image", @@ -29,11 +30,13 @@ Generic, Optional, ParamSpec, + Protocol, Tuple, TypeVar, Union, cast, overload, + runtime_checkable, ) # # These aren't used directly in this file, but they seem necessary for Sphinx to work @@ -48,10 +51,9 @@ from ..session._utils import RenderedDeps import pandas as pd -from typing import Protocol, runtime_checkable - from .. import _utils from .._namespaces import ResolvedId +from .._typing_extensions import TypedDict from ..types import ImgData from ._try_render_plot import try_render_matplotlib, try_render_pil, try_render_plotnine @@ -65,58 +67,59 @@ T = TypeVar("T") -# ====================================================================================== -# RenderFunctionMeta/RenderFunction/RenderFunctionAsync base class -# ====================================================================================== - - -# TODO-barret; Remove `RenderFunctionMeta`. Just use `RenderFunction` -# TODO-barret; Where `RenderFunctionMeta` is supplied to `meta`, instead, use a -# TypedDict of information so users don't leverage `_` values -class RenderFunctionMeta: +class RendererMeta(TypedDict): is_async: bool + session: Session + name: str - def set_metadata(self, session: Session, name: str) -> None: - """When RenderFunctions are assigned to Output object slots, this method - is used to pass along session and name information. - """ - self._session: Session = session - self._name: str = name + +# ====================================================================================== +# RenderFunction / RenderFunctionSync / RenderFunctionAsync base class +# ====================================================================================== # A RenderFunction object is given a user-provided function (`value_fn`) which returns # an `IT`. When the .__call___ method is invoked, it calls the user-provided function # (which returns an `IT`), then converts the `IT` to an `OT`. Note that in many cases # but not all, `IT` and `OT` will be the same. -class RenderFunction(Generic[IT, OT], RenderFunctionMeta): - def __init__(self, render_fn: UserFuncAsync[IT]) -> None: +class RenderFunction(Generic[IT, OT]): + @property + def is_async(self) -> bool: + raise NotImplementedError() + + def __init__(self, render_fn: UserFunc[IT]) -> None: self.__name__ = render_fn.__name__ # TODO-barret; Set name of async function self.__doc__ = render_fn.__doc__ - self._fn = render_fn - - def __call__(self) -> OT | None: - raise NotImplementedError + # Given we use `_utils.run_coro_sync(self._run())` to call our method, + # we can act as if `render_fn` is always async + self._fn = _utils.wrap_async(render_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(RenderFunction[IT, OT]): - async def __call__( # pyright: ignore[reportIncompatibleMethodOverride] - self, - ) -> OT | None: + def __call__(self) -> OT | None: raise NotImplementedError + def _set_metadata(self, session: Session, name: str) -> None: + """When RenderFunctions are assigned to Output object slots, this method + is used to pass along session and name information. + """ + self._session: Session = session + self._name: str = name -# ====================================================================================== -# RenderSync/RenderAsync base classes for Sync/async render functions -# ====================================================================================== + @property + def meta(self) -> RendererMeta: + return RendererMeta( + is_async=self.is_async, + session=self._session, + name=self._name, + ) -# TODO-barret; Q: Why are there separate classes between RenderSync and RenderFunction (and RenderAsync / RenderFunctionAsync) -# TODO-barret; Collapse RenderFunction / RenderSync & RenderFunctionAsync / RenderAsync? +# Using a second class to help clarify that it is of a particular type +class RenderFunctionSync(Generic[IT, OT, P], RenderFunction[IT, OT]): + @property + def is_async(self) -> bool: + return False -class RenderSync(Generic[IT, OT, P], RenderFunction[IT, OT]): def __init__( self, # Use single arg to minimize overlap with P.kwargs @@ -124,18 +127,13 @@ def __init__( *args: P.args, **kwargs: P.kwargs, ) -> None: + # `*args` must be in the `__init__` signature + # Make sure there no `args`! + assert len(args) == 0 + # Unpack args _fn, _value_fn = _render_args - - # super == RenderFunction - _awaitable_fn = _utils.wrap_async(_fn) - super().__init__(_awaitable_fn) - self.is_async = False - - # The Render*Async subclass will pass in an async function, but it tells the - # static type checker that it's synchronous. wrap_async() is smart -- if is - # passed an async function, it will not change it. - # self._fn: UserFuncSync[IT] = _utils.wrap_async(_fn) + super().__init__(_fn) self._value_fn = _utils.wrap_async(_value_fn) self._args = args @@ -152,8 +150,8 @@ async def _run(self) -> OT | None: if fn_val is None: return fn_val ret = await self._value_fn( - # RenderFunctionMeta - self, + # RendererMeta + self.meta, # IT fn_val, # P @@ -163,7 +161,14 @@ async def _run(self) -> OT | None: return ret -class RenderAsync(Generic[IT, OT, P], RenderFunctionAsync[IT, OT]): +# 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, P], RenderFunction[IT, OT]): + @property + def is_async(self) -> bool: + return True + def __init__( self, # Use single arg to minimize overlap with P.kwargs @@ -171,6 +176,10 @@ def __init__( *args: P.args, **kwargs: P.kwargs, ) -> None: + # `*args` must be in the `__init__` signature + # Make sure there no `args`! + assert len(args) == 0 + # Unpack args _fn, _value_fn = _render_args @@ -178,13 +187,15 @@ def __init__( raise TypeError(self.__class__.__name__ + " requires an async function") # super == RenderFunctionAsync, RenderFunction super().__init__(_fn) - self.is_async = True + self._fn = _fn self._value_fn = _utils.wrap_async(_value_fn) self._args = args self._kwargs = kwargs - async def __call__(self) -> OT | None: + async def __call__( # pyright: ignore[reportIncompatibleMethodOverride] + self, + ) -> OT | None: return await self._run() async def _run(self) -> OT | None: @@ -193,8 +204,8 @@ async def _run(self) -> OT | None: if fn_val is None: return fn_val ret = await self._value_fn( - # RenderFunctionMeta - self, + # RendererMeta + self.meta, # IT fn_val, # P @@ -212,14 +223,14 @@ async def _run(self) -> OT | None: UserFuncAsync = Callable[[], Awaitable[IT | None]] UserFunc = UserFuncSync[IT] | UserFuncAsync[IT] ValueFunc = ( - Callable[Concatenate[RenderFunctionMeta, IT, P], OT | None] - | Callable[Concatenate[RenderFunctionMeta, IT, P], Awaitable[OT | None]] + Callable[Concatenate[RendererMeta, IT, P], OT | None] + | Callable[Concatenate[RendererMeta, IT, P], Awaitable[OT | None]] ) -# RenderDecoSync = Callable[[UserFuncSync[IT]], RenderSync[IT, OT, P]] -# RenderDecoAsync = Callable[[UserFuncAsync[IT]], RenderAsync[IT, OT, P]] +RenderDecoSync = Callable[[UserFuncSync[IT]], RenderFunctionSync[IT, OT, P]] +RenderDecoAsync = Callable[[UserFuncAsync[IT]], RenderFunctionAsync[IT, OT, P]] RenderDeco = Callable[ [UserFuncSync[IT] | UserFuncAsync[IT]], - RenderSync[IT, OT, P] | RenderAsync[IT, OT, P], + RenderFunctionSync[IT, OT, P] | RenderFunctionAsync[IT, OT, P], ] @@ -236,14 +247,14 @@ async def _run(self) -> OT | None: # * We need a way to distinguish between a plain function and args supplied to the next function. This is done by not allowing `*args`. # assert: All kwargs of value_fn should have a default value # * This makes calling the method with both `()` and without `()` possible / consistent. -def assert_value_fn(value_fn: ValueFunc[IT, P, OT]): +def _assert_value_fn(value_fn: ValueFunc[IT, P, OT]): params = inspect.Signature.from_callable(value_fn).parameters for i, param in zip(range(len(params)), params.values()): # # Not a good test as `param.annotation` has type `str`: # if i == 0: # print(type(param.annotation)) - # assert isinstance(param.annotation, RenderFunctionMeta) + # assert isinstance(param.annotation, RendererMeta) # Make sure there are no more than 2 positional args if i >= 2 and param.kind == inspect.Parameter.POSITIONAL_OR_KEYWORD: @@ -272,25 +283,27 @@ def renderer_gen( TODO-barret; Docs go here! """ - assert_value_fn(value_fn) + _assert_value_fn(value_fn) @overload - def render_deco(render_fn: UserFuncSync[IT]) -> RenderSync[IT, OT, P]: + # RenderDecoSync[IT, OT, P] + def renderer_deco(_render_fn: UserFuncSync[IT]) -> RenderFunctionSync[IT, OT, P]: ... @overload - def render_deco(render_fn: UserFuncAsync[IT]) -> RenderAsync[IT, OT, P]: + # RenderDecoAsync[IT, OT, P] + def renderer_deco(_render_fn: UserFuncAsync[IT]) -> RenderFunctionAsync[IT, OT, P]: ... @overload - def render_deco(*args: P.args, **kwargs: P.kwargs) -> RenderDeco[IT, OT, P]: + def renderer_deco(*args: P.args, **kwargs: P.kwargs) -> RenderDeco[IT, OT, P]: ... # # If we use `wraps()`, the overloads are lost. # @functools.wraps(value_fn) # Ignoring the type issue on the next line of code as the overloads for - # `render_deco` are not consistent with the function definition. + # `renderer_deco` are not consistent with the function definition. # Motivation: # * https://peps.python.org/pep-0612/ does allow for prepending an arg # (`_render_fn`). @@ -301,73 +314,65 @@ def render_deco(*args: P.args, **kwargs: P.kwargs) -> RenderDeco[IT, OT, P]: # * By making assertions on `P.args` to only allow for `*`, we _can_ make overloads # that use either the single positional arg (`_render_fn`) or # the `P.kwargs` (as `P.args` == `*`) - def render_deco( # type: ignore[reportGeneralTypeIssues] + def renderer_deco( # type: ignore[reportGeneralTypeIssues] _render_fn: Optional[UserFuncSync[IT] | UserFuncAsync[IT]] = None, - *args: P.args, # Equivalent to `*` after assertions in `assert_value_fn()` + *args: P.args, # Equivalent to `*` after assertions in `_assert_value_fn()` **kwargs: P.kwargs, ) -> ( - Callable[[UserFuncSync[IT]], RenderSync[IT, OT, P]] - | Callable[[UserFuncAsync[IT]], RenderAsync[IT, OT, P]] - | RenderSync[IT, OT, P] - | RenderAsync[IT, OT, P] + RenderDecoSync[IT, OT, P] + | RenderDecoAsync[IT, OT, P] + | RenderFunctionSync[IT, OT, P] + | RenderFunctionAsync[IT, OT, P] ): - # `args` **must** be in `render_deco` definition. + # `args` **must** be in `renderer_deco` definition. # Make sure there no `args`! assert len(args) == 0 - def wrapper_sync( - wrapper_sync_fn: UserFuncSync[IT], - ) -> RenderSync[IT, OT, P]: - return RenderSync( - (wrapper_sync_fn, value_fn), + def render_fn_sync( + fn_sync: UserFuncSync[IT], + ) -> RenderFunctionSync[IT, OT, P]: + return RenderFunctionSync( + (fn_sync, value_fn), *args, **kwargs, ) - def wrapper_async( - wrapper_async_fn: UserFuncAsync[IT], - ) -> RenderAsync[IT, OT, P]: - # Make sure there no `args`! - assert len(args) == 0 - - return RenderAsync( - (wrapper_async_fn, value_fn), + def render_fn_async( + fn_async: UserFuncAsync[IT], + ) -> RenderFunctionAsync[IT, OT, P]: + return RenderFunctionAsync( + (fn_async, value_fn), *args, **kwargs, ) @overload - def wrapper( - wrapper_fn: UserFuncSync[IT], - ) -> RenderSync[IT, OT, P]: + def as_render_fn( + fn: UserFuncSync[IT], + ) -> RenderFunctionSync[IT, OT, P]: ... @overload - def wrapper( - wrapper_fn: UserFuncAsync[IT], - ) -> RenderAsync[IT, OT, P]: + def as_render_fn( + fn: UserFuncAsync[IT], + ) -> RenderFunctionAsync[IT, OT, P]: ... - def wrapper( - wrapper_fn: UserFuncSync[IT] | UserFuncAsync[IT], - ) -> RenderSync[IT, OT, P] | RenderAsync[IT, OT, P]: - if _utils.is_async_callable(wrapper_fn): - return wrapper_async(wrapper_fn) + def as_render_fn( + fn: UserFuncSync[IT] | UserFuncAsync[IT], + ) -> RenderFunctionSync[IT, OT, P] | RenderFunctionAsync[IT, OT, P]: + if _utils.is_async_callable(fn): + return render_fn_async(fn) else: # Is not not `UserFuncAsync[IT]`. Cast `wrapper_fn` - wrapper_fn = cast(UserFuncSync[IT], wrapper_fn) - return wrapper_sync(wrapper_fn) + fn = cast(UserFuncSync[IT], fn) + return render_fn_sync(fn) if _render_fn is None: - return wrapper - - if _utils.is_async_callable(_render_fn): - return wrapper_async(_render_fn) - else: - _render_fn = cast(UserFuncSync[IT], _render_fn) - return wrapper_sync(_render_fn) + return as_render_fn + return as_render_fn(_render_fn) - return render_deco + return renderer_deco # ====================================================================================== @@ -375,7 +380,7 @@ def wrapper( # ====================================================================================== @renderer_gen def text( - meta: RenderFunctionMeta, + meta: RendererMeta, value: str, ) -> str: """ @@ -409,7 +414,7 @@ def text( # a nontrivial amount of overhead. So for now, we're just using `object`. @renderer_gen def plot( - meta: RenderFunctionMeta, + meta: RendererMeta, x: ImgData, *, alt: Optional[str] = None, @@ -458,21 +463,24 @@ def plot( ~shiny.ui.output_plot ~shiny.render.image """ - is_userfn_async = isinstance(meta, RenderFunctionAsync) + is_userfn_async = meta["is_async"] + name = meta["name"] + session = meta["session"] + ppi: float = 96 # TODO-barret; Q: These variable calls are **after** `self._fn()`. Is this ok? - inputs = meta._session.root_scope().input + inputs = session.root_scope().input # Reactively read some information about the plot. pixelratio: float = typing.cast( float, inputs[ResolvedId(".clientdata_pixelratio")]() ) width: float = typing.cast( - float, inputs[ResolvedId(f".clientdata_output_{meta._name}_width")]() + float, inputs[ResolvedId(f".clientdata_output_{name}_width")]() ) height: float = typing.cast( - float, inputs[ResolvedId(f".clientdata_output_{meta._name}_height")]() + float, inputs[ResolvedId(f".clientdata_output_{name}_height")]() ) # !! Normal position for `x = await self._fn()` @@ -551,7 +559,7 @@ def plot( # ====================================================================================== @renderer_gen def image( - meta: RenderFunctionMeta, + meta: RendererMeta, res: ImgData | None, *, delete_file: bool = False, @@ -615,7 +623,7 @@ def to_pandas(self) -> "pd.DataFrame": @renderer_gen def table( - meta: RenderFunctionMeta, + meta: RendererMeta, x: TableResult, *, index: bool = False, @@ -700,7 +708,7 @@ def table( # ====================================================================================== @renderer_gen def ui( - meta: RenderFunctionMeta, + meta: RendererMeta, ui: TagChild, ) -> RenderedDeps | None: """ @@ -725,4 +733,4 @@ def ui( if ui is None: return None - return meta._session._process_ui(ui) + return meta["session"]._process_ui(ui) diff --git a/shiny/session/_session.py b/shiny/session/_session.py index 5db4d7a18..730e2884c 100644 --- a/shiny/session/_session.py +++ b/shiny/session/_session.py @@ -998,7 +998,7 @@ def set_fn(fn: RenderFunction[IT, OT]) -> None: ) # fn is a RenderFunction object. Give it a bit of metadata. - fn.set_metadata(self._session, output_name) + fn._set_metadata(self._session, output_name) if output_name in self._effects: self._effects[output_name].destroy() diff --git a/tests/test_renderer_gen.py b/tests/test_renderer_gen.py index 98c9cff1f..223095a1e 100644 --- a/tests/test_renderer_gen.py +++ b/tests/test_renderer_gen.py @@ -1,10 +1,10 @@ -from shiny.render._render import RenderFunctionMeta, renderer_gen +from shiny.render._render import RendererMeta, renderer_gen def test_renderer_gen_assertions(): @renderer_gen def test_fn1( - meta: RenderFunctionMeta, + meta: RendererMeta, x: str, ): ... @@ -13,7 +13,7 @@ def test_fn1( @renderer_gen def test_fn2( - meta: RenderFunctionMeta, + meta: RendererMeta, x: str, y: str, ): @@ -27,7 +27,7 @@ def test_fn2( @renderer_gen def test_fn3( - meta: RenderFunctionMeta, + meta: RendererMeta, x: str, *args: str, ): @@ -42,7 +42,7 @@ def test_fn3( @renderer_gen def test_fn4( - meta: RenderFunctionMeta, + meta: RendererMeta, x: str, *, y: str, @@ -57,7 +57,7 @@ def test_fn4( # Test that kwargs can be allowed @renderer_gen def test_fn5( - meta: RenderFunctionMeta, + meta: RendererMeta, x: str, *, y: str = "42", From 8834255fb9437b98436449261d27176fc87d603f Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Fri, 14 Jul 2023 10:49:45 -0400 Subject: [PATCH 09/64] Require users to use `None` when describing `IT` and `OT` --- e2e/server/renderer_gen/app.py | 6 +++-- shiny/render/_dataframe.py | 2 +- shiny/render/_render.py | 41 ++++++++++++++++------------------ 3 files changed, 24 insertions(+), 25 deletions(-) diff --git a/e2e/server/renderer_gen/app.py b/e2e/server/renderer_gen/app.py index 8375a9b43..3931aa7f9 100644 --- a/e2e/server/renderer_gen/app.py +++ b/e2e/server/renderer_gen/app.py @@ -7,10 +7,12 @@ @renderer_gen def render_test_text( meta: RendererMeta, - value: str, + value: str | None, *, extra_txt: Optional[str] = None, -) -> str: +) -> str | None: + if value is None: + return None value = str(value) value += "; " value += "async" if meta["is_async"] else "sync" diff --git a/shiny/render/_dataframe.py b/shiny/render/_dataframe.py index 2de0ba8aa..dc0813cfc 100644 --- a/shiny/render/_dataframe.py +++ b/shiny/render/_dataframe.py @@ -199,7 +199,7 @@ def to_payload(self) -> object: @renderer_gen def data_frame( meta: RendererMeta, - x: DataFrameResult, + x: DataFrameResult | None, ) -> object | None: """ Reactively render a Pandas data frame object (or similar) as a basic HTML table. diff --git a/shiny/render/_render.py b/shiny/render/_render.py index 0d599ec03..c34b034e9 100644 --- a/shiny/render/_render.py +++ b/shiny/render/_render.py @@ -67,6 +67,7 @@ T = TypeVar("T") +# Meta informatoin to give `value_fn()` some context class RendererMeta(TypedDict): is_async: bool session: Session @@ -95,7 +96,7 @@ def __init__(self, render_fn: UserFunc[IT]) -> None: # we can act as if `render_fn` is always async self._fn = _utils.wrap_async(render_fn) - def __call__(self) -> OT | None: + def __call__(self) -> OT: raise NotImplementedError def _set_metadata(self, session: Session, name: str) -> None: @@ -139,16 +140,11 @@ def __init__( self._args = args self._kwargs = kwargs - def __call__(self) -> OT | None: + def __call__(self) -> OT: return _utils.run_coro_sync(self._run()) - async def _run(self) -> OT | None: + async def _run(self) -> OT: fn_val = await self._fn() - # TODO-barret; `self._value_fn()` must handle the `None` case - # TODO-barret; Make `OT` include `| None` - - if fn_val is None: - return fn_val ret = await self._value_fn( # RendererMeta self.meta, @@ -195,14 +191,11 @@ def __init__( async def __call__( # pyright: ignore[reportIncompatibleMethodOverride] self, - ) -> OT | None: + ) -> OT: return await self._run() - async def _run(self) -> OT | None: + async def _run(self) -> OT: fn_val = await self._fn() - # If User returned None, quit early and return `None` before being processed - if fn_val is None: - return fn_val ret = await self._value_fn( # RendererMeta self.meta, @@ -219,12 +212,12 @@ async def _run(self) -> OT | None: # Type definitions # ====================================================================================== -UserFuncSync = Callable[[], IT | None] -UserFuncAsync = Callable[[], Awaitable[IT | None]] +UserFuncSync = Callable[[], IT] +UserFuncAsync = Callable[[], Awaitable[IT]] UserFunc = UserFuncSync[IT] | UserFuncAsync[IT] ValueFunc = ( - Callable[Concatenate[RendererMeta, IT, P], OT | None] - | Callable[Concatenate[RendererMeta, IT, P], Awaitable[OT | None]] + Callable[Concatenate[RendererMeta, IT, P], OT] + | Callable[Concatenate[RendererMeta, IT, P], Awaitable[OT]] ) RenderDecoSync = Callable[[UserFuncSync[IT]], RenderFunctionSync[IT, OT, P]] RenderDecoAsync = Callable[[UserFuncAsync[IT]], RenderFunctionAsync[IT, OT, P]] @@ -381,8 +374,8 @@ def as_render_fn( @renderer_gen def text( meta: RendererMeta, - value: str, -) -> str: + value: str | None, +) -> str | None: """ Reactively render text. @@ -402,6 +395,8 @@ def text( -------- ~shiny.ui.output_text """ + if value is None: + return None return str(value) @@ -415,7 +410,7 @@ def text( @renderer_gen def plot( meta: RendererMeta, - x: ImgData, + x: ImgData | None, *, alt: Optional[str] = None, **kwargs: object, @@ -544,7 +539,7 @@ def plot( # This check must happen last because # matplotlib might be able to plot even if x is `None` - if x is None: # type: ignore ; TODO-barret remove this check once `value` can be `None` + if x is None: return None raise Exception( @@ -624,7 +619,7 @@ def to_pandas(self) -> "pd.DataFrame": @renderer_gen def table( meta: RendererMeta, - x: TableResult, + x: TableResult | None, *, index: bool = False, classes: str = "table shiny-table w-auto", @@ -670,6 +665,8 @@ def table( -------- ~shiny.ui.output_table """ + if x is None: + return None import pandas import pandas.io.formats.style From 7b8abf788e870db84305e2ed9681cef70dd67574 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Fri, 14 Jul 2023 12:12:59 -0400 Subject: [PATCH 10/64] Do not allow args to be named `_render_fn`. Break up test into many tests --- shiny/render/_render.py | 54 ++++++++++----- tests/test_renderer_gen.py | 136 ++++++++++++++++++++++++++++++++++--- 2 files changed, 163 insertions(+), 27 deletions(-) diff --git a/shiny/render/_render.py b/shiny/render/_render.py index c34b034e9..03f8a843e 100644 --- a/shiny/render/_render.py +++ b/shiny/render/_render.py @@ -130,7 +130,7 @@ def __init__( ) -> None: # `*args` must be in the `__init__` signature # Make sure there no `args`! - assert len(args) == 0 + _assert_no_args(args) # Unpack args _fn, _value_fn = _render_args @@ -174,7 +174,7 @@ def __init__( ) -> None: # `*args` must be in the `__init__` signature # Make sure there no `args`! - assert len(args) == 0 + _assert_no_args(args) # Unpack args _fn, _value_fn = _render_args @@ -236,11 +236,16 @@ async def _run(self) -> OT: # ====================================================================================== +def _assert_no_args(args: tuple[object]) -> None: + if len(args) > 0: + raise RuntimeError("`args` should not be supplied") + + # assert: No variable length positional values; # * We need a way to distinguish between a plain function and args supplied to the next function. This is done by not allowing `*args`. # assert: All kwargs of value_fn should have a default value # * This makes calling the method with both `()` and without `()` possible / consistent. -def _assert_value_fn(value_fn: ValueFunc[IT, P, OT]): +def _assert_value_fn(value_fn: ValueFunc[IT, P, OT]) -> None: params = inspect.Signature.from_callable(value_fn).parameters for i, param in zip(range(len(params)), params.values()): @@ -259,13 +264,17 @@ def _assert_value_fn(value_fn: ValueFunc[IT, P, OT]): raise TypeError( f"No variadic parameters (e.g. `*args`) can be supplied to `value_fn=`. Received: `{param.name}`" ) - if ( - param.kind == inspect.Parameter.KEYWORD_ONLY - and param.default is inspect.Parameter.empty - ): - raise TypeError( - f"In `value_fn=`, parameter `{param.name}` did not have a default value" - ) + if param.kind == inspect.Parameter.KEYWORD_ONLY: + # Do not allow for a kwarg to be named `_render_fn` + if param.name == "_render_fn": + raise ValueError( + "In `value_fn=`, parameters can not be named `_render_fn`" + ) + # Make sure kwargs have default values + if param.default is inspect.Parameter.empty: + raise TypeError( + f"In `value_fn=`, parameter `{param.name}` did not have a default value" + ) def renderer_gen( @@ -280,16 +289,20 @@ def renderer_gen( @overload # RenderDecoSync[IT, OT, P] - def renderer_deco(_render_fn: UserFuncSync[IT]) -> RenderFunctionSync[IT, OT, P]: + def renderer_decorator( + _render_fn: UserFuncSync[IT], + ) -> RenderFunctionSync[IT, OT, P]: ... @overload # RenderDecoAsync[IT, OT, P] - def renderer_deco(_render_fn: UserFuncAsync[IT]) -> RenderFunctionAsync[IT, OT, P]: + def renderer_decorator( + _render_fn: UserFuncAsync[IT], + ) -> RenderFunctionAsync[IT, OT, P]: ... @overload - def renderer_deco(*args: P.args, **kwargs: P.kwargs) -> RenderDeco[IT, OT, P]: + def renderer_decorator(*args: P.args, **kwargs: P.kwargs) -> RenderDeco[IT, OT, P]: ... # # If we use `wraps()`, the overloads are lost. @@ -307,7 +320,7 @@ def renderer_deco(*args: P.args, **kwargs: P.kwargs) -> RenderDeco[IT, OT, P]: # * By making assertions on `P.args` to only allow for `*`, we _can_ make overloads # that use either the single positional arg (`_render_fn`) or # the `P.kwargs` (as `P.args` == `*`) - def renderer_deco( # type: ignore[reportGeneralTypeIssues] + def renderer_decorator( # type: ignore[reportGeneralTypeIssues] _render_fn: Optional[UserFuncSync[IT] | UserFuncAsync[IT]] = None, *args: P.args, # Equivalent to `*` after assertions in `_assert_value_fn()` **kwargs: P.kwargs, @@ -317,9 +330,9 @@ def renderer_deco( # type: ignore[reportGeneralTypeIssues] | RenderFunctionSync[IT, OT, P] | RenderFunctionAsync[IT, OT, P] ): - # `args` **must** be in `renderer_deco` definition. + # `args` **must** be in `renderer_decorator` definition. # Make sure there no `args`! - assert len(args) == 0 + _assert_no_args(args) def render_fn_sync( fn_sync: UserFuncSync[IT], @@ -365,7 +378,14 @@ def as_render_fn( return as_render_fn return as_render_fn(_render_fn) - return renderer_deco + # Copy over name an docs + renderer_decorator.__doc__ = value_fn.__doc__ + renderer_decorator.__name__ = value_fn.__name__ + # # TODO-barret; Fix name of decorated function. Hovering over method name does not work + # ren_func = getattr(renderer_decorator, "__func__", renderer_decorator) + # ren_func.__name__ = value_fn.__name__ + + return renderer_decorator # ====================================================================================== diff --git a/tests/test_renderer_gen.py b/tests/test_renderer_gen.py index 223095a1e..af253fa46 100644 --- a/tests/test_renderer_gen.py +++ b/tests/test_renderer_gen.py @@ -1,18 +1,90 @@ from shiny.render._render import RendererMeta, renderer_gen -def test_renderer_gen_assertions(): +def test_renderer_gen_name_and_docs_are_copied(): @renderer_gen - def test_fn1( + def fn_sync(meta: RendererMeta, x: str) -> str: + "Sync test docs go here" + return "42" + + assert fn_sync.__doc__ == "Sync test docs go here" + assert fn_sync.__name__ == "fn_sync" + + @renderer_gen + async def fn_async(meta: RendererMeta, x: str) -> str: + "Async test docs go here" + return "42" + + assert fn_async.__doc__ == "Async test docs go here" + assert fn_async.__name__ == "fn_async" + + +def test_renderer_gen_works(): + # No args works + @renderer_gen + def test_renderer_sync( + meta: RendererMeta, + x: str, + ): + ... + + @renderer_gen + async def test_renderer_async( + meta: RendererMeta, + x: str, + ): + ... + + +def test_renderer_gen_kwargs_are_allowed(): + # Test that kwargs can be allowed + @renderer_gen + def test_renderer_sync( meta: RendererMeta, x: str, + *, + y: str = "42", ): ... + @renderer_gen + async def test_renderer_async( + meta: RendererMeta, + x: str, + *, + y: str = "42", + ): + ... + + +def test_renderer_gen_with_pass_through_kwargs(): + # No args works + @renderer_gen + def test_renderer_sync( + meta: RendererMeta, + x: str, + *, + y: str = "42", + **kwargs: float, + ): + ... + + @renderer_gen + async def test_renderer_async( + meta: RendererMeta, + x: str, + *, + y: str = "42", + **kwargs: float, + ): + ... + + +def test_renderer_gen_limits_positional_arg_count(): try: @renderer_gen - def test_fn2( + def test_renderer( meta: RendererMeta, x: str, y: str, @@ -23,10 +95,12 @@ def test_fn2( except TypeError as e: assert "more than 2 positional" in str(e) + +def test_renderer_gen_does_not_allow_args(): try: @renderer_gen - def test_fn3( + def test_renderer( meta: RendererMeta, x: str, *args: str, @@ -38,10 +112,12 @@ def test_fn3( except TypeError as e: assert "No variadic parameters" in str(e) + +def test_renderer_gen_kwargs_have_defaults(): try: @renderer_gen - def test_fn4( + def test_renderer( meta: RendererMeta, x: str, *, @@ -54,13 +130,53 @@ def test_fn4( except TypeError as e: assert "did not have a default value" in str(e) - # Test that kwargs can be allowed + +def test_renderer_gen_kwargs_can_not_be_name_render_fn(): + try: + + @renderer_gen + def test_renderer( + meta: RendererMeta, + x: str, + *, + _render_fn: str, + ): + ... + + raise RuntimeError() + + except ValueError as e: + assert "parameters can not be named `_render_fn`" in str(e) + + +def test_renderer_gen_result_does_not_allow_args(): @renderer_gen - def test_fn5( + def test_renderer( meta: RendererMeta, x: str, - *, - y: str = "42", - **kwargs: object, ): ... + + # Test that args can **not** be supplied + def render_fn_sync(*args: str): + return " ".join(args) + + async def render_fn_async(*args: str): + return " ".join(args) + + try: + test_renderer( # type: ignore + "X", + "Y", + )(render_fn_sync) + raise RuntimeError() + except RuntimeError as e: + assert "`args` should not be supplied" in str(e) + + try: + test_renderer( # type: ignore + "X", + "Y", + )(render_fn_async) + except RuntimeError as e: + assert "`args` should not be supplied" in str(e) From 87fc09f54a94ff5426f167c633e75c260e78466b Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Fri, 14 Jul 2023 12:23:51 -0400 Subject: [PATCH 11/64] Get ParamSpec and Concatenate from typing extensions --- shiny/render/_render.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/shiny/render/_render.py b/shiny/render/_render.py index 03f8a843e..93007c7d1 100644 --- a/shiny/render/_render.py +++ b/shiny/render/_render.py @@ -26,10 +26,8 @@ TYPE_CHECKING, Awaitable, Callable, - Concatenate, Generic, Optional, - ParamSpec, Protocol, Tuple, TypeVar, @@ -53,7 +51,7 @@ from .. import _utils from .._namespaces import ResolvedId -from .._typing_extensions import TypedDict +from .._typing_extensions import Concatenate, ParamSpec, TypedDict from ..types import ImgData from ._try_render_plot import try_render_matplotlib, try_render_pil, try_render_plotnine From 4f69268d7a2e20ddb8b77e6699038eab7b72d664 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Fri, 14 Jul 2023 12:30:53 -0400 Subject: [PATCH 12/64] Legacy typing lints --- e2e/server/renderer_gen/app.py | 4 ++++ shiny/render/_render.py | 17 ++++++++++------- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/e2e/server/renderer_gen/app.py b/e2e/server/renderer_gen/app.py index 3931aa7f9..6129c918c 100644 --- a/e2e/server/renderer_gen/app.py +++ b/e2e/server/renderer_gen/app.py @@ -1,3 +1,7 @@ +# Needed for types imported only during TYPE_CHECKING with Python 3.7 - 3.9 +# See https://www.python.org/dev/peps/pep-0655/#usage-in-python-3-11 +from __future__ import annotations + from typing import Optional from shiny import App, Inputs, Outputs, Session, ui diff --git a/shiny/render/_render.py b/shiny/render/_render.py index 93007c7d1..24b0d5838 100644 --- a/shiny/render/_render.py +++ b/shiny/render/_render.py @@ -212,16 +212,19 @@ async def _run(self) -> OT: UserFuncSync = Callable[[], IT] UserFuncAsync = Callable[[], Awaitable[IT]] -UserFunc = UserFuncSync[IT] | UserFuncAsync[IT] -ValueFunc = ( - Callable[Concatenate[RendererMeta, IT, P], OT] - | Callable[Concatenate[RendererMeta, IT, P], Awaitable[OT]] -) +UserFunc = Union[ + UserFuncSync[IT], + UserFuncAsync[IT], +] +ValueFunc = Union[ + Callable[Concatenate[RendererMeta, IT, P], OT], + Callable[Concatenate[RendererMeta, IT, P], Awaitable[OT]], +] RenderDecoSync = Callable[[UserFuncSync[IT]], RenderFunctionSync[IT, OT, P]] RenderDecoAsync = Callable[[UserFuncAsync[IT]], RenderFunctionAsync[IT, OT, P]] RenderDeco = Callable[ - [UserFuncSync[IT] | UserFuncAsync[IT]], - RenderFunctionSync[IT, OT, P] | RenderFunctionAsync[IT, OT, P], + [Union[UserFuncSync[IT], UserFuncAsync[IT]]], + Union[RenderFunctionSync[IT, OT, P], RenderFunctionAsync[IT, OT, P]], ] From be7d0b5cab9dfe0e99b1bec109fc66d68cad2c44 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Mon, 17 Jul 2023 09:21:19 -0400 Subject: [PATCH 13/64] Add TODOs --- shiny/render/_render.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/shiny/render/_render.py b/shiny/render/_render.py index 24b0d5838..d3c51e040 100644 --- a/shiny/render/_render.py +++ b/shiny/render/_render.py @@ -2,6 +2,13 @@ # See https://www.python.org/dev/peps/pep-0655/#usage-in-python-3-11 from __future__ import annotations +# TODO-barret; Rename `renderer_gen` to `renderer`? +# TODO-barret; change the name of the returned function from renderer_gen function in the overload. If anything, use `_`. +# TODO-barret; See if @overload will work on the returned already-overloaded function +# * From initial attempts, it does not work. :-( +# * TODO-barret; Make a helper method to return all types (and function?) that could be used to make the overload signatures manually + + __all__ = ( # "renderer_gen", # "RendererMeta", @@ -87,7 +94,7 @@ def is_async(self) -> bool: raise NotImplementedError() def __init__(self, render_fn: UserFunc[IT]) -> None: - self.__name__ = render_fn.__name__ # TODO-barret; Set name of async function + self.__name__ = render_fn.__name__ self.__doc__ = render_fn.__doc__ # Given we use `_utils.run_coro_sync(self._run())` to call our method, @@ -421,6 +428,18 @@ def text( return str(value) +# @renderer_gen +# async def async_text(meta: RendererMeta, value: str | None) -> str | None: +# """ +# My docs go here! +# """ +# return str(value) + + +# text +# async_text + + # ====================================================================================== # RenderPlot # ====================================================================================== From b36c893caa4679ffda503e26dcd662201ad4b03c Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 25 Jul 2023 10:35:22 -0400 Subject: [PATCH 14/64] Rework names; Have the handler function receive the render function for better control --- e2e/server/renderer_gen/app.py | 13 +- shiny/reactive/_reactives.py | 4 +- shiny/render/__init__.py | 12 +- shiny/render/_dataframe.py | 11 +- shiny/render/_render.py | 306 ++++++++++++++++++++------------- shiny/session/_session.py | 33 ++-- tests/test_renderer_gen.py | 112 +++++------- 7 files changed, 263 insertions(+), 228 deletions(-) diff --git a/e2e/server/renderer_gen/app.py b/e2e/server/renderer_gen/app.py index 6129c918c..203d046ad 100644 --- a/e2e/server/renderer_gen/app.py +++ b/e2e/server/renderer_gen/app.py @@ -5,18 +5,17 @@ from typing import Optional from shiny import App, Inputs, Outputs, Session, ui -from shiny.render._render import RendererMeta, renderer_gen +from shiny.render._render import RenderFn, RenderMeta, renderer -@renderer_gen -def render_test_text( - meta: RendererMeta, - value: str | None, +@renderer +async def render_test_text( + meta: RenderMeta, + fn: RenderFn[str | None], *, extra_txt: Optional[str] = None, ) -> str | None: - if value is None: - return None + value = await fn() value = str(value) value += "; " value += "async" if meta["is_async"] else "sync" diff --git a/shiny/reactive/_reactives.py b/shiny/reactive/_reactives.py index 642933aa8..614d58da5 100644 --- a/shiny/reactive/_reactives.py +++ b/shiny/reactive/_reactives.py @@ -22,7 +22,7 @@ from .._docstring import add_example from .._utils import is_async_callable, run_coro_sync from .._validation import req -from ..render import RenderFunction +from ..render._render import Renderer from ..types import MISSING, MISSING_TYPE, ActionButtonValue, SilentException from ._core import Context, Dependents, ReactiveWarning, isolate @@ -782,7 +782,7 @@ def decorator(user_fn: Callable[[], T]) -> Callable[[], T]: + "In other words, `@reactive.Calc` must be above `@reactive.event()`." ) - if isinstance(user_fn, RenderFunction): + if isinstance(user_fn, Renderer): # 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 2c98029af..d08a8f73e 100644 --- a/shiny/render/__init__.py +++ b/shiny/render/__init__.py @@ -4,11 +4,12 @@ from ._render import ( # noqa: F401 # Import these values, but do not give autocomplete hints for `shiny.render.FOO` - RendererMeta as RendererMeta, - RenderFunction as RenderFunction, - RenderFunctionSync as RenderFunctionSync, - RenderFunctionAsync as RenderFunctionAsync, - renderer_gen as renderer_gen, + RenderMeta as RenderMeta, + RenderFn as RenderFn, + # Renderer as Renderer, + # RendererSync as RendererSync, + # RendererAsync as RendererAsync, + renderer as renderer, text, plot, image, @@ -30,5 +31,4 @@ "image", "table", "ui", - "renderer_gen", ) diff --git a/shiny/render/_dataframe.py b/shiny/render/_dataframe.py index dc0813cfc..c3b640a92 100644 --- a/shiny/render/_dataframe.py +++ b/shiny/render/_dataframe.py @@ -5,7 +5,7 @@ from typing import TYPE_CHECKING, Any, Literal, Protocol, Union, cast, runtime_checkable from .._docstring import add_example -from . import RendererMeta, renderer_gen +from . import RenderFn, RenderMeta, renderer if TYPE_CHECKING: import pandas as pd @@ -196,10 +196,10 @@ def to_payload(self) -> object: # TODO-barret; Port `__name__` and `__docs__` of `value_fn` @add_example() -@renderer_gen -def data_frame( - meta: RendererMeta, - x: DataFrameResult | None, +@renderer +async def data_frame( + meta: RenderMeta, + fn: RenderFn[DataFrameResult | None], ) -> object | None: """ Reactively render a Pandas data frame object (or similar) as a basic HTML table. @@ -224,6 +224,7 @@ def data_frame( :class:`~shiny.render.DataTable` :func:`~shiny.ui.output_data_frame` """ + x = await fn() if x is None: return None diff --git a/shiny/render/_render.py b/shiny/render/_render.py index d3c51e040..0522beb45 100644 --- a/shiny/render/_render.py +++ b/shiny/render/_render.py @@ -2,19 +2,25 @@ # See https://www.python.org/dev/peps/pep-0655/#usage-in-python-3-11 from __future__ import annotations -# TODO-barret; Rename `renderer_gen` to `renderer`? -# TODO-barret; change the name of the returned function from renderer_gen function in the overload. If anything, use `_`. +# TODO-barret; change the name of the returned function from renderer function in the overload. If anything, use `_`. # TODO-barret; See if @overload will work on the returned already-overloaded function # * From initial attempts, it does not work. :-( # * TODO-barret; Make a helper method to return all types (and function?) that could be used to make the overload signatures manually +# TODO-barret; Changelog that RenderFunction no longer exists. +# TODO-barret; Should `Renderer` be exported? +# W/ Rich: +# The function is called a "render handler" as it handles the "render function" and returns a rendered result. + +# result of `@renderer` is "renderer function" + +# Names: +# * `_value_fn` -> `_handler` +# * `value: IT` -> `fn: RenderFn[IT]` + +# TODO-barret; Require that `render_fn` is called __all__ = ( - # "renderer_gen", - # "RendererMeta", - # "RenderFunction", - # "RenderFunctionSync", - # "RenderFunctionAsync", "text", "plot", "image", @@ -64,64 +70,94 @@ # Input type for the user-spplied function that is passed to a render.xx IT = TypeVar("IT") -# Output type after the RenderFunction.__call__ method is called on the IT object. +# Output type after the Renderer.__call__ method is called on the IT object. OT = TypeVar("OT") -# Param specification for value function +# Param specification for render_fn function P = ParamSpec("P") # Generic type var T = TypeVar("T") -# Meta informatoin to give `value_fn()` some context -class RendererMeta(TypedDict): +# Meta informatoin to give `hander()` some context +class RenderMeta(TypedDict): is_async: bool session: Session name: str # ====================================================================================== -# RenderFunction / RenderFunctionSync / RenderFunctionAsync base class +# Renderer / RendererSync / RendererAsync base class # ====================================================================================== -# A RenderFunction object is given a user-provided function (`value_fn`) which returns -# an `IT`. When the .__call___ method is invoked, it calls the user-provided function -# (which returns an `IT`), then converts the `IT` to an `OT`. Note that in many cases -# but not all, `IT` and `OT` will be the same. -class RenderFunction(Generic[IT, OT]): - @property - def is_async(self) -> bool: - raise NotImplementedError() +# A Renderer object is given a user-provided function (`handler_fn`) which returns an +# `OT`. +class Renderer(Generic[OT]): + """ + Output Renderer - def __init__(self, render_fn: UserFunc[IT]) -> None: - self.__name__ = render_fn.__name__ - self.__doc__ = render_fn.__doc__ + Base class to build :class:`~shiny.render.RendererSync` and :class:`~shiny.render.RendererAsync`. - # Given we use `_utils.run_coro_sync(self._run())` to call our method, - # we can act as if `render_fn` is always async - self._fn = _utils.wrap_async(render_fn) + + When the `.__call__` method is invoked, the handler function (which defined by + package authors) is called. The handler function is given `meta` information, the + (app-supplied) render function, and any keyword arguments supplied to the decorator. + + The render function should return type `IT` and has parameter specification of type + `P`. The handler function should return type `OT`. Note that in many cases but not + all, `IT` and `OT` will be the same. `None` values must always be defined in `IT` and `OT`. + + + Properties + ---------- + is_async + If `TRUE`, the app-supplied render function is asynchronous + meta + A named dictionary of values: `is_async`, `session` (the :class:`~shiny.Session` + object), and `name` (the name of the output being rendered) + + """ def __call__(self) -> OT: raise NotImplementedError - def _set_metadata(self, session: Session, name: str) -> None: - """When RenderFunctions are assigned to Output object slots, this method - is used to pass along session and name information. + def __init__(self, *, name: str, doc: str | None) -> None: + """\ + Renderer init method + + Arguments + --------- + name + Name of original output function. Ex: `my_txt` + doc + Documentation of the output function. Ex: `"My text output will be displayed verbatim". """ - self._session: Session = session - self._name: str = name + self.__name__ = name + self.__doc__ = doc + + @property + def is_async(self) -> bool: + raise NotImplementedError() @property - def meta(self) -> RendererMeta: - return RendererMeta( + def meta(self) -> RenderMeta: + return RenderMeta( is_async=self.is_async, session=self._session, name=self._name, ) + 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 + # Using a second class to help clarify that it is of a particular type -class RenderFunctionSync(Generic[IT, OT, P], RenderFunction[IT, OT]): +class RendererSync(Generic[IT, OT, P], Renderer[OT]): @property def is_async(self) -> bool: return False @@ -138,10 +174,25 @@ def __init__( _assert_no_args(args) # Unpack args - _fn, _value_fn = _render_args - super().__init__(_fn) + render_fn, handler_fn = _render_args + if _utils.is_async_callable(render_fn): + raise TypeError( + self.__class__.__name__ + " requires a sync render function" + ) + if not _utils.is_async_callable(handler_fn): + raise TypeError( + self.__class__.__name__ + " requires an async handler function" + ) + super().__init__( + name=render_fn.__name__, + doc=render_fn.__doc__, + ) + + # Given we use `_utils.run_coro_sync(self._run())` to call our method, + # we can act as if `render_fn` and `handler_fn` are always async + self._render_fn = _utils.wrap_async(render_fn) + self._handler_fn = _utils.wrap_async(handler_fn) - self._value_fn = _utils.wrap_async(_value_fn) self._args = args self._kwargs = kwargs @@ -149,12 +200,11 @@ def __call__(self) -> OT: return _utils.run_coro_sync(self._run()) async def _run(self) -> OT: - fn_val = await self._fn() - ret = await self._value_fn( + ret = await self._handler_fn( # RendererMeta self.meta, - # IT - fn_val, + # Callable[[], Awaitable[IT]] + self._render_fn, # P *self._args, **self._kwargs, @@ -162,10 +212,10 @@ async def _run(self) -> OT: return ret -# The reason for having a separate RenderFunctionAsync class is because the __call__ +# 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 RenderFunctionAsync(Generic[IT, OT, P], RenderFunction[IT, OT]): +class RendererAsync(Generic[IT, OT, P], Renderer[OT]): @property def is_async(self) -> bool: return True @@ -182,15 +232,26 @@ def __init__( _assert_no_args(args) # Unpack args - _fn, _value_fn = _render_args + render_fn, handler_fn = _render_args - if not _utils.is_async_callable(_fn): - raise TypeError(self.__class__.__name__ + " requires an async function") - # super == RenderFunctionAsync, RenderFunction - super().__init__(_fn) + if not _utils.is_async_callable(render_fn): + raise TypeError( + self.__class__.__name__ + " requires an async render function" + ) + if not _utils.is_async_callable(handler_fn): + raise TypeError( + self.__class__.__name__ + " requires an async handler function" + ) + # super == RendererAsync, Renderer + super().__init__( + name=render_fn.__name__, + doc=render_fn.__doc__, + ) - self._fn = _fn - self._value_fn = _utils.wrap_async(_value_fn) + # Given we use `_utils.run_coro_sync(self._run())` to call our method, + # we can act as if `render_fn` and `handler_fn` are always async + self._render_fn = _utils.wrap_async(render_fn) + self._handler_fn = _utils.wrap_async(handler_fn) self._args = args self._kwargs = kwargs @@ -200,12 +261,11 @@ async def __call__( # pyright: ignore[reportIncompatibleMethodOverride] return await self._run() async def _run(self) -> OT: - fn_val = await self._fn() - ret = await self._value_fn( + ret = await self._handler_fn( # RendererMeta self.meta, - # IT - fn_val, + # Callable[[], Awaitable[IT]] + self._render_fn, # P *self._args, **self._kwargs, @@ -217,26 +277,28 @@ async def _run(self) -> OT: # Type definitions # ====================================================================================== + UserFuncSync = Callable[[], IT] UserFuncAsync = Callable[[], Awaitable[IT]] UserFunc = Union[ UserFuncSync[IT], UserFuncAsync[IT], ] -ValueFunc = Union[ - Callable[Concatenate[RendererMeta, IT, P], OT], - Callable[Concatenate[RendererMeta, IT, P], Awaitable[OT]], -] -RenderDecoSync = Callable[[UserFuncSync[IT]], RenderFunctionSync[IT, OT, P]] -RenderDecoAsync = Callable[[UserFuncAsync[IT]], RenderFunctionAsync[IT, OT, P]] + +# RenderFn == UserFuncAsync as UserFuncSync is wrapped into an async fn +RenderFn = Callable[[], Awaitable[IT]] +HandlerFn = Callable[Concatenate[RenderMeta, RenderFn[IT], P], Awaitable[OT]] + +RenderDecoSync = Callable[[UserFuncSync[IT]], RendererSync[IT, OT, P]] +RenderDecoAsync = Callable[[UserFuncAsync[IT]], RendererAsync[IT, OT, P]] RenderDeco = Callable[ [Union[UserFuncSync[IT], UserFuncAsync[IT]]], - Union[RenderFunctionSync[IT, OT, P], RenderFunctionAsync[IT, OT, P]], + Union[RendererSync[IT, OT, P], RendererAsync[IT, OT, P]], ] -_RenderArgsSync = Tuple[UserFuncSync[IT], ValueFunc[IT, P, OT]] -_RenderArgsAsync = Tuple[UserFuncAsync[IT], ValueFunc[IT, P, OT]] +_RenderArgsSync = Tuple[UserFuncSync[IT], HandlerFn[IT, P, OT]] +_RenderArgsAsync = Tuple[UserFuncAsync[IT], HandlerFn[IT, P, OT]] # ====================================================================================== @@ -251,10 +313,10 @@ def _assert_no_args(args: tuple[object]) -> None: # assert: No variable length positional values; # * We need a way to distinguish between a plain function and args supplied to the next function. This is done by not allowing `*args`. -# assert: All kwargs of value_fn should have a default value +# assert: All kwargs of handler_fn should have a default value # * This makes calling the method with both `()` and without `()` possible / consistent. -def _assert_value_fn(value_fn: ValueFunc[IT, P, OT]) -> None: - params = inspect.Signature.from_callable(value_fn).parameters +def _assert_handler_fn(handler_fn: HandlerFn[IT, P, OT]) -> None: + params = inspect.Signature.from_callable(handler_fn).parameters for i, param in zip(range(len(params)), params.values()): # # Not a good test as `param.annotation` has type `str`: @@ -265,56 +327,56 @@ def _assert_value_fn(value_fn: ValueFunc[IT, P, OT]) -> None: # Make sure there are no more than 2 positional args if i >= 2 and param.kind == inspect.Parameter.POSITIONAL_OR_KEYWORD: raise TypeError( - "`value_fn=` must not contain more than 2 positional parameters" + "`handler_fn=` must not contain more than 2 positional parameters" ) # Make sure there are no `*args` if param.kind == inspect.Parameter.VAR_POSITIONAL: raise TypeError( - f"No variadic parameters (e.g. `*args`) can be supplied to `value_fn=`. Received: `{param.name}`" + f"No variadic parameters (e.g. `*args`) can be supplied to `handler_fn=`. Received: `{param.name}`" ) if param.kind == inspect.Parameter.KEYWORD_ONLY: # Do not allow for a kwarg to be named `_render_fn` if param.name == "_render_fn": raise ValueError( - "In `value_fn=`, parameters can not be named `_render_fn`" + "In `handler_fn=`, parameters can not be named `_render_fn`" ) # Make sure kwargs have default values if param.default is inspect.Parameter.empty: raise TypeError( - f"In `value_fn=`, parameter `{param.name}` did not have a default value" + f"In `handler_fn=`, parameter `{param.name}` did not have a default value" ) -def renderer_gen( - value_fn: ValueFunc[IT, P, OT], +def renderer( + handler_fn: HandlerFn[IT, P, OT], ): """\ Renderer generator TODO-barret; Docs go here! """ - _assert_value_fn(value_fn) + _assert_handler_fn(handler_fn) + + @overload + def renderer_decorator(*args: P.args, **kwargs: P.kwargs) -> RenderDeco[IT, OT, P]: + ... @overload # RenderDecoSync[IT, OT, P] def renderer_decorator( _render_fn: UserFuncSync[IT], - ) -> RenderFunctionSync[IT, OT, P]: + ) -> RendererSync[IT, OT, P]: ... @overload # RenderDecoAsync[IT, OT, P] def renderer_decorator( _render_fn: UserFuncAsync[IT], - ) -> RenderFunctionAsync[IT, OT, P]: - ... - - @overload - def renderer_decorator(*args: P.args, **kwargs: P.kwargs) -> RenderDeco[IT, OT, P]: + ) -> RendererAsync[IT, OT, P]: ... # # If we use `wraps()`, the overloads are lost. - # @functools.wraps(value_fn) + # @functools.wraps(handler_fn) # Ignoring the type issue on the next line of code as the overloads for # `renderer_deco` are not consistent with the function definition. @@ -330,13 +392,13 @@ def renderer_decorator(*args: P.args, **kwargs: P.kwargs) -> RenderDeco[IT, OT, # the `P.kwargs` (as `P.args` == `*`) def renderer_decorator( # type: ignore[reportGeneralTypeIssues] _render_fn: Optional[UserFuncSync[IT] | UserFuncAsync[IT]] = None, - *args: P.args, # Equivalent to `*` after assertions in `_assert_value_fn()` + *args: P.args, # Equivalent to `*` after assertions in `_assert_handler_fn()` **kwargs: P.kwargs, ) -> ( RenderDecoSync[IT, OT, P] | RenderDecoAsync[IT, OT, P] - | RenderFunctionSync[IT, OT, P] - | RenderFunctionAsync[IT, OT, P] + | RendererSync[IT, OT, P] + | RendererAsync[IT, OT, P] ): # `args` **must** be in `renderer_decorator` definition. # Make sure there no `args`! @@ -344,18 +406,18 @@ def renderer_decorator( # type: ignore[reportGeneralTypeIssues] def render_fn_sync( fn_sync: UserFuncSync[IT], - ) -> RenderFunctionSync[IT, OT, P]: - return RenderFunctionSync( - (fn_sync, value_fn), + ) -> RendererSync[IT, OT, P]: + return RendererSync( + (fn_sync, handler_fn), *args, **kwargs, ) def render_fn_async( fn_async: UserFuncAsync[IT], - ) -> RenderFunctionAsync[IT, OT, P]: - return RenderFunctionAsync( - (fn_async, value_fn), + ) -> RendererAsync[IT, OT, P]: + return RendererAsync( + (fn_async, handler_fn), *args, **kwargs, ) @@ -363,18 +425,18 @@ def render_fn_async( @overload def as_render_fn( fn: UserFuncSync[IT], - ) -> RenderFunctionSync[IT, OT, P]: + ) -> RendererSync[IT, OT, P]: ... @overload def as_render_fn( fn: UserFuncAsync[IT], - ) -> RenderFunctionAsync[IT, OT, P]: + ) -> RendererAsync[IT, OT, P]: ... def as_render_fn( fn: UserFuncSync[IT] | UserFuncAsync[IT], - ) -> RenderFunctionSync[IT, OT, P] | RenderFunctionAsync[IT, OT, P]: + ) -> RendererSync[IT, OT, P] | RendererAsync[IT, OT, P]: if _utils.is_async_callable(fn): return render_fn_async(fn) else: @@ -387,11 +449,11 @@ def as_render_fn( return as_render_fn(_render_fn) # Copy over name an docs - renderer_decorator.__doc__ = value_fn.__doc__ - renderer_decorator.__name__ = value_fn.__name__ + renderer_decorator.__doc__ = handler_fn.__doc__ + renderer_decorator.__name__ = handler_fn.__name__ # # TODO-barret; Fix name of decorated function. Hovering over method name does not work # ren_func = getattr(renderer_decorator, "__func__", renderer_decorator) - # ren_func.__name__ = value_fn.__name__ + # ren_func.__name__ = handler_fn.__name__ return renderer_decorator @@ -399,10 +461,12 @@ def as_render_fn( # ====================================================================================== # RenderText # ====================================================================================== -@renderer_gen -def text( - meta: RendererMeta, - value: str | None, + + +@renderer +async def text( + meta: RenderMeta, + fn: RenderFn[str | None], ) -> str | None: """ Reactively render text. @@ -423,12 +487,13 @@ def text( -------- ~shiny.ui.output_text """ + value = await fn() if value is None: return None return str(value) -# @renderer_gen +# @renderer # async def async_text(meta: RendererMeta, value: str | None) -> str | None: # """ # My docs go here! @@ -447,10 +512,10 @@ def text( # 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`. -@renderer_gen -def plot( - meta: RendererMeta, - x: ImgData | None, +@renderer +async def plot( + meta: RenderMeta, + fn: RenderFn[ImgData | None], *, alt: Optional[str] = None, **kwargs: object, @@ -504,7 +569,7 @@ def plot( ppi: float = 96 - # TODO-barret; Q: These variable calls are **after** `self._fn()`. Is this ok? + # TODO-barret; Q: These variable calls are **after** `self._render_fn()`. Is this ok? inputs = session.root_scope().input # Reactively read some information about the plot. @@ -518,7 +583,7 @@ def plot( float, inputs[ResolvedId(f".clientdata_output_{name}_height")]() ) - # !! Normal position for `x = await self._fn()` + x = await fn() # Note that x might be None; it could be a matplotlib.pyplot @@ -592,10 +657,10 @@ def plot( # ====================================================================================== # RenderImage # ====================================================================================== -@renderer_gen -def image( - meta: RendererMeta, - res: ImgData | None, +@renderer +async def image( + meta: RenderMeta, + fn: RenderFn[ImgData | None], *, delete_file: bool = False, ) -> ImgData | None: @@ -625,6 +690,7 @@ def image( ~shiny.types.ImgData ~shiny.render.plot """ + res = await fn() if res is None: return None @@ -656,10 +722,10 @@ def to_pandas(self) -> "pd.DataFrame": TableResult = Union["pd.DataFrame", PandasCompatible, None] -@renderer_gen -def table( - meta: RendererMeta, - x: TableResult | None, +@renderer +async def table( + meta: RenderMeta, + fn: RenderFn[TableResult | None], *, index: bool = False, classes: str = "table shiny-table w-auto", @@ -705,8 +771,11 @@ def table( -------- ~shiny.ui.output_table """ + x = await fn() + if x is None: return None + import pandas import pandas.io.formats.style @@ -743,10 +812,10 @@ def table( # ====================================================================================== # RenderUI # ====================================================================================== -@renderer_gen -def ui( - meta: RendererMeta, - ui: TagChild, +@renderer +async def ui( + meta: RenderMeta, + fn: RenderFn[TagChild], ) -> RenderedDeps | None: """ Reactively render HTML content. @@ -767,6 +836,7 @@ def ui( -------- ~shiny.ui.output_ui """ + ui = await fn() if ui is None: return None diff --git a/shiny/session/_session.py b/shiny/session/_session.py index 730e2884c..77592d334 100644 --- a/shiny/session/_session.py +++ b/shiny/session/_session.py @@ -47,11 +47,10 @@ from ..input_handler import input_handlers from ..reactive import Effect, Effect_, Value, flush, isolate from ..reactive._core import lock, on_flushed -from ..render import RenderFunction +from ..render._render import Renderer from ..types import SafeException, SilentCancelOutputException, SilentException from ._utils import RenderedDeps, read_thunk_opt, session_context -IT = TypeVar("IT") OT = TypeVar("OT") @@ -956,7 +955,7 @@ def __init__( self._suspend_when_hidden = suspend_when_hidden @overload - def __call__(self, fn: RenderFunction[Any, Any]) -> None: + def __call__(self, renderer: Renderer[Any]) -> None: ... @overload @@ -967,18 +966,18 @@ def __call__( suspend_when_hidden: bool = True, priority: int = 0, name: Optional[str] = None, - ) -> Callable[[RenderFunction[Any, Any]], None]: + ) -> Callable[[Renderer[Any]], None]: ... def __call__( self, - fn: Optional[RenderFunction[IT, OT]] = None, + renderer: Optional[Renderer[OT]] = None, *, id: Optional[str] = None, suspend_when_hidden: bool = True, priority: int = 0, name: Optional[str] = None, - ) -> None | Callable[[RenderFunction[IT, OT]], None]: + ) -> None | Callable[[Renderer[OT]], None]: if name is not None: from .. import _deprecated @@ -987,18 +986,18 @@ def __call__( ) id = name - def set_fn(fn: RenderFunction[IT, OT]) -> None: + def set_renderer(renderer: Renderer[OT]) -> None: # Get the (possibly namespaced) output id - output_name = self._ns(id or fn.__name__) + output_name = self._ns(id or renderer.__name__) - if not isinstance(fn, RenderFunction): + if not isinstance(renderer, Renderer): raise TypeError( "`@output` must be applied to a `@render.xx` function.\n" + "In other words, `@output` must be above `@render.xx`." ) - # fn is a RenderFunction object. Give it a bit of metadata. - fn._set_metadata(self._session, output_name) + # renderer is a Renderer object. Give it a bit of metadata. + renderer._set_metadata(self._session, output_name) if output_name in self._effects: self._effects[output_name].destroy() @@ -1016,10 +1015,10 @@ async def output_obs(): message: dict[str, Optional[OT]] = {} try: - if _utils.is_async_callable(fn): - message[output_name] = await fn() + if _utils.is_async_callable(renderer): + message[output_name] = await renderer() else: - message[output_name] = fn() + message[output_name] = renderer() except SilentCancelOutputException: return except SilentException: @@ -1060,10 +1059,10 @@ async def output_obs(): return None - if fn is None: - return set_fn + if renderer is None: + return set_renderer else: - return set_fn(fn) + return set_renderer(renderer) def _manage_hidden(self) -> None: "Suspends execution of hidden outputs and resumes execution of visible outputs." diff --git a/tests/test_renderer_gen.py b/tests/test_renderer_gen.py index af253fa46..c68c9f11c 100644 --- a/tests/test_renderer_gen.py +++ b/tests/test_renderer_gen.py @@ -1,56 +1,32 @@ -from shiny.render._render import RendererMeta, renderer_gen +from shiny.render._render import RenderFn, RenderMeta, renderer def test_renderer_gen_name_and_docs_are_copied(): - @renderer_gen - def fn_sync(meta: RendererMeta, x: str) -> str: - "Sync test docs go here" - return "42" + @renderer + async def my_handler(meta: RenderMeta, fn: RenderFn[str]) -> str: + "Test docs go here" + return str(await fn()) - assert fn_sync.__doc__ == "Sync test docs go here" - assert fn_sync.__name__ == "fn_sync" - - @renderer_gen - async def fn_async(meta: RendererMeta, x: str) -> str: - "Async test docs go here" - return "42" - - assert fn_async.__doc__ == "Async test docs go here" - assert fn_async.__name__ == "fn_async" + assert my_handler.__doc__ == "Test docs go here" + assert my_handler.__name__ == "my_handler" def test_renderer_gen_works(): # No args works - @renderer_gen - def test_renderer_sync( - meta: RendererMeta, - x: str, - ): - ... - - @renderer_gen - async def test_renderer_async( - meta: RendererMeta, - x: str, + @renderer + async def test_renderer( + meta: RenderMeta, + fn: RenderFn[str], ): ... def test_renderer_gen_kwargs_are_allowed(): # Test that kwargs can be allowed - @renderer_gen - def test_renderer_sync( - meta: RendererMeta, - x: str, - *, - y: str = "42", - ): - ... - - @renderer_gen - async def test_renderer_async( - meta: RendererMeta, - x: str, + @renderer + async def test_renderer( + meta: RenderMeta, + fn: RenderFn[str], *, y: str = "42", ): @@ -59,20 +35,10 @@ async def test_renderer_async( def test_renderer_gen_with_pass_through_kwargs(): # No args works - @renderer_gen - def test_renderer_sync( - meta: RendererMeta, - x: str, - *, - y: str = "42", - **kwargs: float, - ): - ... - - @renderer_gen - async def test_renderer_async( - meta: RendererMeta, - x: str, + @renderer + async def test_renderer( + meta: RenderMeta, + fn: RenderFn[str], *, y: str = "42", **kwargs: float, @@ -83,10 +49,10 @@ async def test_renderer_async( def test_renderer_gen_limits_positional_arg_count(): try: - @renderer_gen - def test_renderer( - meta: RendererMeta, - x: str, + @renderer + async def test_renderer( + meta: RenderMeta, + fn: RenderFn[str], y: str, ): ... @@ -99,10 +65,10 @@ def test_renderer( def test_renderer_gen_does_not_allow_args(): try: - @renderer_gen - def test_renderer( - meta: RendererMeta, - x: str, + @renderer + async def test_renderer( + meta: RenderMeta, + fn: RenderFn[str], *args: str, ): ... @@ -116,10 +82,10 @@ def test_renderer( def test_renderer_gen_kwargs_have_defaults(): try: - @renderer_gen - def test_renderer( - meta: RendererMeta, - x: str, + @renderer + async def test_renderer( + meta: RenderMeta, + fn: RenderFn[str], *, y: str, ): @@ -134,10 +100,10 @@ def test_renderer( def test_renderer_gen_kwargs_can_not_be_name_render_fn(): try: - @renderer_gen - def test_renderer( - meta: RendererMeta, - x: str, + @renderer + async def test_renderer( + meta: RenderMeta, + fn: RenderFn[str], *, _render_fn: str, ): @@ -150,10 +116,10 @@ def test_renderer( def test_renderer_gen_result_does_not_allow_args(): - @renderer_gen - def test_renderer( - meta: RendererMeta, - x: str, + @renderer + async def test_renderer( + meta: RenderMeta, + fn: RenderFn[str], ): ... From b45880bd5ddaa056194f975f5164147a0f7e7b95 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 25 Jul 2023 12:17:09 -0400 Subject: [PATCH 15/64] Minimize the number of generic variables in the classes. Add helper class and verify that the render fn was called --- shiny/render/_render.py | 251 +++++++++++++++++++++---------------- shiny/session/_session.py | 24 ++-- tests/test_renderer_gen.py | 64 ++++++++-- 3 files changed, 207 insertions(+), 132 deletions(-) diff --git a/shiny/render/_render.py b/shiny/render/_render.py index 0522beb45..9be517f3f 100644 --- a/shiny/render/_render.py +++ b/shiny/render/_render.py @@ -18,7 +18,6 @@ # * `_value_fn` -> `_handler` # * `value: IT` -> `fn: RenderFn[IT]` -# TODO-barret; Require that `render_fn` is called __all__ = ( "text", @@ -78,13 +77,65 @@ T = TypeVar("T") -# Meta informatoin to give `hander()` some context +# ====================================================================================== +# Type definitions +# ====================================================================================== + + +RenderFnSync = Callable[[], IT] +# RenderFn == RenderFnAsync as UserFuncSync is wrapped into an async fn +RenderFnAsync = Callable[[], Awaitable[IT]] +RenderFn = RenderFnAsync[IT] +HandlerFn = Callable[Concatenate["RenderMeta", RenderFn[IT], P], Awaitable[OT]] + + +_RenderArgsSync = Tuple[RenderFnSync[IT], HandlerFn[IT, P, OT]] +_RenderArgsAsync = Tuple[RenderFnAsync[IT], HandlerFn[IT, P, OT]] +_RenderArgs = Union[_RenderArgsSync[IT, P, OT], _RenderArgsAsync[IT, P, OT]] + +RenderDecoSync = Callable[[RenderFnSync[IT]], "RendererSync[OT]"] +RenderDecoAsync = Callable[[RenderFnAsync[IT]], "RendererAsync[OT]"] +RenderDeco = Callable[ + [Union[RenderFnSync[IT], RenderFnAsync[IT]]], + Union["RendererSync[OT]", "RendererAsync[OT]"], +] + + +# ====================================================================================== +# Helper classes +# ====================================================================================== + + +# Meta information to give `hander()` some context class RenderMeta(TypedDict): is_async: bool session: Session name: str +class _CallCounter(Generic[IT]): + def assert_call_count(self, total_calls: int = 1): + if self._call_count != total_calls: + raise RuntimeError( + f"The total number of calls (`{self._call_count}`) to '{self._render_fn_name}' in the '{self._handler_fn_name}' handler did not equal `{total_calls}`." + ) + + def __init__( + self, + *, + render_fn: RenderFn[IT], + handler_fn: HandlerFn[IT, P, OT], + ): + self._call_count: int = 0 + self._render_fn = render_fn + self._render_fn_name = render_fn.__name__ + self._handler_fn_name = handler_fn.__name__ + + async def __call__(self) -> IT: + self._call_count += 1 + return await self._render_fn() + + # ====================================================================================== # Renderer / RendererSync / RendererAsync base class # ====================================================================================== @@ -156,16 +207,12 @@ def _set_metadata(self, session: Session, name: str) -> None: self._name: str = name -# Using a second class to help clarify that it is of a particular type -class RendererSync(Generic[IT, OT, P], Renderer[OT]): - @property - def is_async(self) -> bool: - return False - +# Include +class RendererRun(Renderer[OT]): def __init__( self, # Use single arg to minimize overlap with P.kwargs - _render_args: _RenderArgsSync[IT, P, OT], + _render_args: _RenderArgs[IT, P, OT], *args: P.args, **kwargs: P.kwargs, ) -> None: @@ -175,10 +222,6 @@ def __init__( # Unpack args render_fn, handler_fn = _render_args - if _utils.is_async_callable(render_fn): - raise TypeError( - self.__class__.__name__ + " requires a sync render function" - ) if not _utils.is_async_callable(handler_fn): raise TypeError( self.__class__.__name__ + " requires an async handler function" @@ -196,26 +239,57 @@ def __init__( self._args = args self._kwargs = kwargs - def __call__(self) -> OT: - return _utils.run_coro_sync(self._run()) - async def _run(self) -> OT: + render_fn_w_counter = _CallCounter( + render_fn=self._render_fn, + handler_fn=self._handler_fn, + ) ret = await self._handler_fn( # RendererMeta self.meta, # Callable[[], Awaitable[IT]] - self._render_fn, + render_fn_w_counter, # P *self._args, **self._kwargs, ) + render_fn_w_counter.assert_call_count(1) return ret +# Using a second class to help clarify that it is of a particular type +class RendererSync(RendererRun[OT]): + @property + def is_async(self) -> bool: + return False + + def __init__( + self, + # Use single arg to minimize overlap with P.kwargs + _render_args: _RenderArgsSync[IT, P, OT], + *args: P.args, + **kwargs: P.kwargs, + ) -> None: + render_fn = _render_args[0] + if _utils.is_async_callable(render_fn): + raise TypeError( + self.__class__.__name__ + " requires a synchronous render function" + ) + # super == RendererRun + super().__init__( + _render_args, + *args, + **kwargs, + ) + + def __call__(self) -> OT: + return _utils.run_coro_sync(self._run()) + + # The reason for having a separate RendererAsync class is because the __call__ # method is marked here as async; you can't have a single class where one method could # be either sync or async. -class RendererAsync(Generic[IT, OT, P], Renderer[OT]): +class RendererAsync(RendererRun[OT]): @property def is_async(self) -> bool: return True @@ -227,79 +301,23 @@ def __init__( *args: P.args, **kwargs: P.kwargs, ) -> None: - # `*args` must be in the `__init__` signature - # Make sure there no `args`! - _assert_no_args(args) - - # Unpack args - render_fn, handler_fn = _render_args - + render_fn = _render_args[0] if not _utils.is_async_callable(render_fn): raise TypeError( - self.__class__.__name__ + " requires an async render function" - ) - if not _utils.is_async_callable(handler_fn): - raise TypeError( - self.__class__.__name__ + " requires an async handler function" + self.__class__.__name__ + " requires an asynchronous render function" ) - # super == RendererAsync, Renderer + # super == RendererRun super().__init__( - name=render_fn.__name__, - doc=render_fn.__doc__, + _render_args, + *args, + **kwargs, ) - # Given we use `_utils.run_coro_sync(self._run())` to call our method, - # we can act as if `render_fn` and `handler_fn` are always async - self._render_fn = _utils.wrap_async(render_fn) - self._handler_fn = _utils.wrap_async(handler_fn) - self._args = args - self._kwargs = kwargs - async def __call__( # pyright: ignore[reportIncompatibleMethodOverride] self, ) -> OT: return await self._run() - async def _run(self) -> OT: - ret = await self._handler_fn( - # RendererMeta - self.meta, - # Callable[[], Awaitable[IT]] - self._render_fn, - # P - *self._args, - **self._kwargs, - ) - return ret - - -# ====================================================================================== -# Type definitions -# ====================================================================================== - - -UserFuncSync = Callable[[], IT] -UserFuncAsync = Callable[[], Awaitable[IT]] -UserFunc = Union[ - UserFuncSync[IT], - UserFuncAsync[IT], -] - -# RenderFn == UserFuncAsync as UserFuncSync is wrapped into an async fn -RenderFn = Callable[[], Awaitable[IT]] -HandlerFn = Callable[Concatenate[RenderMeta, RenderFn[IT], P], Awaitable[OT]] - -RenderDecoSync = Callable[[UserFuncSync[IT]], RendererSync[IT, OT, P]] -RenderDecoAsync = Callable[[UserFuncAsync[IT]], RendererAsync[IT, OT, P]] -RenderDeco = Callable[ - [Union[UserFuncSync[IT], UserFuncAsync[IT]]], - Union[RendererSync[IT, OT, P], RendererAsync[IT, OT, P]], -] - - -_RenderArgsSync = Tuple[UserFuncSync[IT], HandlerFn[IT, P, OT]] -_RenderArgsAsync = Tuple[UserFuncAsync[IT], HandlerFn[IT, P, OT]] - # ====================================================================================== # Restrict the value function @@ -335,11 +353,15 @@ def _assert_handler_fn(handler_fn: HandlerFn[IT, P, OT]) -> None: f"No variadic parameters (e.g. `*args`) can be supplied to `handler_fn=`. Received: `{param.name}`" ) if param.kind == inspect.Parameter.KEYWORD_ONLY: - # Do not allow for a kwarg to be named `_render_fn` + # Do not allow for a kwarg to be named `_render_fn` or `_render_args` if param.name == "_render_fn": raise ValueError( "In `handler_fn=`, parameters can not be named `_render_fn`" ) + if param.name == "_render_args": + raise ValueError( + "In `handler_fn=`, parameters can not be named `_render_args`" + ) # Make sure kwargs have default values if param.default is inspect.Parameter.empty: raise TypeError( @@ -347,6 +369,11 @@ def _assert_handler_fn(handler_fn: HandlerFn[IT, P, OT]) -> None: ) +# ====================================================================================== +# Renderer decorator +# ====================================================================================== + + def renderer( handler_fn: HandlerFn[IT, P, OT], ): @@ -358,21 +385,21 @@ def renderer( _assert_handler_fn(handler_fn) @overload - def renderer_decorator(*args: P.args, **kwargs: P.kwargs) -> RenderDeco[IT, OT, P]: + def renderer_decorator(*args: P.args, **kwargs: P.kwargs) -> RenderDeco[IT, OT]: ... @overload # RenderDecoSync[IT, OT, P] def renderer_decorator( - _render_fn: UserFuncSync[IT], - ) -> RendererSync[IT, OT, P]: + _render_fn: RenderFnSync[IT], + ) -> RendererSync[OT]: ... @overload # RenderDecoAsync[IT, OT, P] def renderer_decorator( - _render_fn: UserFuncAsync[IT], - ) -> RendererAsync[IT, OT, P]: + _render_fn: RenderFnAsync[IT], + ) -> RendererAsync[OT]: ... # # If we use `wraps()`, the overloads are lost. @@ -381,32 +408,32 @@ def renderer_decorator( # Ignoring the type issue on the next line of code as the overloads for # `renderer_deco` are not consistent with the function definition. # Motivation: - # * https://peps.python.org/pep-0612/ does allow for prepending an arg - # (`_render_fn`). - # * However, the overload is not happy when both a positional arg (`_render_fn`) is - # dropped and the variadic args (`*args`) are kept. + # * https://peps.python.org/pep-0612/ does allow for prepending an arg (e.g. + # `_render_fn`). + # * However, the overload is not happy when both a positional arg (e.g. + # `_render_fn`) is dropped and the variadic args (`*args`) are kept. # * The variadic args CAN NOT be dropped as PEP612 states that both components of # the `ParamSpec` must be used in the same function signature. # * By making assertions on `P.args` to only allow for `*`, we _can_ make overloads - # that use either the single positional arg (`_render_fn`) or - # the `P.kwargs` (as `P.args` == `*`) + # that use either the single positional arg (e.g. `_render_fn`) or the `P.kwargs` + # (as `P.args` == `*`) def renderer_decorator( # type: ignore[reportGeneralTypeIssues] - _render_fn: Optional[UserFuncSync[IT] | UserFuncAsync[IT]] = None, + _render_fn: Optional[RenderFnSync[IT] | RenderFnAsync[IT]] = None, *args: P.args, # Equivalent to `*` after assertions in `_assert_handler_fn()` **kwargs: P.kwargs, ) -> ( - RenderDecoSync[IT, OT, P] - | RenderDecoAsync[IT, OT, P] - | RendererSync[IT, OT, P] - | RendererAsync[IT, OT, P] + RenderDecoSync[IT, OT] + | RenderDecoAsync[IT, OT] + | RendererSync[OT] + | RendererAsync[OT] ): # `args` **must** be in `renderer_decorator` definition. # Make sure there no `args`! _assert_no_args(args) def render_fn_sync( - fn_sync: UserFuncSync[IT], - ) -> RendererSync[IT, OT, P]: + fn_sync: RenderFnSync[IT], + ) -> RendererSync[OT]: return RendererSync( (fn_sync, handler_fn), *args, @@ -414,8 +441,8 @@ def render_fn_sync( ) def render_fn_async( - fn_async: UserFuncAsync[IT], - ) -> RendererAsync[IT, OT, P]: + fn_async: RenderFnAsync[IT], + ) -> RendererAsync[OT]: return RendererAsync( (fn_async, handler_fn), *args, @@ -424,24 +451,24 @@ def render_fn_async( @overload def as_render_fn( - fn: UserFuncSync[IT], - ) -> RendererSync[IT, OT, P]: + fn: RenderFnSync[IT], + ) -> RendererSync[OT]: ... @overload def as_render_fn( - fn: UserFuncAsync[IT], - ) -> RendererAsync[IT, OT, P]: + fn: RenderFnAsync[IT], + ) -> RendererAsync[OT]: ... def as_render_fn( - fn: UserFuncSync[IT] | UserFuncAsync[IT], - ) -> RendererSync[IT, OT, P] | RendererAsync[IT, OT, P]: + fn: RenderFnSync[IT] | RenderFnAsync[IT], + ) -> RendererSync[OT] | RendererAsync[OT]: if _utils.is_async_callable(fn): return render_fn_async(fn) else: - # Is not not `UserFuncAsync[IT]`. Cast `wrapper_fn` - fn = cast(UserFuncSync[IT], fn) + # Is not not `RenderFnAsync[IT]`. Cast `wrapper_fn` + fn = cast(RenderFnSync[IT], fn) return render_fn_sync(fn) if _render_fn is None: @@ -494,11 +521,13 @@ async def text( # @renderer -# async def async_text(meta: RendererMeta, value: str | None) -> str | None: +# async def async_text( +# meta: RenderMeta, fn: RenderFn[str | None], *, extra_arg: str = "42" +# ) -> str | None: # """ # My docs go here! # """ -# return str(value) +# return str(await fn()) # text diff --git a/shiny/session/_session.py b/shiny/session/_session.py index 77592d334..81f7c22f8 100644 --- a/shiny/session/_session.py +++ b/shiny/session/_session.py @@ -955,7 +955,7 @@ def __init__( self._suspend_when_hidden = suspend_when_hidden @overload - def __call__(self, renderer: Renderer[Any]) -> None: + def __call__(self, renderer_fn: Renderer[Any]) -> None: ... @overload @@ -971,7 +971,7 @@ def __call__( def __call__( self, - renderer: Optional[Renderer[OT]] = None, + renderer_fn: Optional[Renderer[OT]] = None, *, id: Optional[str] = None, suspend_when_hidden: bool = True, @@ -986,18 +986,18 @@ def __call__( ) id = name - def set_renderer(renderer: Renderer[OT]) -> None: + def set_renderer(renderer_fn: Renderer[OT]) -> None: # Get the (possibly namespaced) output id - output_name = self._ns(id or renderer.__name__) + output_name = self._ns(id or renderer_fn.__name__) - if not isinstance(renderer, Renderer): + if not isinstance(renderer_fn, Renderer): raise TypeError( "`@output` must be applied to a `@render.xx` function.\n" + "In other words, `@output` must be above `@render.xx`." ) - # renderer is a Renderer object. Give it a bit of metadata. - renderer._set_metadata(self._session, output_name) + # renderer_fn is a Renderer object. Give it a bit of metadata. + renderer_fn._set_metadata(self._session, output_name) if output_name in self._effects: self._effects[output_name].destroy() @@ -1015,10 +1015,10 @@ async def output_obs(): message: dict[str, Optional[OT]] = {} try: - if _utils.is_async_callable(renderer): - message[output_name] = await renderer() + if _utils.is_async_callable(renderer_fn): + message[output_name] = await renderer_fn() else: - message[output_name] = renderer() + message[output_name] = renderer_fn() except SilentCancelOutputException: return except SilentException: @@ -1059,10 +1059,10 @@ async def output_obs(): return None - if renderer is None: + if renderer_fn is None: return set_renderer else: - return set_renderer(renderer) + return set_renderer(renderer_fn) def _manage_hidden(self) -> None: "Suspends execution of hidden outputs and resumes execution of visible outputs." diff --git a/tests/test_renderer_gen.py b/tests/test_renderer_gen.py index c68c9f11c..6f9b7f84a 100644 --- a/tests/test_renderer_gen.py +++ b/tests/test_renderer_gen.py @@ -1,7 +1,7 @@ from shiny.render._render import RenderFn, RenderMeta, renderer -def test_renderer_gen_name_and_docs_are_copied(): +def test_renderer_name_and_docs_are_copied(): @renderer async def my_handler(meta: RenderMeta, fn: RenderFn[str]) -> str: "Test docs go here" @@ -11,7 +11,7 @@ async def my_handler(meta: RenderMeta, fn: RenderFn[str]) -> str: assert my_handler.__name__ == "my_handler" -def test_renderer_gen_works(): +def test_renderer_works(): # No args works @renderer async def test_renderer( @@ -21,7 +21,7 @@ async def test_renderer( ... -def test_renderer_gen_kwargs_are_allowed(): +def test_renderer_kwargs_are_allowed(): # Test that kwargs can be allowed @renderer async def test_renderer( @@ -33,7 +33,7 @@ async def test_renderer( ... -def test_renderer_gen_with_pass_through_kwargs(): +def test_renderer_with_pass_through_kwargs(): # No args works @renderer async def test_renderer( @@ -46,7 +46,7 @@ async def test_renderer( ... -def test_renderer_gen_limits_positional_arg_count(): +def test_renderer_limits_positional_arg_count(): try: @renderer @@ -62,7 +62,7 @@ async def test_renderer( assert "more than 2 positional" in str(e) -def test_renderer_gen_does_not_allow_args(): +def test_renderer_does_not_allow_args(): try: @renderer @@ -79,7 +79,7 @@ async def test_renderer( assert "No variadic parameters" in str(e) -def test_renderer_gen_kwargs_have_defaults(): +def test_renderer_kwargs_have_defaults(): try: @renderer @@ -97,7 +97,7 @@ async def test_renderer( assert "did not have a default value" in str(e) -def test_renderer_gen_kwargs_can_not_be_name_render_fn(): +def test_renderer_kwargs_can_not_be_name_render_fn(): try: @renderer @@ -115,7 +115,7 @@ async def test_renderer( assert "parameters can not be named `_render_fn`" in str(e) -def test_renderer_gen_result_does_not_allow_args(): +def test_renderer_result_does_not_allow_args(): @renderer async def test_renderer( meta: RenderMeta, @@ -146,3 +146,49 @@ async def render_fn_async(*args: str): )(render_fn_async) except RuntimeError as e: assert "`args` should not be supplied" in str(e) + + +def test_renderer_makes_calls_render_fn_once(): + @renderer + async def test_renderer_no_calls( + meta: RenderMeta, + fn: RenderFn[str], + ): + # Does not call `fn` + return "Not 42" + + @renderer + async def test_renderer_multiple_calls( + meta: RenderMeta, + fn: RenderFn[str], + ): + # Calls `fn` > 1 times + return f"{await fn()} - {await fn()}" + + # Test that args can **not** be supplied + def render_fn(): + return "42" + + # try: + renderer_fn_none = test_renderer_no_calls(render_fn) + renderer_fn_none._set_metadata(None, "test_out") # type: ignore + + try: + renderer_fn_none() + raise RuntimeError() + except RuntimeError as e: + assert ( + str(e) + == "The total number of calls (`0`) to 'render_fn' in the 'test_renderer_no_calls' handler did not equal `1`." + ) + + renderer_fn_multiple = test_renderer_multiple_calls(render_fn) + renderer_fn_multiple._set_metadata(None, "test_out") # type: ignore + try: + renderer_fn_multiple() + raise RuntimeError() + except RuntimeError as e: + assert ( + str(e) + == "The total number of calls (`2`) to 'render_fn' in the 'test_renderer_multiple_calls' handler did not equal `1`." + ) From d3a3b20771164a04349171f751c637ebf98b6340 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 26 Jul 2023 10:01:36 -0400 Subject: [PATCH 16/64] temp commit to save thoughts before destroying them. Could not get overloaded method renaming to work --- shiny/render/_render.py | 153 +++++++++++++++++++++++++++++++--------- 1 file changed, 118 insertions(+), 35 deletions(-) diff --git a/shiny/render/_render.py b/shiny/render/_render.py index 9be517f3f..5d1ff7df1 100644 --- a/shiny/render/_render.py +++ b/shiny/render/_render.py @@ -28,6 +28,7 @@ ) import base64 +import functools import inspect import os import sys @@ -384,26 +385,40 @@ def renderer( """ _assert_handler_fn(handler_fn) - @overload - def renderer_decorator(*args: P.args, **kwargs: P.kwargs) -> RenderDeco[IT, OT]: - ... + # @overload + # def renderer_decorator(*args: P.args, **kwargs: P.kwargs) -> RenderDeco[IT, OT]: + # ... + + # @overload + # # RenderDecoSync[IT, OT, P] + # def renderer_decorator( + # _render_fn: RenderFnSync[IT], + # ) -> RendererSync[OT]: + # ... + + # @overload + # # RenderDecoAsync[IT, OT, P] + # def renderer_decorator( + # _render_fn: RenderFnAsync[IT], + # ) -> RendererAsync[OT]: + # ... + + class Barret(Protocol): + def __init__(self) -> None: + super().__init__() + self.__class__.__name__ = "Bearit" - @overload - # RenderDecoSync[IT, OT, P] - def renderer_decorator( - _render_fn: RenderFnSync[IT], - ) -> RendererSync[OT]: - ... + @overload + def __call__(self, *args: P.args, **kwargs: P.kwargs) -> RenderDeco[IT, OT]: + ... - @overload - # RenderDecoAsync[IT, OT, P] - def renderer_decorator( - _render_fn: RenderFnAsync[IT], - ) -> RendererAsync[OT]: - ... + @overload + def __call__(self, _render_fn: RenderFnSync[IT]) -> RendererSync[OT]: + ... - # # If we use `wraps()`, the overloads are lost. - # @functools.wraps(handler_fn) + @overload + def __call__(self, _render_fn: RenderFnAsync[IT]) -> RendererAsync[OT]: + ... # Ignoring the type issue on the next line of code as the overloads for # `renderer_deco` are not consistent with the function definition. @@ -417,20 +432,41 @@ def renderer_decorator( # * By making assertions on `P.args` to only allow for `*`, we _can_ make overloads # that use either the single positional arg (e.g. `_render_fn`) or the `P.kwargs` # (as `P.args` == `*`) + # # If we use `wraps()`, the overloads are lost. + @functools.wraps(handler_fn) def renderer_decorator( # type: ignore[reportGeneralTypeIssues] _render_fn: Optional[RenderFnSync[IT] | RenderFnAsync[IT]] = None, *args: P.args, # Equivalent to `*` after assertions in `_assert_handler_fn()` **kwargs: P.kwargs, - ) -> ( - RenderDecoSync[IT, OT] - | RenderDecoAsync[IT, OT] - | RendererSync[OT] - | RendererAsync[OT] ): + # -> ( + # RenderDecoSync[IT, OT] + # | RenderDecoAsync[IT, OT] + # | RendererSync[OT] + # | RendererAsync[OT] + # ): # `args` **must** be in `renderer_decorator` definition. # Make sure there no `args`! _assert_no_args(args) + # def barret(): + # return + + # barret.__name__ = "bearit" + # barret + + # class Barret: + # def __init__(self) -> None: + # self.__name__ = "Bearit" + # self.__class__.__name__ = "BearitClass" + # pass + + # def __call__(self) -> None: + # return + + # b = Barret() + # b + def render_fn_sync( fn_sync: RenderFnSync[IT], ) -> RendererSync[OT]: @@ -475,13 +511,42 @@ def as_render_fn( return as_render_fn return as_render_fn(_render_fn) - # Copy over name an docs - renderer_decorator.__doc__ = handler_fn.__doc__ - renderer_decorator.__name__ = handler_fn.__name__ + # r_overloads = typing.get_overloads(renderer_decorator) + # print(list(r_overloads)) + # for r_overload in r_overloads: + # print(r_overload.__builtins__) + # # print(r_overload.__name__, r_overload.__doc__) + # # r_overload.__name__ = handler_fn.__name__ + # # r_overload.__doc__ = handler_fn.__doc__ + # # print(r_overload.__name__, r_overload.__doc__) + # # import pdb + + # pdb.set_trace() + + # import inspect + + # curframe = inspect.currentframe() + # if curframe: + # curframe.f_locals["bearit"] = renderer_decorator + # print(curframe, curframe.f_locals) + # return curframe.f_locals["bearit"] + + # # Copy over name an docs + # renderer_decorator.__doc__ = handler_fn.__doc__ + # renderer_decorator.__name__ = handler_fn.__name__ + # # lie and give it a pretty qualifying name + # renderer_decorator.__qualname__ = f"renderer..{handler_fn.__name__}" + # # TODO-barret; Fix name of decorated function. Hovering over method name does not work # ren_func = getattr(renderer_decorator, "__func__", renderer_decorator) # ren_func.__name__ = handler_fn.__name__ + # ret = cast(Barret, renderer_decorator) + # ret.__name__ = handler_fn.__name__ + # ret.__doc__ = handler_fn.__doc__ + + # return ret + return renderer_decorator @@ -520,18 +585,36 @@ async def text( return str(value) -# @renderer -# async def async_text( -# meta: RenderMeta, fn: RenderFn[str | None], *, extra_arg: str = "42" -# ) -> str | None: -# """ -# My docs go here! -# """ -# return str(await fn()) +@renderer +async def async_text( + meta: RenderMeta, fn: RenderFn[str | None], *, extra_arg: str = "42" +) -> str | None: + """ + My docs go here! + """ + return str(await fn()) + + +async_text +text + + +# def barret_wrapper(): +# def barret(): +# return None + +# barret.__name__ = "bearit" +# return barret -# text -# async_text +# b = barret_wrapper() +# print(b) +# print(text) +# print(text.__name__) +# print(text.__qualname__) +# print(text.__annotations__) +# print(dir(text)) +# print(text.__builtins__) # ====================================================================================== From 46c1cc9d125f0f330e6e6c3dbd3fc2f23f724f05 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Fri, 28 Jul 2023 08:59:15 -0400 Subject: [PATCH 17/64] Another temp commit in messy state. Using classes as return type to hide --- shiny/render/_render.py | 303 ++++++++++++++++++++++++++++++++++------ 1 file changed, 264 insertions(+), 39 deletions(-) diff --git a/shiny/render/_render.py b/shiny/render/_render.py index 5d1ff7df1..e0ec51f30 100644 --- a/shiny/render/_render.py +++ b/shiny/render/_render.py @@ -8,6 +8,11 @@ # * TODO-barret; Make a helper method to return all types (and function?) that could be used to make the overload signatures manually # TODO-barret; Changelog that RenderFunction no longer exists. # TODO-barret; Should `Renderer` be exported? +# TODO-barret; Test for `"`@reactive.event()` must be applied before `@render.xx` .\n"`` +# TODO-barret; Test for `"`@output` must be applied to a `@render.xx` function.\n"` +# TODO-barret; Rename `RendererDecorator` to `Renderer`?; Rename `Renderer` to something else +# TODO-barret; Add in `IT` to RendererDecorator to enforce return type + # W/ Rich: # The function is called a "render handler" as it handles the "render function" and returns a rendered result. @@ -28,7 +33,6 @@ ) import base64 -import functools import inspect import os import sys @@ -254,6 +258,7 @@ async def _run(self) -> OT: *self._args, **self._kwargs, ) + # TODO-future; Should we assert the call count? Should we check against non-missing values? render_fn_w_counter.assert_call_count(1) return ret @@ -375,51 +380,105 @@ def _assert_handler_fn(handler_fn: HandlerFn[IT, P, OT]) -> None: # ====================================================================================== -def renderer( +def renderer_components( handler_fn: HandlerFn[IT, P, OT], -): +) -> RendererComponents[IT, OT, P]: """\ Renderer generator + TODO-barret; Docs go here! + """ + return renderer_components(handler_fn) + + +class RendererTypes(Generic[IT, OT]): + arg_render_fn_sync: RenderFnSync[IT] + arg_render_fn_async: RenderFnAsync[IT] + return_renderer_sync: typing.Type[RendererSync[OT]] + return_renderer_async: typing.Type[RendererAsync[OT]] + return_renderer_decorator: RenderDeco[IT, OT] + + def __init__(self): + self.arg_render_fn_async = RenderFnAsync[IT] + self.arg_render_fn_sync = RenderFnSync[IT] + self.return_renderer_sync = RendererSync[OT] + self.return_renderer_async = RendererAsync[OT] + self.return_renderer_decorator = RenderDeco[IT, OT] + + +class RendererComponents(Generic[IT, OT, P]): + function: RenderDeco[IT, OT] | Callable[P, RenderDeco[IT, OT]] + types: RendererTypes[IT, OT] + + +class RendererDecorator(Generic[IT, OT]): + # @overload + # def __call__(self, _render_fn: RenderFnSync[IT]) -> RendererSync[OT]: + # ... + + # @overload + # def __call__(self, _render_fn: RenderFnAsync[IT]) -> RendererAsync[OT]: + # ... + + def __call__( + self, _render_fn: RenderFnSync[IT] | RenderFnAsync[IT] + ) -> RendererSync[OT] | RendererAsync[OT]: + ... + + +class RendererDecoSync(Generic[OT]): + ... + + +class RendererDecoAsync(Generic[OT]): + ... + + +def renderer( + handler_fn: HandlerFn[IT, P, OT], +) -> Union[ + Callable[ + Concatenate[RenderFnSync[IT] | RenderFnAsync[IT], P], RendererDecorator[IT, OT] + ], + RendererDecorator[IT, OT], +]: + # ): + """\ + Renderer components generator + TODO-barret; Docs go here! """ _assert_handler_fn(handler_fn) # @overload - # def renderer_decorator(*args: P.args, **kwargs: P.kwargs) -> RenderDeco[IT, OT]: + # # @functools.wraps( + # # handler_fn, assigned=("__module__", "__name__", "__qualname__", "__doc__") + # # ) + # def _(*args: P.args, **kwargs: P.kwargs) -> RenderDeco[IT, OT]: # ... # @overload - # # RenderDecoSync[IT, OT, P] - # def renderer_decorator( + # # @functools.wraps( + # # handler_fn, assigned=("__module__", "__name__", "__qualname__", "__doc__") + # # ) + # # RenderDecoSync[IT, OT] + # def _( # _render_fn: RenderFnSync[IT], # ) -> RendererSync[OT]: # ... # @overload - # # RenderDecoAsync[IT, OT, P] - # def renderer_decorator( + # # @functools.wraps( + # # handler_fn, assigned=("__module__", "__name__", "__qualname__", "__doc__") + # # ) + # # RenderDecoAsync[IT, OT] + # # RendererDecoAsync[OT] + # # RendererDecoAsync[OT] + # def _( # _render_fn: RenderFnAsync[IT], # ) -> RendererAsync[OT]: # ... - class Barret(Protocol): - def __init__(self) -> None: - super().__init__() - self.__class__.__name__ = "Bearit" - - @overload - def __call__(self, *args: P.args, **kwargs: P.kwargs) -> RenderDeco[IT, OT]: - ... - - @overload - def __call__(self, _render_fn: RenderFnSync[IT]) -> RendererSync[OT]: - ... - - @overload - def __call__(self, _render_fn: RenderFnAsync[IT]) -> RendererAsync[OT]: - ... - # Ignoring the type issue on the next line of code as the overloads for # `renderer_deco` are not consistent with the function definition. # Motivation: @@ -432,11 +491,13 @@ def __call__(self, _render_fn: RenderFnAsync[IT]) -> RendererAsync[OT]: # * By making assertions on `P.args` to only allow for `*`, we _can_ make overloads # that use either the single positional arg (e.g. `_render_fn`) or the `P.kwargs` # (as `P.args` == `*`) - # # If we use `wraps()`, the overloads are lost. - @functools.wraps(handler_fn) - def renderer_decorator( # type: ignore[reportGeneralTypeIssues] + # @functools.wraps( + # handler_fn, assigned=("__module__", "__name__", "__qualname__", "__doc__") + # ) + def _( # type: ignore[reportGeneralTypeIssues] _render_fn: Optional[RenderFnSync[IT] | RenderFnAsync[IT]] = None, *args: P.args, # Equivalent to `*` after assertions in `_assert_handler_fn()` + # *, **kwargs: P.kwargs, ): # -> ( @@ -514,11 +575,28 @@ def as_render_fn( # r_overloads = typing.get_overloads(renderer_decorator) # print(list(r_overloads)) # for r_overload in r_overloads: - # print(r_overload.__builtins__) - # # print(r_overload.__name__, r_overload.__doc__) - # # r_overload.__name__ = handler_fn.__name__ - # # r_overload.__doc__ = handler_fn.__doc__ - # # print(r_overload.__name__, r_overload.__doc__) + # for key in ( + # "__module__", + # "__name__", + # "__qualname__", + # "__doc__", + # # "__annotations__", + # ): + # print(key, getattr(r_overload, key), getattr(handler_fn, key)) + # # # print(r_overload.__builtins__) + # print( + # r_overload.__name__, + # r_overload.__qualname__, + # # r_overload.__dir__(), + # # r_overload.__hash__(), + # # r_overload.__str__(), + # # r_overload.__repr__(), + # # r_overload.__annotations__, + # # r_overload.__builtins__, + # ) + # r_overload.__name__ = handler_fn.__name__ + # r_overload.__doc__ = handler_fn.__doc__ + # print(r_overload.__name__, r_overload.__doc__) # # import pdb # pdb.set_trace() @@ -531,11 +609,14 @@ def as_render_fn( # print(curframe, curframe.f_locals) # return curframe.f_locals["bearit"] - # # Copy over name an docs + # Copy over name an docs # renderer_decorator.__doc__ = handler_fn.__doc__ # renderer_decorator.__name__ = handler_fn.__name__ - # # lie and give it a pretty qualifying name - # renderer_decorator.__qualname__ = f"renderer..{handler_fn.__name__}" + # # Lie and give it a pretty qualifying name + # renderer_decorator.__qualname__ = renderer_decorator.__qualname__.replace( + # "renderer_decorator", + # handler_fn.__name__, + # ) # # TODO-barret; Fix name of decorated function. Hovering over method name does not work # ren_func = getattr(renderer_decorator, "__func__", renderer_decorator) @@ -547,9 +628,126 @@ def as_render_fn( # return ret + # renderer_decorator.barret = 43 # type: ignore + # b = f"{43}" + # key = "barret" + # setattr(renderer_decorator, key, f"{b}") # type: ignore + + # return renderer_decorator + + # renderer_decorator.__dict__ = { + # "renderer_types": { + # "arg_render_fn_sync": RenderFnSync[IT], + # "arg_render_fn_async": RenderFnAsync[IT], + # "return_renderer_sync": RendererSync[OT], + # "return_renderer_async": RendererAsync[OT], + # "return_renderer_decorator": RenderDeco[IT, OT], + # } + # } + # renderer_decorator["renderer_types"]["arg_render_fn_sync"] + # _.__name__ = + + return _ + + ret_c = RendererComponents[IT, OT, P]() + ret_c.function = renderer_decorator # type: ignore + ret_c.types = RendererTypes[IT, OT]() + ret_c.types.arg_render_fn_sync = RenderFnSync[IT] + ret_c.types.arg_render_fn_async = RenderFnAsync[IT] + ret_c.types.return_renderer_sync = RendererSync[OT] + ret_c.types.return_renderer_async = RendererAsync[OT] + ret_c.types.return_renderer_decorator = RenderDeco[IT, OT] + return ret_c + + return { + "function": renderer_decorator, + "types": { + "arg_render_fn_sync": RenderFnSync[IT], + "arg_render_fn_async": RenderFnAsync[IT], + "return_renderer_sync": RendererSync[OT], + "return_renderer_async": RendererAsync[OT], + "return_renderer_decorator": RenderDeco[IT, OT], + }, + } + + # b = "barret" + # my_fn = type("B", (), {"barret": 42}) + # return my_fn + # my_fn + + class RendererDecorator: + @property + def types(self): + return { + "return_renderer_decorator": RenderDeco[IT, OT], + "arg_render_fn_sync": RenderFnSync[IT], + "return_renderer_sync": RendererSync[OT], + "arg_render_fn_async": RenderFnAsync[IT], + "return_renderer_async": RendererAsync[OT], + } + + def __init__(self) -> None: + # self.__name__ = handler_fn.__name__ + # self.__func__.__doc__ = handler_fn.__doc__ + ... + + # @functools.wraps(handler_fn) + @overload + def __call__(self, _render_fn: RenderFnSync[IT]) -> RendererSync[OT]: + ... + + def __call__( + self, + _render_fn: Optional[RenderFnSync[IT] | RenderFnAsync[IT]] = None, + *args: P.args, # Equivalent to `*` after assertions in `_assert_handler_fn()` + **kwargs: P.kwargs, + ): + return renderer_decorator(_render_fn, *args, **kwargs) + + ret = RendererDecorator() + # ret.__class__.__doc__ = handler_fn.__doc__ + # ret.__doc__ = handler_fn.__doc__ + + return ret return renderer_decorator +P2 = ParamSpec("P2") +T2 = TypeVar("T2") + + +def barret_wraps2(wrapper: Callable[..., typing.Any]): + """An implementation of functools.wraps.""" + + def decorator(func: Callable[P2, T2]) -> Callable[P2, T2]: + # func.__doc__ = wrapper.__doc__ + print( + func.__name__, + wrapper.__name__, + func.__qualname__, + wrapper.__qualname__, + func.__qualname__.replace( + "renderer_decorator", + wrapper.__name__, + ), + func.__code__.co_firstlineno, + wrapper.__code__.co_firstlineno, + ) + func.__name__ = wrapper.__name__ + func.__doc__ = wrapper.__doc__ + func.__module__ = wrapper.__module__ + + # # Do not adjust qualname as it is used in the registry for the overloads + # # https://github.com/python/cpython/blob/36208b5/Lib/typing.py#L2607 + # func.__qualname__ = func.__qualname__.replace( + # "renderer_decorator", + # wrapper.__name__, + # ) + return func + + return decorator + + # ====================================================================================== # RenderText # ====================================================================================== @@ -586,8 +784,11 @@ async def text( @renderer -async def async_text( - meta: RenderMeta, fn: RenderFn[str | None], *, extra_arg: str = "42" +async def barret2( + meta: RenderMeta, + fn: RenderFn[str], + *, + extra_arg: str = "42", ) -> str | None: """ My docs go here! @@ -595,10 +796,34 @@ async def async_text( return str(await fn()) -async_text +# def renderer_types(renderer_decorator: RenderDeco[IT, OT]): +# return renderer_decorator["types"] + + +barret2(e) text +# def createClass(classname: str, attributes: dict[str, str | int]): +# def init_fn(self: object, arg1: str, arg2: int) -> None: +# setattr(self, "args", (arg1, arg2)) + +# return type( +# classname, +# (object,), +# { +# "__init__": init_fn, +# "args": attributes, +# }, +# ) + + +# CarVal = createClass("Car", {"name": "", "age": 0}) + +# mycar = CarVal("Audi R8", 3) +# mycar.args + + # def barret_wrapper(): # def barret(): # return None From 7bf726799e987aea2c71795e3c3ac48992e12511 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Fri, 28 Jul 2023 10:57:36 -0400 Subject: [PATCH 18/64] Using helper method approach to function and types for the user to make overloads manually Before, one of the overloads was being made with input type `P`. This expands to `(*P.args, **P.kwargs) -> ...`. Given our restrictions, `P.args` is equal to `*` and therefore is not in the function signature. However, we need `*` to be there to allow for `_fn` to be passed in the other overloads. In the case of `text` renderer, the function was being defined as a `Callable` whose args where `P`. This evaluated to no arguments. As a decorator, it needs to accept an argument of the function. This is incompatible. However, with overloads, the overloaded functions can not really agree with each other as long as they agree with the base method. This allows us to "define" a dedorator who does not take an argument. I find the function name to be too important to give up. Pursuing returning types and the renderer function for users to make overloads manually. --- e2e/server/renderer_gen/app.py | 4 +- shiny/render/__init__.py | 2 +- shiny/render/_dataframe.py | 4 +- shiny/render/_render.py | 209 +++++++++++++++++++-------------- tests/test_renderer_gen.py | 24 ++-- 5 files changed, 141 insertions(+), 102 deletions(-) diff --git a/e2e/server/renderer_gen/app.py b/e2e/server/renderer_gen/app.py index 203d046ad..bbc8f299b 100644 --- a/e2e/server/renderer_gen/app.py +++ b/e2e/server/renderer_gen/app.py @@ -5,13 +5,13 @@ from typing import Optional from shiny import App, Inputs, Outputs, Session, ui -from shiny.render._render import RenderFn, RenderMeta, renderer +from shiny.render._render import RenderFnAsync, RenderMeta, renderer @renderer async def render_test_text( meta: RenderMeta, - fn: RenderFn[str | None], + fn: RenderFnAsync[str | None], *, extra_txt: Optional[str] = None, ) -> str | None: diff --git a/shiny/render/__init__.py b/shiny/render/__init__.py index d08a8f73e..2628c06f1 100644 --- a/shiny/render/__init__.py +++ b/shiny/render/__init__.py @@ -5,7 +5,7 @@ from ._render import ( # noqa: F401 # Import these values, but do not give autocomplete hints for `shiny.render.FOO` RenderMeta as RenderMeta, - RenderFn as RenderFn, + RenderFnAsync as RenderFnAsync, # Renderer as Renderer, # RendererSync as RendererSync, # RendererAsync as RendererAsync, diff --git a/shiny/render/_dataframe.py b/shiny/render/_dataframe.py index c3b640a92..a5f134bb3 100644 --- a/shiny/render/_dataframe.py +++ b/shiny/render/_dataframe.py @@ -5,7 +5,7 @@ from typing import TYPE_CHECKING, Any, Literal, Protocol, Union, cast, runtime_checkable from .._docstring import add_example -from . import RenderFn, RenderMeta, renderer +from . import RenderFnAsync, RenderMeta, renderer if TYPE_CHECKING: import pandas as pd @@ -199,7 +199,7 @@ def to_payload(self) -> object: @renderer async def data_frame( meta: RenderMeta, - fn: RenderFn[DataFrameResult | None], + fn: RenderFnAsync[DataFrameResult | None], ) -> object | None: """ Reactively render a Pandas data frame object (or similar) as a basic HTML table. diff --git a/shiny/render/_render.py b/shiny/render/_render.py index e0ec51f30..bcb5bf935 100644 --- a/shiny/render/_render.py +++ b/shiny/render/_render.py @@ -87,11 +87,20 @@ # ====================================================================================== +# class RenderFnSync(Generic[IT]): +# def __call__(self) -> IT: +# ... + + +# class RenderFnAsync(Generic[IT]): +# async def __call__(self) -> IT: +# ... + + RenderFnSync = Callable[[], IT] -# RenderFn == RenderFnAsync as UserFuncSync is wrapped into an async fn RenderFnAsync = Callable[[], Awaitable[IT]] -RenderFn = RenderFnAsync[IT] -HandlerFn = Callable[Concatenate["RenderMeta", RenderFn[IT], P], Awaitable[OT]] +RenderFn = RenderFnSync[IT] | RenderFnAsync[IT] +HandlerFn = Callable[Concatenate["RenderMeta", RenderFnAsync[IT], P], Awaitable[OT]] _RenderArgsSync = Tuple[RenderFnSync[IT], HandlerFn[IT, P, OT]] @@ -128,7 +137,7 @@ def assert_call_count(self, total_calls: int = 1): def __init__( self, *, - render_fn: RenderFn[IT], + render_fn: RenderFnAsync[IT], handler_fn: HandlerFn[IT, P, OT], ): self._call_count: int = 0 @@ -392,23 +401,56 @@ def renderer_components( class RendererTypes(Generic[IT, OT]): - arg_render_fn_sync: RenderFnSync[IT] - arg_render_fn_async: RenderFnAsync[IT] - return_renderer_sync: typing.Type[RendererSync[OT]] - return_renderer_async: typing.Type[RendererAsync[OT]] - return_renderer_decorator: RenderDeco[IT, OT] - - def __init__(self): - self.arg_render_fn_async = RenderFnAsync[IT] - self.arg_render_fn_sync = RenderFnSync[IT] - self.return_renderer_sync = RendererSync[OT] - self.return_renderer_async = RendererAsync[OT] - self.return_renderer_decorator = RenderDeco[IT, OT] + # arg_render_fn: typing.Type[RenderFnSync[IT] | RenderFnAsync[IT]] + arg_render_fn: typing.Type[RenderFn[IT]] + # arg_render_fn: typing.Type[RenderFnSync[IT] | RenderFnAsync[IT]] + + # arg_render_fn_sync: typing.Type[RenderFnSync[IT]] + # arg_render_fn_async: typing.Type[RenderFnAsync[IT]] + # return_renderer: typing.Type[RendererSync[OT] | RendererAsync[OT]] + return_renderer: typing.Type[Renderer[OT]] + # return_renderer_sync: typing.Type[RendererSync[OT]] + # return_renderer_async: typing.Type[RendererAsync[OT]] + # return_renderer_decorator: RenderDeco[IT, OT] + + # def __init__(self): + # self.arg_render_fn_async = RenderFnAsync[IT] + # self.arg_render_fn_sync = RenderFnSync[IT] + # self.return_renderer_sync = RendererSync[OT] + # self.return_renderer_async = RendererAsync[OT] + # self.return_renderer_decorator = RenderDeco[IT, OT] class RendererComponents(Generic[IT, OT, P]): - function: RenderDeco[IT, OT] | Callable[P, RenderDeco[IT, OT]] - types: RendererTypes[IT, OT] + # # function: RenderDeco[IT, OT] | Callable[P, RenderDeco[IT, OT]] + # renderer: Callable[ + # Concatenate[ + # Optional[RenderFn[IT]], + # P, + # ], + # # RendererSync[OT] | RendererAsync[OT], + # Renderer[OT], + # ] + # type_fn: RenderFn[IT] + # type_renderer: typing.Type[Renderer[OT]] + + # types: RendererTypes[IT, OT] + def __init__( + self, + renderer: Callable[ + Concatenate[ + Optional[RenderFn[IT]], + P, + ], + # RendererSync[OT] | RendererAsync[OT], + Renderer[OT], + ], + ) -> None: + super().__init__() + self.type_fn = RenderFn[IT] + self.type_renderer = Renderer[OT] + + self.renderer = renderer class RendererDecorator(Generic[IT, OT]): @@ -436,13 +478,8 @@ class RendererDecoAsync(Generic[OT]): def renderer( handler_fn: HandlerFn[IT, P, OT], -) -> Union[ - Callable[ - Concatenate[RenderFnSync[IT] | RenderFnAsync[IT], P], RendererDecorator[IT, OT] - ], - RendererDecorator[IT, OT], -]: - # ): + # ) -> Union[Callable[P, RendererDecorator[IT, OT]], RendererDecorator[IT, OT]]: +): """\ Renderer components generator @@ -564,7 +601,7 @@ def as_render_fn( if _utils.is_async_callable(fn): return render_fn_async(fn) else: - # Is not not `RenderFnAsync[IT]`. Cast `wrapper_fn` + # `fn` is not Async, cast as Sync fn = cast(RenderFnSync[IT], fn) return render_fn_sync(fn) @@ -647,16 +684,17 @@ def as_render_fn( # renderer_decorator["renderer_types"]["arg_render_fn_sync"] # _.__name__ = - return _ - - ret_c = RendererComponents[IT, OT, P]() - ret_c.function = renderer_decorator # type: ignore - ret_c.types = RendererTypes[IT, OT]() - ret_c.types.arg_render_fn_sync = RenderFnSync[IT] - ret_c.types.arg_render_fn_async = RenderFnAsync[IT] - ret_c.types.return_renderer_sync = RendererSync[OT] - ret_c.types.return_renderer_async = RendererAsync[OT] - ret_c.types.return_renderer_decorator = RenderDeco[IT, OT] + # return _ + renderer_decorator = _ + ret_c = RendererComponents[IT, OT, P](renderer_decorator) + # ret_c.types = RendererTypes[IT, OT]() + # ret_c.renderer = renderer_decorator + # ret_c.types.arg_render_fn = RenderFnSync[IT] | RenderFnAsync[IT] + # ret_c.types.arg_render_fn_sync = RenderFnSync[IT] + # ret_c.types.arg_render_fn_async = RenderFnAsync[IT] + # ret_c.types.return_renderer_sync = RendererSync[OT] + # ret_c.types.return_renderer_async = RendererAsync[OT] + # ret_c.types.return_renderer_decorator = RenderDeco[IT, OT] return ret_c return { @@ -754,10 +792,29 @@ def decorator(func: Callable[P2, T2]) -> Callable[P2, T2]: @renderer -async def text( +async def text2( meta: RenderMeta, - fn: RenderFn[str | None], + fn: RenderFnAsync[str | None], ) -> str | None: + value = await fn() + if value is None: + return None + return str(value) + + +@overload +def text() -> text2.type_renderer: + ... + + +@overload +def text(_fn: text2.type_fn) -> text2.type_renderer: + ... + + +def text( + _fn: Optional[text2.type_fn] = None, +) -> text2.type_renderer: """ Reactively render text. @@ -777,16 +834,13 @@ async def text( -------- ~shiny.ui.output_text """ - value = await fn() - if value is None: - return None - return str(value) + return text2.renderer(_fn) @renderer async def barret2( meta: RenderMeta, - fn: RenderFn[str], + fn: RenderFnAsync[str], *, extra_arg: str = "42", ) -> str | None: @@ -796,50 +850,35 @@ async def barret2( return str(await fn()) -# def renderer_types(renderer_decorator: RenderDeco[IT, OT]): -# return renderer_decorator["types"] - - -barret2(e) -text - - -# def createClass(classname: str, attributes: dict[str, str | int]): -# def init_fn(self: object, arg1: str, arg2: int) -> None: -# setattr(self, "args", (arg1, arg2)) - -# return type( -# classname, -# (object,), -# { -# "__init__": init_fn, -# "args": attributes, -# }, -# ) - - -# CarVal = createClass("Car", {"name": "", "age": 0}) +@overload +def barret2_fn( + *, + extra_arg: str = "42", +) -> barret2.type_renderer: + ... -# mycar = CarVal("Audi R8", 3) -# mycar.args +@overload +def barret2_fn( + _fn: barret2.type_fn, +) -> barret2.type_renderer: + ... -# def barret_wrapper(): -# def barret(): -# return None -# barret.__name__ = "bearit" -# return barret +def barret2_fn( + _fn: Optional[barret2.type_fn] = None, + *, + extra_arg: str = "42", +) -> barret2.type_renderer: + return barret2.renderer( + _fn, + extra_arg=extra_arg, + ) -# b = barret_wrapper() -# print(b) -# print(text) -# print(text.__name__) -# print(text.__qualname__) -# print(text.__annotations__) -# print(dir(text)) -# print(text.__builtins__) +barret2_fn +text +barret2_fn() # ====================================================================================== @@ -852,7 +891,7 @@ async def barret2( @renderer async def plot( meta: RenderMeta, - fn: RenderFn[ImgData | None], + fn: RenderFnAsync[ImgData | None], *, alt: Optional[str] = None, **kwargs: object, @@ -997,7 +1036,7 @@ async def plot( @renderer async def image( meta: RenderMeta, - fn: RenderFn[ImgData | None], + fn: RenderFnAsync[ImgData | None], *, delete_file: bool = False, ) -> ImgData | None: @@ -1062,7 +1101,7 @@ def to_pandas(self) -> "pd.DataFrame": @renderer async def table( meta: RenderMeta, - fn: RenderFn[TableResult | None], + fn: RenderFnAsync[TableResult | None], *, index: bool = False, classes: str = "table shiny-table w-auto", @@ -1152,7 +1191,7 @@ async def table( @renderer async def ui( meta: RenderMeta, - fn: RenderFn[TagChild], + fn: RenderFnAsync[TagChild], ) -> RenderedDeps | None: """ Reactively render HTML content. diff --git a/tests/test_renderer_gen.py b/tests/test_renderer_gen.py index 6f9b7f84a..35310da7e 100644 --- a/tests/test_renderer_gen.py +++ b/tests/test_renderer_gen.py @@ -1,9 +1,9 @@ -from shiny.render._render import RenderFn, RenderMeta, renderer +from shiny.render._render import RenderFnAsync, RenderMeta, renderer def test_renderer_name_and_docs_are_copied(): @renderer - async def my_handler(meta: RenderMeta, fn: RenderFn[str]) -> str: + async def my_handler(meta: RenderMeta, fn: RenderFnAsync[str]) -> str: "Test docs go here" return str(await fn()) @@ -16,7 +16,7 @@ def test_renderer_works(): @renderer async def test_renderer( meta: RenderMeta, - fn: RenderFn[str], + fn: RenderFnAsync[str], ): ... @@ -26,7 +26,7 @@ def test_renderer_kwargs_are_allowed(): @renderer async def test_renderer( meta: RenderMeta, - fn: RenderFn[str], + fn: RenderFnAsync[str], *, y: str = "42", ): @@ -38,7 +38,7 @@ def test_renderer_with_pass_through_kwargs(): @renderer async def test_renderer( meta: RenderMeta, - fn: RenderFn[str], + fn: RenderFnAsync[str], *, y: str = "42", **kwargs: float, @@ -52,7 +52,7 @@ def test_renderer_limits_positional_arg_count(): @renderer async def test_renderer( meta: RenderMeta, - fn: RenderFn[str], + fn: RenderFnAsync[str], y: str, ): ... @@ -68,7 +68,7 @@ def test_renderer_does_not_allow_args(): @renderer async def test_renderer( meta: RenderMeta, - fn: RenderFn[str], + fn: RenderFnAsync[str], *args: str, ): ... @@ -85,7 +85,7 @@ def test_renderer_kwargs_have_defaults(): @renderer async def test_renderer( meta: RenderMeta, - fn: RenderFn[str], + fn: RenderFnAsync[str], *, y: str, ): @@ -103,7 +103,7 @@ def test_renderer_kwargs_can_not_be_name_render_fn(): @renderer async def test_renderer( meta: RenderMeta, - fn: RenderFn[str], + fn: RenderFnAsync[str], *, _render_fn: str, ): @@ -119,7 +119,7 @@ def test_renderer_result_does_not_allow_args(): @renderer async def test_renderer( meta: RenderMeta, - fn: RenderFn[str], + fn: RenderFnAsync[str], ): ... @@ -152,7 +152,7 @@ def test_renderer_makes_calls_render_fn_once(): @renderer async def test_renderer_no_calls( meta: RenderMeta, - fn: RenderFn[str], + fn: RenderFnAsync[str], ): # Does not call `fn` return "Not 42" @@ -160,7 +160,7 @@ async def test_renderer_no_calls( @renderer async def test_renderer_multiple_calls( meta: RenderMeta, - fn: RenderFn[str], + fn: RenderFnAsync[str], ): # Calls `fn` > 1 times return f"{await fn()} - {await fn()}" From 3179506b431f3e2d05afde03928fea47dffa20fa Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Mon, 31 Jul 2023 08:41:50 -0400 Subject: [PATCH 19/64] Save state again before we start dropping code in favor of using overload approach --- shiny/render/_render.py | 360 +++++++++++++++++++++------------------- 1 file changed, 188 insertions(+), 172 deletions(-) diff --git a/shiny/render/_render.py b/shiny/render/_render.py index bcb5bf935..323386700 100644 --- a/shiny/render/_render.py +++ b/shiny/render/_render.py @@ -127,6 +127,7 @@ class RenderMeta(TypedDict): name: str +# TODO-barret remove; wch √ class _CallCounter(Generic[IT]): def assert_call_count(self, total_calls: int = 1): if self._call_count != total_calls: @@ -400,41 +401,7 @@ def renderer_components( return renderer_components(handler_fn) -class RendererTypes(Generic[IT, OT]): - # arg_render_fn: typing.Type[RenderFnSync[IT] | RenderFnAsync[IT]] - arg_render_fn: typing.Type[RenderFn[IT]] - # arg_render_fn: typing.Type[RenderFnSync[IT] | RenderFnAsync[IT]] - - # arg_render_fn_sync: typing.Type[RenderFnSync[IT]] - # arg_render_fn_async: typing.Type[RenderFnAsync[IT]] - # return_renderer: typing.Type[RendererSync[OT] | RendererAsync[OT]] - return_renderer: typing.Type[Renderer[OT]] - # return_renderer_sync: typing.Type[RendererSync[OT]] - # return_renderer_async: typing.Type[RendererAsync[OT]] - # return_renderer_decorator: RenderDeco[IT, OT] - - # def __init__(self): - # self.arg_render_fn_async = RenderFnAsync[IT] - # self.arg_render_fn_sync = RenderFnSync[IT] - # self.return_renderer_sync = RendererSync[OT] - # self.return_renderer_async = RendererAsync[OT] - # self.return_renderer_decorator = RenderDeco[IT, OT] - - class RendererComponents(Generic[IT, OT, P]): - # # function: RenderDeco[IT, OT] | Callable[P, RenderDeco[IT, OT]] - # renderer: Callable[ - # Concatenate[ - # Optional[RenderFn[IT]], - # P, - # ], - # # RendererSync[OT] | RendererAsync[OT], - # Renderer[OT], - # ] - # type_fn: RenderFn[IT] - # type_renderer: typing.Type[Renderer[OT]] - - # types: RendererTypes[IT, OT] def __init__( self, renderer: Callable[ @@ -453,33 +420,161 @@ def __init__( self.renderer = renderer -class RendererDecorator(Generic[IT, OT]): +# class RendererDecorator(Generic[IT, OT]): +# # @overload +# # def __call__(self, _render_fn: RenderFnSync[IT]) -> RendererSync[OT]: +# # ... + +# # @overload +# # def __call__(self, _render_fn: RenderFnAsync[IT]) -> RendererAsync[OT]: +# # ... + +# def __call__( +# self, _render_fn: RenderFnSync[IT] | RenderFnAsync[IT] +# ) -> RendererSync[OT] | RendererAsync[OT]: +# ... + + +def coyp_doc(doc: str | None): + def inner(obj: T) -> T: + obj.__doc__ = doc or "" + return obj + + return inner + + +class RendererDecorator(Generic[IT, OT, P]): + """ + Renderer Decorator class docs go here! + """ + + @property + def type_renderer_fn(self): + return RenderFn[IT] + + @property + def type_renderer(self): + return Renderer[OT] + + @property + def type_decorator(self): + # return RenderDeco[IT, OT] + return Callable[[RenderFn[IT]], Renderer[OT]] + + @property + def type_impl_fn(self): + return Optional[RenderFn[IT]] + + @property + def type_impl(self): + return Renderer[OT] | Callable[[RenderFn[IT]], Renderer[OT]] + # RenderDeco[IT, OT] + + # @property + # def types(self): + # return { + # "return_renderer_decorator": RenderDeco[IT, OT], + # "arg_render_fn_sync": RenderFnSync[IT], + # "return_renderer_sync": RendererSync[OT], + # "arg_render_fn_async": RenderFnAsync[IT], + # "return_renderer_async": RendererAsync[OT], + # } + + def __init__( + self, + fn: Callable[ + Concatenate[ + Optional[RenderFn[IT]], + P, + ], + # RendererSync[OT] | RendererAsync[OT], + Renderer[OT] | RenderDeco[IT, OT], + ], + # doc: str | None = None, + ) -> None: + # self.__name__ = handler_fn.__name__ + # self.__func__.__doc__ = handler_fn.__doc__ + self._fn = fn + # self.__doc__ = doc + import functools + + functools.update_wrapper(self, fn, updated=()) + + # @property + # def __doc__(self): + # return "C doc" + + # @functools.wraps(handler_fn) + @overload + def __call__(self, *args: P.args, **kwargs: P.kwargs) -> RenderDeco[IT, OT]: + ... + # @overload - # def __call__(self, _render_fn: RenderFnSync[IT]) -> RendererSync[OT]: + # def __call__(self, _render_fn: RenderFnAsync[IT]) -> RendererAsync[OT]: # ... # @overload - # def __call__(self, _render_fn: RenderFnAsync[IT]) -> RendererAsync[OT]: + # def __call__(self, _render_fn: RenderFnSync[IT]) -> RendererSync[OT]: # ... - def __call__( - self, _render_fn: RenderFnSync[IT] | RenderFnAsync[IT] - ) -> RendererSync[OT] | RendererAsync[OT]: + @overload + def __call__(self, _fn: RenderFn[IT]) -> Renderer[OT]: ... + def __call__( # type: ignore[reportGeneralTypeIssues] + self, + _render_fn: Optional[RenderFnSync[IT] | RenderFnAsync[IT]] = None, + *args: P.args, # Equivalent to `*` after assertions in `_assert_handler_fn()` + **kwargs: P.kwargs, + ): + return self._fn(_render_fn, *args, **kwargs) -class RendererDecoSync(Generic[OT]): - ... + @property + def impl(self): + return self._fn + @property + def fn( + self, + # ) -> Callable[ + # Concatenate[ + # Optional[RenderFn[IT]], + # P, + # ], + # # RendererSync[OT] | RendererAsync[OT], + # Renderer[OT], + # ]: + ): + @overload + def bear( + *args: P.args, **kwargs: P.kwargs + ) -> Callable[[RenderFn[IT]], Renderer[OT]]: + ... -class RendererDecoAsync(Generic[OT]): - ... + @overload + def bear(_fn: RenderFn[IT]) -> Renderer[OT]: + ... + + def bear(_fn: RenderFn[IT], *args: P.args, **kwargs: P.kwargs) -> Renderer[OT]: + return self._fn(_fn, *args, **kwargs) + + bear.__doc__ = self._fn.__doc__ + + return bear + + # def fn( # type: ignore[reportGeneralTypeIssues] + # self, + # _render_fn: Optional[RenderFnSync[IT] | RenderFnAsync[IT]] = None, + # *args: P.args, # Equivalent to `*` after assertions in `_assert_handler_fn()` + # **kwargs: P.kwargs, + # ): + # return self._fn(_render_fn, *args, **kwargs) def renderer( handler_fn: HandlerFn[IT, P, OT], # ) -> Union[Callable[P, RendererDecorator[IT, OT]], RendererDecorator[IT, OT]]: -): +) -> RendererDecorator[IT, OT, P]: """\ Renderer components generator @@ -547,24 +642,6 @@ def _( # type: ignore[reportGeneralTypeIssues] # Make sure there no `args`! _assert_no_args(args) - # def barret(): - # return - - # barret.__name__ = "bearit" - # barret - - # class Barret: - # def __init__(self) -> None: - # self.__name__ = "Bearit" - # self.__class__.__name__ = "BearitClass" - # pass - - # def __call__(self) -> None: - # return - - # b = Barret() - # b - def render_fn_sync( fn_sync: RenderFnSync[IT], ) -> RendererSync[OT]: @@ -684,106 +761,16 @@ def as_render_fn( # renderer_decorator["renderer_types"]["arg_render_fn_sync"] # _.__name__ = - # return _ renderer_decorator = _ - ret_c = RendererComponents[IT, OT, P](renderer_decorator) - # ret_c.types = RendererTypes[IT, OT]() - # ret_c.renderer = renderer_decorator - # ret_c.types.arg_render_fn = RenderFnSync[IT] | RenderFnAsync[IT] - # ret_c.types.arg_render_fn_sync = RenderFnSync[IT] - # ret_c.types.arg_render_fn_async = RenderFnAsync[IT] - # ret_c.types.return_renderer_sync = RendererSync[OT] - # ret_c.types.return_renderer_async = RendererAsync[OT] - # ret_c.types.return_renderer_decorator = RenderDeco[IT, OT] - return ret_c - - return { - "function": renderer_decorator, - "types": { - "arg_render_fn_sync": RenderFnSync[IT], - "arg_render_fn_async": RenderFnAsync[IT], - "return_renderer_sync": RendererSync[OT], - "return_renderer_async": RendererAsync[OT], - "return_renderer_decorator": RenderDeco[IT, OT], - }, - } - - # b = "barret" - # my_fn = type("B", (), {"barret": 42}) - # return my_fn - # my_fn - - class RendererDecorator: - @property - def types(self): - return { - "return_renderer_decorator": RenderDeco[IT, OT], - "arg_render_fn_sync": RenderFnSync[IT], - "return_renderer_sync": RendererSync[OT], - "arg_render_fn_async": RenderFnAsync[IT], - "return_renderer_async": RendererAsync[OT], - } - - def __init__(self) -> None: - # self.__name__ = handler_fn.__name__ - # self.__func__.__doc__ = handler_fn.__doc__ - ... - - # @functools.wraps(handler_fn) - @overload - def __call__(self, _render_fn: RenderFnSync[IT]) -> RendererSync[OT]: - ... - - def __call__( - self, - _render_fn: Optional[RenderFnSync[IT] | RenderFnAsync[IT]] = None, - *args: P.args, # Equivalent to `*` after assertions in `_assert_handler_fn()` - **kwargs: P.kwargs, - ): - return renderer_decorator(_render_fn, *args, **kwargs) - - ret = RendererDecorator() - # ret.__class__.__doc__ = handler_fn.__doc__ - # ret.__doc__ = handler_fn.__doc__ - + # return renderer_decorator + # return RendererComponents[IT, OT, P](renderer_decorator) + ret = RendererDecorator[IT, OT, P]( + renderer_decorator, + # doc=handler_fn.__doc__ + ) + ret.__doc__ = handler_fn.__doc__ + ret.__class__.__doc__ = handler_fn.__doc__ return ret - return renderer_decorator - - -P2 = ParamSpec("P2") -T2 = TypeVar("T2") - - -def barret_wraps2(wrapper: Callable[..., typing.Any]): - """An implementation of functools.wraps.""" - - def decorator(func: Callable[P2, T2]) -> Callable[P2, T2]: - # func.__doc__ = wrapper.__doc__ - print( - func.__name__, - wrapper.__name__, - func.__qualname__, - wrapper.__qualname__, - func.__qualname__.replace( - "renderer_decorator", - wrapper.__name__, - ), - func.__code__.co_firstlineno, - wrapper.__code__.co_firstlineno, - ) - func.__name__ = wrapper.__name__ - func.__doc__ = wrapper.__doc__ - func.__module__ = wrapper.__module__ - - # # Do not adjust qualname as it is used in the registry for the overloads - # # https://github.com/python/cpython/blob/36208b5/Lib/typing.py#L2607 - # func.__qualname__ = func.__qualname__.replace( - # "renderer_decorator", - # wrapper.__name__, - # ) - return func - - return decorator # ====================================================================================== @@ -792,29 +779,39 @@ def decorator(func: Callable[P2, T2]) -> Callable[P2, T2]: @renderer -async def text2( +async def text_handler( meta: RenderMeta, fn: RenderFnAsync[str | None], + *, + extra_arg: str = "42", ) -> str | None: + """ + # Barret + """ value = await fn() if value is None: return None return str(value) +text_handler + + @overload -def text() -> text2.type_renderer: +def text(*, extra_arg: str = "42") -> text_handler.type_decorator: ... @overload -def text(_fn: text2.type_fn) -> text2.type_renderer: +def text(_fn: text_handler.type_renderer_fn) -> text_handler.type_renderer: ... def text( - _fn: Optional[text2.type_fn] = None, -) -> text2.type_renderer: + _fn: text_handler.type_impl_fn = None, + *, + extra_arg: str = "42", +) -> text_handler.type_impl: """ Reactively render text. @@ -834,9 +831,14 @@ def text( -------- ~shiny.ui.output_text """ - return text2.renderer(_fn) + return text_handler.impl(_fn, extra_arg=extra_arg) +# TODO-barret; Have `type_imple_fn` be a class to hide all of the grossness of the signature +text + + +# TODO-barret: Plan of action: If we can get barret2 doc string to be ported, then we use the class with no manual overrides. If we can NOT get the docs string, use manual override approach only @renderer async def barret2( meta: RenderMeta, @@ -846,31 +848,45 @@ async def barret2( ) -> str | None: """ My docs go here! + + Parameters + ---------- + extra_arg + My extra arg docs go here! """ return str(await fn()) +barret2() +# @output +# @barret2 +# def my_name(): +# ... +barret2fn = barret2.fn +barret2 + + @overload def barret2_fn( *, extra_arg: str = "42", -) -> barret2.type_renderer: +) -> barret2.type_decorator: ... @overload def barret2_fn( - _fn: barret2.type_fn, + _fn: barret2.type_renderer_fn, ) -> barret2.type_renderer: ... def barret2_fn( - _fn: Optional[barret2.type_fn] = None, + _fn: Optional[barret2.type_renderer_fn] = None, *, extra_arg: str = "42", -) -> barret2.type_renderer: - return barret2.renderer( +) -> barret2.type_impl: + return barret2.impl( _fn, extra_arg=extra_arg, ) From 0ab8d4ad6f32d3a2f3b2eb4fcfad114847e33e11 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Mon, 31 Jul 2023 16:40:03 -0400 Subject: [PATCH 20/64] Save state again. Lots of code as been chopped and it is a good break point --- e2e/server/renderer_gen/app.py | 30 +- shiny/_deprecated.py | 8 +- shiny/render/__init__.py | 5 +- shiny/render/_dataframe.py | 55 ++- shiny/render/_render.py | 799 +++++++++++---------------------- tests/test_renderer_gen.py | 149 ++++-- 6 files changed, 438 insertions(+), 608 deletions(-) diff --git a/e2e/server/renderer_gen/app.py b/e2e/server/renderer_gen/app.py index bbc8f299b..07d29ae22 100644 --- a/e2e/server/renderer_gen/app.py +++ b/e2e/server/renderer_gen/app.py @@ -2,14 +2,14 @@ # See https://www.python.org/dev/peps/pep-0655/#usage-in-python-3-11 from __future__ import annotations -from typing import Optional +from typing import Optional, overload from shiny import App, Inputs, Outputs, Session, ui -from shiny.render._render import RenderFnAsync, RenderMeta, renderer +from shiny.render._render import RenderFnAsync, RenderMeta, renderer_components -@renderer -async def render_test_text( +@renderer_components +async def _render_test_text_components( meta: RenderMeta, fn: RenderFnAsync[str | None], *, @@ -24,6 +24,28 @@ async def render_test_text( return value +@overload +def render_test_text( + *, extra_txt: Optional[str] = None +) -> _render_test_text_components.type_decorator: + ... + + +@overload +def render_test_text( + _fn: _render_test_text_components.type_renderer_fn, +) -> _render_test_text_components.type_renderer: + ... + + +def render_test_text( + _fn: _render_test_text_components.type_impl_fn = None, + *, + extra_txt: Optional[str] = None, +) -> _render_test_text_components.type_impl: + return _render_test_text_components.impl(_fn, extra_txt=extra_txt) + + app_ui = ui.page_fluid( ui.code("t1:"), ui.output_text_verbatim("t1"), diff --git a/shiny/_deprecated.py b/shiny/_deprecated.py index bb00d4e92..f6be2e40b 100644 --- a/shiny/_deprecated.py +++ b/shiny/_deprecated.py @@ -39,16 +39,16 @@ def render_ui(): return render.ui() -def render_plot(*args: Any, **kwargs: Any): +def render_plot(*args: Any, **kwargs: Any): # type: ignore """Deprecated. Please use render.plot() instead of render_plot().""" warn_deprecated("render_plot() is deprecated. Use render.plot() instead.") - return render.plot(*args, **kwargs) + return render.plot(*args, **kwargs) # type: ignore -def render_image(*args: Any, **kwargs: Any): +def render_image(*args: Any, **kwargs: Any): # type: ignore """Deprecated. Please use render.image() instead of render_image().""" warn_deprecated("render_image() is deprecated. Use render.image() instead.") - return render.image(*args, **kwargs) + return render.image(*args, **kwargs) # type: ignore def event(*args: Any, **kwargs: Any): diff --git a/shiny/render/__init__.py b/shiny/render/__init__.py index 2628c06f1..bf4121f79 100644 --- a/shiny/render/__init__.py +++ b/shiny/render/__init__.py @@ -6,10 +6,7 @@ # Import these values, but do not give autocomplete hints for `shiny.render.FOO` RenderMeta as RenderMeta, RenderFnAsync as RenderFnAsync, - # Renderer as Renderer, - # RendererSync as RendererSync, - # RendererAsync as RendererAsync, - renderer as renderer, + renderer_components as renderer_components, text, plot, image, diff --git a/shiny/render/_dataframe.py b/shiny/render/_dataframe.py index a5f134bb3..a4b7a7f08 100644 --- a/shiny/render/_dataframe.py +++ b/shiny/render/_dataframe.py @@ -2,10 +2,19 @@ import abc import json -from typing import TYPE_CHECKING, Any, Literal, Protocol, Union, cast, runtime_checkable +from typing import ( + TYPE_CHECKING, + Any, + Literal, + Protocol, + Union, + cast, + overload, + runtime_checkable, +) from .._docstring import add_example -from . import RenderFnAsync, RenderMeta, renderer +from . import RenderFnAsync, RenderMeta, renderer_components if TYPE_CHECKING: import pandas as pd @@ -195,12 +204,36 @@ def to_payload(self) -> object: # TODO-barret; Port `__name__` and `__docs__` of `value_fn` -@add_example() -@renderer -async def data_frame( +@renderer_components +async def _data_frame( meta: RenderMeta, fn: RenderFnAsync[DataFrameResult | None], ) -> object | None: + x = await 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(_fn: None = None) -> _data_frame.type_decorator: + ... + + +@overload +def data_frame(_fn: _data_frame.type_renderer_fn) -> _data_frame.type_renderer: + ... + + +@add_example() +def data_frame(_fn: _data_frame.type_impl_fn = None) -> _data_frame.type_impl: """ Reactively render a Pandas data frame object (or similar) as a basic HTML table. @@ -224,17 +257,7 @@ async def data_frame( :class:`~shiny.render.DataTable` :func:`~shiny.ui.output_data_frame` """ - x = await 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() + return _data_frame.impl(_fn) @runtime_checkable diff --git a/shiny/render/_render.py b/shiny/render/_render.py index 323386700..a162cc20c 100644 --- a/shiny/render/_render.py +++ b/shiny/render/_render.py @@ -13,15 +13,11 @@ # TODO-barret; Rename `RendererDecorator` to `Renderer`?; Rename `Renderer` to something else # TODO-barret; Add in `IT` to RendererDecorator to enforce return type +# TODO-barret: Plan of action: If we can get barret2 doc string to be ported, then we use the class with no manual overrides. If we can NOT get the docs string, use manual override approach only +# TODO-barret; Use the manual approach only +# Revert base classes and use the original classes -# W/ Rich: -# The function is called a "render handler" as it handles the "render function" and returns a rendered result. - -# result of `@renderer` is "renderer function" - -# Names: -# * `_value_fn` -> `_handler` -# * `value: IT` -> `fn: RenderFn[IT]` +# TODO-barret; Look into using a ParamSpecValue class to contain the values of the ParamSpec __all__ = ( @@ -107,11 +103,10 @@ _RenderArgsAsync = Tuple[RenderFnAsync[IT], HandlerFn[IT, P, OT]] _RenderArgs = Union[_RenderArgsSync[IT, P, OT], _RenderArgsAsync[IT, P, OT]] -RenderDecoSync = Callable[[RenderFnSync[IT]], "RendererSync[OT]"] -RenderDecoAsync = Callable[[RenderFnAsync[IT]], "RendererAsync[OT]"] RenderDeco = Callable[ [Union[RenderFnSync[IT], RenderFnAsync[IT]]], - Union["RendererSync[OT]", "RendererAsync[OT]"], + "Renderer[OT]" + # Union["RendererSync[OT]", "RendererAsync[OT]"], ] @@ -127,30 +122,6 @@ class RenderMeta(TypedDict): name: str -# TODO-barret remove; wch √ -class _CallCounter(Generic[IT]): - def assert_call_count(self, total_calls: int = 1): - if self._call_count != total_calls: - raise RuntimeError( - f"The total number of calls (`{self._call_count}`) to '{self._render_fn_name}' in the '{self._handler_fn_name}' handler did not equal `{total_calls}`." - ) - - def __init__( - self, - *, - render_fn: RenderFnAsync[IT], - handler_fn: HandlerFn[IT, P, OT], - ): - self._call_count: int = 0 - self._render_fn = render_fn - self._render_fn_name = render_fn.__name__ - self._handler_fn_name = handler_fn.__name__ - - async def __call__(self) -> IT: - self._call_count += 1 - return await self._render_fn() - - # ====================================================================================== # Renderer / RendererSync / RendererAsync base class # ====================================================================================== @@ -184,7 +155,7 @@ class Renderer(Generic[OT]): """ - def __call__(self) -> OT: + def __call__(self, *_) -> OT: raise NotImplementedError def __init__(self, *, name: str, doc: str | None) -> None: @@ -255,21 +226,15 @@ def __init__( self._kwargs = kwargs async def _run(self) -> OT: - render_fn_w_counter = _CallCounter( - render_fn=self._render_fn, - handler_fn=self._handler_fn, - ) ret = await self._handler_fn( # RendererMeta self.meta, # Callable[[], Awaitable[IT]] - render_fn_w_counter, + self._render_fn, # P *self._args, **self._kwargs, ) - # TODO-future; Should we assert the call count? Should we check against non-missing values? - render_fn_w_counter.assert_call_count(1) return ret @@ -298,7 +263,7 @@ def __init__( **kwargs, ) - def __call__(self) -> OT: + def __call__(self, *_) -> OT: return _utils.run_coro_sync(self._run()) @@ -330,7 +295,7 @@ def __init__( ) async def __call__( # pyright: ignore[reportIncompatibleMethodOverride] - self, + self, *_ ) -> OT: return await self._run() @@ -389,65 +354,26 @@ def _assert_handler_fn(handler_fn: HandlerFn[IT, P, OT]) -> None: # Renderer decorator # ====================================================================================== - -def renderer_components( - handler_fn: HandlerFn[IT, P, OT], -) -> RendererComponents[IT, OT, P]: - """\ - Renderer generator - - TODO-barret; Docs go here! - """ - return renderer_components(handler_fn) +RendererDeco = Callable[[RenderFn[IT]], Renderer[OT]] +RenderImplFn = Callable[ + Concatenate[ + Optional[RenderFn[IT]], + P, + ], + # RendererSync[OT] | RendererAsync[OT] | RenderDeco[IT, OT], + Renderer[OT] | RenderDeco[IT, OT], +] class RendererComponents(Generic[IT, OT, P]): - def __init__( - self, - renderer: Callable[ - Concatenate[ - Optional[RenderFn[IT]], - P, - ], - # RendererSync[OT] | RendererAsync[OT], - Renderer[OT], - ], - ) -> None: - super().__init__() - self.type_fn = RenderFn[IT] - self.type_renderer = Renderer[OT] - - self.renderer = renderer - - -# class RendererDecorator(Generic[IT, OT]): -# # @overload -# # def __call__(self, _render_fn: RenderFnSync[IT]) -> RendererSync[OT]: -# # ... - -# # @overload -# # def __call__(self, _render_fn: RenderFnAsync[IT]) -> RendererAsync[OT]: -# # ... - -# def __call__( -# self, _render_fn: RenderFnSync[IT] | RenderFnAsync[IT] -# ) -> RendererSync[OT] | RendererAsync[OT]: -# ... - - -def coyp_doc(doc: str | None): - def inner(obj: T) -> T: - obj.__doc__ = doc or "" - return obj - - return inner - - -class RendererDecorator(Generic[IT, OT, P]): """ Renderer Decorator class docs go here! """ + @property + def type_decorator(self): + return RendererDeco[IT, OT] + @property def type_renderer_fn(self): return RenderFn[IT] @@ -456,161 +382,53 @@ def type_renderer_fn(self): def type_renderer(self): return Renderer[OT] - @property - def type_decorator(self): - # return RenderDeco[IT, OT] - return Callable[[RenderFn[IT]], Renderer[OT]] - @property def type_impl_fn(self): return Optional[RenderFn[IT]] @property def type_impl(self): - return Renderer[OT] | Callable[[RenderFn[IT]], Renderer[OT]] - # RenderDeco[IT, OT] - - # @property - # def types(self): - # return { - # "return_renderer_decorator": RenderDeco[IT, OT], - # "arg_render_fn_sync": RenderFnSync[IT], - # "return_renderer_sync": RendererSync[OT], - # "arg_render_fn_async": RenderFnAsync[IT], - # "return_renderer_async": RendererAsync[OT], - # } - - def __init__( - self, - fn: Callable[ - Concatenate[ - Optional[RenderFn[IT]], - P, - ], - # RendererSync[OT] | RendererAsync[OT], - Renderer[OT] | RenderDeco[IT, OT], - ], - # doc: str | None = None, - ) -> None: - # self.__name__ = handler_fn.__name__ - # self.__func__.__doc__ = handler_fn.__doc__ - self._fn = fn - # self.__doc__ = doc - import functools - - functools.update_wrapper(self, fn, updated=()) + return Renderer[OT] | RendererDeco[IT, OT] # @property - # def __doc__(self): - # return "C doc" - - # @functools.wraps(handler_fn) - @overload - def __call__(self, *args: P.args, **kwargs: P.kwargs) -> RenderDeco[IT, OT]: - ... + # def impl(self): + # return self._fn # @overload - # def __call__(self, _render_fn: RenderFnAsync[IT]) -> RendererAsync[OT]: + # def impl( + # self, _render_fn: None = None, *args: P.args, **kwargs: P.kwargs + # ) -> RendererDeco[IT, OT]: # ... # @overload - # def __call__(self, _render_fn: RenderFnSync[IT]) -> RendererSync[OT]: + # def impl(self, _render_fn: RenderFn[IT]) -> Renderer[OT]: # ... - @overload - def __call__(self, _fn: RenderFn[IT]) -> Renderer[OT]: - ... - - def __call__( # type: ignore[reportGeneralTypeIssues] + def impl( self, - _render_fn: Optional[RenderFnSync[IT] | RenderFnAsync[IT]] = None, - *args: P.args, # Equivalent to `*` after assertions in `_assert_handler_fn()` + _render_fn: Optional[RenderFn[IT]] = None, + *args: P.args, **kwargs: P.kwargs, - ): + ) -> Renderer[OT] | RendererDeco[IT, OT]: return self._fn(_render_fn, *args, **kwargs) - @property - def impl(self): - return self._fn - - @property - def fn( + def __init__( self, - # ) -> Callable[ - # Concatenate[ - # Optional[RenderFn[IT]], - # P, - # ], - # # RendererSync[OT] | RendererAsync[OT], - # Renderer[OT], - # ]: - ): - @overload - def bear( - *args: P.args, **kwargs: P.kwargs - ) -> Callable[[RenderFn[IT]], Renderer[OT]]: - ... - - @overload - def bear(_fn: RenderFn[IT]) -> Renderer[OT]: - ... - - def bear(_fn: RenderFn[IT], *args: P.args, **kwargs: P.kwargs) -> Renderer[OT]: - return self._fn(_fn, *args, **kwargs) - - bear.__doc__ = self._fn.__doc__ - - return bear - - # def fn( # type: ignore[reportGeneralTypeIssues] - # self, - # _render_fn: Optional[RenderFnSync[IT] | RenderFnAsync[IT]] = None, - # *args: P.args, # Equivalent to `*` after assertions in `_assert_handler_fn()` - # **kwargs: P.kwargs, - # ): - # return self._fn(_render_fn, *args, **kwargs) + fn: RenderImplFn[IT, P, OT], + ) -> None: + self._fn = fn -def renderer( +def renderer_components( handler_fn: HandlerFn[IT, P, OT], - # ) -> Union[Callable[P, RendererDecorator[IT, OT]], RendererDecorator[IT, OT]]: -) -> RendererDecorator[IT, OT, P]: +) -> RendererComponents[IT, OT, P]: """\ - Renderer components generator + Renderer decorator generator TODO-barret; Docs go here! """ _assert_handler_fn(handler_fn) - # @overload - # # @functools.wraps( - # # handler_fn, assigned=("__module__", "__name__", "__qualname__", "__doc__") - # # ) - # def _(*args: P.args, **kwargs: P.kwargs) -> RenderDeco[IT, OT]: - # ... - - # @overload - # # @functools.wraps( - # # handler_fn, assigned=("__module__", "__name__", "__qualname__", "__doc__") - # # ) - # # RenderDecoSync[IT, OT] - # def _( - # _render_fn: RenderFnSync[IT], - # ) -> RendererSync[OT]: - # ... - - # @overload - # # @functools.wraps( - # # handler_fn, assigned=("__module__", "__name__", "__qualname__", "__doc__") - # # ) - # # RenderDecoAsync[IT, OT] - # # RendererDecoAsync[OT] - # # RendererDecoAsync[OT] - # def _( - # _render_fn: RenderFnAsync[IT], - # ) -> RendererAsync[OT]: - # ... - # Ignoring the type issue on the next line of code as the overloads for # `renderer_deco` are not consistent with the function definition. # Motivation: @@ -626,147 +444,43 @@ def renderer( # @functools.wraps( # handler_fn, assigned=("__module__", "__name__", "__qualname__", "__doc__") # ) - def _( # type: ignore[reportGeneralTypeIssues] + def renderer_decorator( _render_fn: Optional[RenderFnSync[IT] | RenderFnAsync[IT]] = None, *args: P.args, # Equivalent to `*` after assertions in `_assert_handler_fn()` # *, **kwargs: P.kwargs, + # ) -> RenderImplFn[IT, P, OT]: ): - # -> ( - # RenderDecoSync[IT, OT] - # | RenderDecoAsync[IT, OT] - # | RendererSync[OT] - # | RendererAsync[OT] - # ): # `args` **must** be in `renderer_decorator` definition. # Make sure there no `args`! _assert_no_args(args) - def render_fn_sync( - fn_sync: RenderFnSync[IT], - ) -> RendererSync[OT]: - return RendererSync( - (fn_sync, handler_fn), - *args, - **kwargs, - ) - - def render_fn_async( - fn_async: RenderFnAsync[IT], - ) -> RendererAsync[OT]: - return RendererAsync( - (fn_async, handler_fn), - *args, - **kwargs, - ) - - @overload - def as_render_fn( - fn: RenderFnSync[IT], - ) -> RendererSync[OT]: - ... - - @overload - def as_render_fn( - fn: RenderFnAsync[IT], - ) -> RendererAsync[OT]: - ... - def as_render_fn( fn: RenderFnSync[IT] | RenderFnAsync[IT], - ) -> RendererSync[OT] | RendererAsync[OT]: + ) -> Renderer[OT]: if _utils.is_async_callable(fn): - return render_fn_async(fn) + return RendererAsync( + (fn, handler_fn), + *args, + **kwargs, + ) + else: # `fn` is not Async, cast as Sync fn = cast(RenderFnSync[IT], fn) - return render_fn_sync(fn) + return RendererSync( + (fn, handler_fn), + *args, + **kwargs, + ) if _render_fn is None: return as_render_fn - return as_render_fn(_render_fn) - - # r_overloads = typing.get_overloads(renderer_decorator) - # print(list(r_overloads)) - # for r_overload in r_overloads: - # for key in ( - # "__module__", - # "__name__", - # "__qualname__", - # "__doc__", - # # "__annotations__", - # ): - # print(key, getattr(r_overload, key), getattr(handler_fn, key)) - # # # print(r_overload.__builtins__) - # print( - # r_overload.__name__, - # r_overload.__qualname__, - # # r_overload.__dir__(), - # # r_overload.__hash__(), - # # r_overload.__str__(), - # # r_overload.__repr__(), - # # r_overload.__annotations__, - # # r_overload.__builtins__, - # ) - # r_overload.__name__ = handler_fn.__name__ - # r_overload.__doc__ = handler_fn.__doc__ - # print(r_overload.__name__, r_overload.__doc__) - # # import pdb - - # pdb.set_trace() - - # import inspect - - # curframe = inspect.currentframe() - # if curframe: - # curframe.f_locals["bearit"] = renderer_decorator - # print(curframe, curframe.f_locals) - # return curframe.f_locals["bearit"] - - # Copy over name an docs - # renderer_decorator.__doc__ = handler_fn.__doc__ - # renderer_decorator.__name__ = handler_fn.__name__ - # # Lie and give it a pretty qualifying name - # renderer_decorator.__qualname__ = renderer_decorator.__qualname__.replace( - # "renderer_decorator", - # handler_fn.__name__, - # ) + val = as_render_fn(_render_fn) + return val - # # TODO-barret; Fix name of decorated function. Hovering over method name does not work - # ren_func = getattr(renderer_decorator, "__func__", renderer_decorator) - # ren_func.__name__ = handler_fn.__name__ - - # ret = cast(Barret, renderer_decorator) - # ret.__name__ = handler_fn.__name__ - # ret.__doc__ = handler_fn.__doc__ - - # return ret - - # renderer_decorator.barret = 43 # type: ignore - # b = f"{43}" - # key = "barret" - # setattr(renderer_decorator, key, f"{b}") # type: ignore - - # return renderer_decorator - - # renderer_decorator.__dict__ = { - # "renderer_types": { - # "arg_render_fn_sync": RenderFnSync[IT], - # "arg_render_fn_async": RenderFnAsync[IT], - # "return_renderer_sync": RendererSync[OT], - # "return_renderer_async": RendererAsync[OT], - # "return_renderer_decorator": RenderDeco[IT, OT], - # } - # } - # renderer_decorator["renderer_types"]["arg_render_fn_sync"] - # _.__name__ = - - renderer_decorator = _ - # return renderer_decorator - # return RendererComponents[IT, OT, P](renderer_decorator) - ret = RendererDecorator[IT, OT, P]( + ret = RendererComponents[IT, OT, P]( renderer_decorator, - # doc=handler_fn.__doc__ ) ret.__doc__ = handler_fn.__doc__ ret.__class__.__doc__ = handler_fn.__doc__ @@ -778,40 +492,30 @@ def as_render_fn( # ====================================================================================== -@renderer -async def text_handler( +@renderer_components +async def _text( meta: RenderMeta, fn: RenderFnAsync[str | None], - *, - extra_arg: str = "42", ) -> str | None: - """ - # Barret - """ value = await fn() if value is None: return None return str(value) -text_handler - - @overload -def text(*, extra_arg: str = "42") -> text_handler.type_decorator: +def text(_fn: None = None) -> _text.type_decorator: ... @overload -def text(_fn: text_handler.type_renderer_fn) -> text_handler.type_renderer: +def text(_fn: _text.type_renderer_fn) -> _text.type_renderer: ... def text( - _fn: text_handler.type_impl_fn = None, - *, - extra_arg: str = "42", -) -> text_handler.type_impl: + _fn: _text.type_impl_fn = None, +) -> _text.type_impl: """ Reactively render text. @@ -831,70 +535,7 @@ def text( -------- ~shiny.ui.output_text """ - return text_handler.impl(_fn, extra_arg=extra_arg) - - -# TODO-barret; Have `type_imple_fn` be a class to hide all of the grossness of the signature -text - - -# TODO-barret: Plan of action: If we can get barret2 doc string to be ported, then we use the class with no manual overrides. If we can NOT get the docs string, use manual override approach only -@renderer -async def barret2( - meta: RenderMeta, - fn: RenderFnAsync[str], - *, - extra_arg: str = "42", -) -> str | None: - """ - My docs go here! - - Parameters - ---------- - extra_arg - My extra arg docs go here! - """ - return str(await fn()) - - -barret2() -# @output -# @barret2 -# def my_name(): -# ... -barret2fn = barret2.fn -barret2 - - -@overload -def barret2_fn( - *, - extra_arg: str = "42", -) -> barret2.type_decorator: - ... - - -@overload -def barret2_fn( - _fn: barret2.type_renderer_fn, -) -> barret2.type_renderer: - ... - - -def barret2_fn( - _fn: Optional[barret2.type_renderer_fn] = None, - *, - extra_arg: str = "42", -) -> barret2.type_impl: - return barret2.impl( - _fn, - extra_arg=extra_arg, - ) - - -barret2_fn -text -barret2_fn() + return _text.impl(_fn) # ====================================================================================== @@ -904,57 +545,14 @@ def barret2_fn( # 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`. -@renderer -async def plot( +@renderer_components +async def _plot( meta: RenderMeta, fn: RenderFnAsync[ImgData | None], *, alt: Optional[str] = None, **kwargs: object, ) -> ImgData | None: - """ - Reactively render a plot object as an HTML image. - - Parameters - ---------- - alt - Alternative text for the image if it cannot be displayed or viewed (i.e., the - user uses a screen reader). - **kwargs - Additional keyword arguments passed to the relevant method for saving the image - (e.g., for matplotlib, arguments to ``savefig()``; for PIL and plotnine, - arguments to ``save()``). - - Returns - ------- - : - A decorator for a function that returns any of the following: - - 1. A :class:`matplotlib.figure.Figure` instance. - 2. An :class:`matplotlib.artist.Artist` instance. - 3. A list/tuple of Figure/Artist instances. - 4. An object with a 'figure' attribute pointing to a - :class:`matplotlib.figure.Figure` instance. - 5. A :class:`PIL.Image.Image` instance. - - It's also possible to use the ``matplotlib.pyplot`` interface; in that case, your - function should just call pyplot functions and not return anything. (Note that if - the decorated function is async, then it's not safe to use pyplot. Shiny will detect - this case and throw an error asking you to use matplotlib's object-oriented - interface instead.) - - Tip - ---- - This decorator should be applied **before** the ``@output`` decorator. Also, the - name of the decorated function (or ``@output(id=...)``) should match the ``id`` of a - :func:`~shiny.ui.output_plot` container (see :func:`~shiny.ui.output_plot` for - example usage). - - See Also - -------- - ~shiny.ui.output_plot - ~shiny.render.image - """ is_userfn_async = meta["is_async"] name = meta["name"] session = meta["session"] @@ -1046,16 +644,119 @@ async def plot( ) +@overload +def plot( + _fn: None = None, + *, + alt: Optional[str] = None, + **kwargs: object, +) -> _plot.type_decorator: + ... + + +@overload +def plot(_fn: _plot.type_renderer_fn) -> _plot.type_renderer: + ... + + +def plot( + _fn: _plot.type_impl_fn = None, + *, + alt: Optional[str] = None, + **kwargs: object, +) -> _plot.type_impl: + """ + Reactively render a plot object as an HTML image. + + Parameters + ---------- + alt + Alternative text for the image if it cannot be displayed or viewed (i.e., the + user uses a screen reader). + **kwargs + Additional keyword arguments passed to the relevant method for saving the image + (e.g., for matplotlib, arguments to ``savefig()``; for PIL and plotnine, + arguments to ``save()``). + + Returns + ------- + : + A decorator for a function that returns any of the following: + + 1. A :class:`matplotlib.figure.Figure` instance. + 2. An :class:`matplotlib.artist.Artist` instance. + 3. A list/tuple of Figure/Artist instances. + 4. An object with a 'figure' attribute pointing to a + :class:`matplotlib.figure.Figure` instance. + 5. A :class:`PIL.Image.Image` instance. + + It's also possible to use the ``matplotlib.pyplot`` interface; in that case, your + function should just call pyplot functions and not return anything. (Note that if + the decorated function is async, then it's not safe to use pyplot. Shiny will detect + this case and throw an error asking you to use matplotlib's object-oriented + interface instead.) + + Tip + ---- + This decorator should be applied **before** the ``@output`` decorator. Also, the + name of the decorated function (or ``@output(id=...)``) should match the ``id`` of a + :func:`~shiny.ui.output_plot` container (see :func:`~shiny.ui.output_plot` for + example usage). + + See Also + -------- + ~shiny.ui.output_plot + ~shiny.render.image + """ + return _plot.impl(_fn, alt=alt, **kwargs) + + # ====================================================================================== # RenderImage # ====================================================================================== -@renderer -async def image( +@renderer_components +async def _image( meta: RenderMeta, fn: RenderFnAsync[ImgData | None], *, delete_file: bool = False, ) -> ImgData | None: + res = await fn() + if res is None: + return None + + src: str = res.get("src") + try: + with open(src, "rb") as f: + data = base64.b64encode(f.read()) + data_str = data.decode("utf-8") + content_type = _utils.guess_mime_type(src) + res["src"] = f"data:{content_type};base64,{data_str}" + return res + finally: + if delete_file: + os.remove(src) + + +@overload +def image( + _fn: None = None, + *, + delete_file: bool = False, +) -> _image.type_decorator: + ... + + +@overload +def image(_fn: _image.type_renderer_fn) -> _image.type_renderer: + ... + + +def image( + _fn: _image.type_impl_fn = None, + *, + delete_file: bool = False, +) -> _image.type_impl: """ Reactively render a image file as an HTML image. @@ -1082,21 +783,7 @@ async def image( ~shiny.types.ImgData ~shiny.render.plot """ - res = await 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) + return _image.impl(_fn) # ====================================================================================== @@ -1114,8 +801,8 @@ def to_pandas(self) -> "pd.DataFrame": TableResult = Union["pd.DataFrame", PandasCompatible, None] -@renderer -async def table( +@renderer_components +async def _table( meta: RenderMeta, fn: RenderFnAsync[TableResult | None], *, @@ -1124,6 +811,69 @@ async def table( border: int = 0, **kwargs: object, ) -> RenderedDeps | None: + x = await fn() + + if x is None: + return None + + import pandas + import pandas.io.formats.style + + html: str + if isinstance(x, pandas.io.formats.style.Styler): + html = cast( # pyright: ignore[reportUnnecessaryCast] + str, + x.to_html( # pyright: ignore[reportUnknownMemberType] + **kwargs # pyright: ignore[reportGeneralTypeIssues] + ), + ) + else: + if not isinstance(x, pandas.DataFrame): + if not isinstance(x, PandasCompatible): + raise TypeError( + "@render.table doesn't know how to render objects of type " + f"'{str(type(x))}'. Return either a pandas.DataFrame, or an object " + "that has a .to_pandas() method." + ) + x = x.to_pandas() + + html = cast( # pyright: ignore[reportUnnecessaryCast] + str, + x.to_html( # pyright: ignore[reportUnknownMemberType] + index=index, + classes=classes, + border=border, + **kwargs, # pyright: ignore[reportGeneralTypeIssues] + ), + ) + return {"deps": [], "html": html} + + +@overload +def table( + _fn: None = None, + *, + index: bool = False, + classes: str = "table shiny-table w-auto", + border: int = 0, + **kwargs: object, +) -> _table.type_decorator: + ... + + +@overload +def table(_fn: _table.type_renderer_fn) -> _table.type_renderer: + ... + + +def table( + _fn: _table.type_impl_fn = None, + *, + index: bool = False, + classes: str = "table shiny-table w-auto", + border: int = 0, + **kwargs: object, +) -> _table.type_impl: """ Reactively render a Pandas data frame object (or similar) as a basic HTML table. @@ -1163,52 +913,43 @@ async def table( -------- ~shiny.ui.output_table """ - x = await fn() - - if x is None: - return None - - import pandas - import pandas.io.formats.style - - html: str - if isinstance(x, pandas.io.formats.style.Styler): - html = cast( # pyright: ignore[reportUnnecessaryCast] - str, - x.to_html( # pyright: ignore[reportUnknownMemberType] - **kwargs # pyright: ignore[reportGeneralTypeIssues] - ), - ) - else: - if not isinstance(x, pandas.DataFrame): - if not isinstance(x, PandasCompatible): - raise TypeError( - "@render.table doesn't know how to render objects of type " - f"'{str(type(x))}'. Return either a pandas.DataFrame, or an object " - "that has a .to_pandas() method." - ) - x = x.to_pandas() - - html = cast( # pyright: ignore[reportUnnecessaryCast] - str, - x.to_html( # pyright: ignore[reportUnknownMemberType] - index=index, - classes=classes, - border=border, - **kwargs, # pyright: ignore[reportGeneralTypeIssues] - ), - ) - return {"deps": [], "html": html} + return _table.impl( + _fn, + index=index, + classes=classes, + border=border, + **kwargs, + ) # ====================================================================================== # RenderUI # ====================================================================================== -@renderer -async def ui( +@renderer_components +async def _ui( meta: RenderMeta, fn: RenderFnAsync[TagChild], ) -> RenderedDeps | None: + ui = await fn() + if ui is None: + return None + + return meta["session"]._process_ui(ui) + + +@overload +def ui(_fn: None = None) -> _ui.type_decorator: + ... + + +@overload +def ui(_fn: _ui.type_renderer_fn) -> _ui.type_renderer: + ... + + +def ui( + _fn: _ui.type_impl_fn = None, +) -> _ui.type_impl: """ Reactively render HTML content. @@ -1228,8 +969,4 @@ async def ui( -------- ~shiny.ui.output_ui """ - ui = await fn() - if ui is None: - return None - - return meta["session"]._process_ui(ui) + return _ui.impl(_fn) diff --git a/tests/test_renderer_gen.py b/tests/test_renderer_gen.py index 35310da7e..e44a1cefc 100644 --- a/tests/test_renderer_gen.py +++ b/tests/test_renderer_gen.py @@ -1,30 +1,42 @@ -from shiny.render._render import RenderFnAsync, RenderMeta, renderer +from typing import Any, overload +from shiny.render._render import ( + Renderer, + RenderFnAsync, + RenderMeta, + renderer_components, +) -def test_renderer_name_and_docs_are_copied(): - @renderer - async def my_handler(meta: RenderMeta, fn: RenderFnAsync[str]) -> str: - "Test docs go here" - return str(await fn()) - assert my_handler.__doc__ == "Test docs go here" - assert my_handler.__name__ == "my_handler" - - -def test_renderer_works(): +def test_renderer_components_works(): # No args works - @renderer - async def test_renderer( + @renderer_components + async def test_components( meta: RenderMeta, fn: RenderFnAsync[str], ): ... + @overload + def test_renderer() -> test_components.type_decorator: + ... + + @overload + def test_renderer( + _fn: test_components.type_renderer_fn, + ) -> test_components.type_renderer: + ... + + def test_renderer( + _fn: test_components.type_impl_fn = None, + ) -> test_components.type_impl: + return test_components.impl(_fn) -def test_renderer_kwargs_are_allowed(): + +def test_renderer_components_kwargs_are_allowed(): # Test that kwargs can be allowed - @renderer - async def test_renderer( + @renderer_components + async def test_components( meta: RenderMeta, fn: RenderFnAsync[str], *, @@ -32,11 +44,28 @@ async def test_renderer( ): ... + @overload + def test_renderer(*, y: str = "42") -> test_components.type_decorator: + ... + + @overload + def test_renderer( + _fn: test_components.type_renderer_fn, + ) -> test_components.type_renderer: + ... + + def test_renderer( + _fn: test_components.type_impl_fn = None, + *, + y: str = "42", + ) -> test_components.type_impl: + return test_components.impl(_fn, y=y) + -def test_renderer_with_pass_through_kwargs(): +def test_renderer_components_with_pass_through_kwargs(): # No args works - @renderer - async def test_renderer( + @renderer_components + async def test_components( meta: RenderMeta, fn: RenderFnAsync[str], *, @@ -45,12 +74,32 @@ async def test_renderer( ): ... + @overload + def test_renderer( + *, y: str = "42", **kwargs: Any + ) -> test_components.type_decorator: + ... + + @overload + def test_renderer( + _fn: test_components.type_renderer_fn, + ) -> test_components.type_renderer: + ... + + def test_renderer( + _fn: test_components.type_impl_fn = None, + *, + y: str = "42", + **kwargs: Any, + ) -> test_components.type_impl: + return test_components.impl(_fn, y=y, **kwargs) + -def test_renderer_limits_positional_arg_count(): +def test_renderer_components_limits_positional_arg_count(): try: - @renderer - async def test_renderer( + @renderer_components + async def test_components( meta: RenderMeta, fn: RenderFnAsync[str], y: str, @@ -62,11 +111,11 @@ async def test_renderer( assert "more than 2 positional" in str(e) -def test_renderer_does_not_allow_args(): +def test_renderer_components_does_not_allow_args(): try: - @renderer - async def test_renderer( + @renderer_components + async def test_components( meta: RenderMeta, fn: RenderFnAsync[str], *args: str, @@ -79,11 +128,11 @@ async def test_renderer( assert "No variadic parameters" in str(e) -def test_renderer_kwargs_have_defaults(): +def test_renderer_components_kwargs_have_defaults(): try: - @renderer - async def test_renderer( + @renderer_components + async def test_components( meta: RenderMeta, fn: RenderFnAsync[str], *, @@ -97,11 +146,11 @@ async def test_renderer( assert "did not have a default value" in str(e) -def test_renderer_kwargs_can_not_be_name_render_fn(): +def test_renderer_components_kwargs_can_not_be_name_render_fn(): try: - @renderer - async def test_renderer( + @renderer_components + async def test_components( meta: RenderMeta, fn: RenderFnAsync[str], *, @@ -115,9 +164,9 @@ async def test_renderer( assert "parameters can not be named `_render_fn`" in str(e) -def test_renderer_result_does_not_allow_args(): - @renderer - async def test_renderer( +def test_renderer_components_result_does_not_allow_args(): + @renderer_components + async def test_components( meta: RenderMeta, fn: RenderFnAsync[str], ): @@ -131,34 +180,34 @@ async def render_fn_async(*args: str): return " ".join(args) try: - test_renderer( # type: ignore + test_components( # type: ignore "X", "Y", - )(render_fn_sync) + ).impl(render_fn_sync) raise RuntimeError() except RuntimeError as e: assert "`args` should not be supplied" in str(e) try: - test_renderer( # type: ignore + test_components( # type: ignore "X", "Y", - )(render_fn_async) + ).impl(render_fn_async) except RuntimeError as e: assert "`args` should not be supplied" in str(e) -def test_renderer_makes_calls_render_fn_once(): - @renderer - async def test_renderer_no_calls( +def test_renderer_components_makes_calls_render_fn_once(): + @renderer_components + async def test_renderer_components_no_calls( meta: RenderMeta, fn: RenderFnAsync[str], ): # Does not call `fn` return "Not 42" - @renderer - async def test_renderer_multiple_calls( + @renderer_components + async def test_renderer_components_multiple_calls( meta: RenderMeta, fn: RenderFnAsync[str], ): @@ -169,26 +218,28 @@ async def test_renderer_multiple_calls( def render_fn(): return "42" - # try: - renderer_fn_none = test_renderer_no_calls(render_fn) + renderer_fn_none = test_renderer_components_no_calls.impl(render_fn) renderer_fn_none._set_metadata(None, "test_out") # type: ignore - + if not isinstance(renderer_fn_none, Renderer): + raise RuntimeError() try: renderer_fn_none() raise RuntimeError() except RuntimeError as e: assert ( str(e) - == "The total number of calls (`0`) to 'render_fn' in the 'test_renderer_no_calls' handler did not equal `1`." + == "The total number of calls (`0`) to 'render_fn' in the 'test_renderer_components_no_calls' handler did not equal `1`." ) - renderer_fn_multiple = test_renderer_multiple_calls(render_fn) + renderer_fn_multiple = test_renderer_components_multiple_calls.impl(render_fn) renderer_fn_multiple._set_metadata(None, "test_out") # type: ignore + if not isinstance(renderer_fn_multiple, Renderer): + raise RuntimeError() try: renderer_fn_multiple() raise RuntimeError() except RuntimeError as e: assert ( str(e) - == "The total number of calls (`2`) to 'render_fn' in the 'test_renderer_multiple_calls' handler did not equal `1`." + == "The total number of calls (`2`) to 'render_fn' in the 'test_renderer_components_multiple_calls' handler did not equal `1`." ) From ad1d285dcec0c5417394e5eca2f965a4c1989c20 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 1 Aug 2023 11:16:38 -0400 Subject: [PATCH 21/64] Clean up renderer components. Add some docs (needs more) --- e2e/server/renderer_gen/app.py | 5 +- shiny/render/__init__.py | 2 + shiny/render/_dataframe.py | 2 +- shiny/render/_render.py | 354 +++++++++++++++------------------ tests/test_renderer_gen.py | 116 +++-------- 5 files changed, 191 insertions(+), 288 deletions(-) diff --git a/e2e/server/renderer_gen/app.py b/e2e/server/renderer_gen/app.py index 07d29ae22..f4ea347bb 100644 --- a/e2e/server/renderer_gen/app.py +++ b/e2e/server/renderer_gen/app.py @@ -43,7 +43,10 @@ def render_test_text( *, extra_txt: Optional[str] = None, ) -> _render_test_text_components.type_impl: - return _render_test_text_components.impl(_fn, extra_txt=extra_txt) + return _render_test_text_components.impl( + _fn, + _render_test_text_components.params(extra_txt=extra_txt), + ) app_ui = ui.page_fluid( diff --git a/shiny/render/__init__.py b/shiny/render/__init__.py index bf4121f79..d88a61d19 100644 --- a/shiny/render/__init__.py +++ b/shiny/render/__init__.py @@ -6,6 +6,8 @@ # Import these values, but do not give autocomplete hints for `shiny.render.FOO` RenderMeta as RenderMeta, RenderFnAsync as RenderFnAsync, + RendererParams as RendererParams, + RendererComponents as RendererComponents, renderer_components as renderer_components, text, plot, diff --git a/shiny/render/_dataframe.py b/shiny/render/_dataframe.py index a4b7a7f08..a9d66663e 100644 --- a/shiny/render/_dataframe.py +++ b/shiny/render/_dataframe.py @@ -223,7 +223,7 @@ async def _data_frame( @overload -def data_frame(_fn: None = None) -> _data_frame.type_decorator: +def data_frame() -> _data_frame.type_decorator: ... diff --git a/shiny/render/_render.py b/shiny/render/_render.py index a162cc20c..3e977fa14 100644 --- a/shiny/render/_render.py +++ b/shiny/render/_render.py @@ -2,23 +2,11 @@ # See https://www.python.org/dev/peps/pep-0655/#usage-in-python-3-11 from __future__ import annotations -# TODO-barret; change the name of the returned function from renderer function in the overload. If anything, use `_`. -# TODO-barret; See if @overload will work on the returned already-overloaded function -# * From initial attempts, it does not work. :-( -# * TODO-barret; Make a helper method to return all types (and function?) that could be used to make the overload signatures manually -# TODO-barret; Changelog that RenderFunction no longer exists. -# TODO-barret; Should `Renderer` be exported? +# TODO-barret; Revert base classes and use the original classes? +# TODO-barret; Changelog - that RenderFunction no longer exists. + # TODO-barret; Test for `"`@reactive.event()` must be applied before `@render.xx` .\n"`` # TODO-barret; Test for `"`@output` must be applied to a `@render.xx` function.\n"` -# TODO-barret; Rename `RendererDecorator` to `Renderer`?; Rename `Renderer` to something else -# TODO-barret; Add in `IT` to RendererDecorator to enforce return type - -# TODO-barret: Plan of action: If we can get barret2 doc string to be ported, then we use the class with no manual overrides. If we can NOT get the docs string, use manual override approach only -# TODO-barret; Use the manual approach only -# Revert base classes and use the original classes - -# TODO-barret; Look into using a ParamSpecValue class to contain the values of the ParamSpec - __all__ = ( "text", @@ -33,16 +21,14 @@ import os import sys import typing - -# import random from typing import ( TYPE_CHECKING, + Any, Awaitable, Callable, Generic, Optional, Protocol, - Tuple, TypeVar, Union, cast, @@ -50,11 +36,6 @@ runtime_checkable, ) -# # These aren't used directly in this file, but they seem necessary for Sphinx to work -# # cleanly. -# from htmltools import Tag # pyright: ignore[reportUnusedImport] # noqa: F401 -# from htmltools import Tagifiable # pyright: ignore[reportUnusedImport] # noqa: F401 -# from htmltools import TagList # pyright: ignore[reportUnusedImport] # noqa: F401 from htmltools import TagChild if TYPE_CHECKING: @@ -74,57 +55,88 @@ OT = TypeVar("OT") # Param specification for render_fn function P = ParamSpec("P") -# Generic type var -T = TypeVar("T") # ====================================================================================== -# Type definitions +# Helper classes # ====================================================================================== -# class RenderFnSync(Generic[IT]): -# def __call__(self) -> IT: -# ... - +# Meta information to give `hander()` some context +class RenderMeta(TypedDict): + """ + Renderer meta information -# class RenderFnAsync(Generic[IT]): -# async def __call__(self) -> IT: -# ... + This class is used to hold meta information for a renderer handler function. + Properties + ---------- + is_async + If `TRUE`, the app-supplied render function is asynchronous. + session + The :class:`~shiny.Session` object of the current render function. + name + The name of the output being rendered. + """ -RenderFnSync = Callable[[], IT] -RenderFnAsync = Callable[[], Awaitable[IT]] -RenderFn = RenderFnSync[IT] | RenderFnAsync[IT] -HandlerFn = Callable[Concatenate["RenderMeta", RenderFnAsync[IT], P], Awaitable[OT]] + is_async: bool + session: Session + name: str -_RenderArgsSync = Tuple[RenderFnSync[IT], HandlerFn[IT, P, OT]] -_RenderArgsAsync = Tuple[RenderFnAsync[IT], HandlerFn[IT, P, OT]] -_RenderArgs = Union[_RenderArgsSync[IT, P, OT], _RenderArgsAsync[IT, P, OT]] +class RendererParams(Generic[P]): + """ + Parameters for a renderer function -RenderDeco = Callable[ - [Union[RenderFnSync[IT], RenderFnAsync[IT]]], - "Renderer[OT]" - # Union["RendererSync[OT]", "RendererAsync[OT]"], -] + This class is used to hold the parameters for a renderer function. It is used to + enforce that the parameters are used in the correct order. + Properties + ---------- + *args + No positional arguments should be supplied. Only keyword arguments should be + supplied. + **kwargs + Keyword arguments for the corresponding renderer function. + """ -# ====================================================================================== -# Helper classes -# ====================================================================================== + # Motivation for using this class: + # * https://peps.python.org/pep-0612/ does allow for prepending an arg (e.g. + # `render_fn`). + # * However, the overload is not happy when both a positional arg (e.g. + # `render_fn`) is dropped and the variadic args (`*args`) are kept. + # * The variadic args (`*args`) CAN NOT be dropped as PEP612 states that both + # components of the `ParamSpec` must be used in the same function signature. + # * By making assertions on `P.args` to only allow for `*`, we _can_ make overloads + # that use either the single positional arg (e.g. `render_fn`) or the `P.kwargs` + # (as `P.args` == `*`) + def __init__(self, *args: P.args, **kwargs: P.kwargs) -> None: + """ + Properties + ---------- + *args + No positional arguments should be supplied. Only keyword arguments should be + supplied. + **kwargs + Keyword arguments for the corresponding renderer function. + """ + # `*args` must be defined with `**kwargs` + # Make sure there no `args` when running! + if len(args) > 0: + raise RuntimeError("`args` should not be supplied") -# Meta information to give `hander()` some context -class RenderMeta(TypedDict): - is_async: bool - session: Session - name: str + self.args = args + self.kwargs = kwargs # ====================================================================================== # Renderer / RendererSync / RendererAsync base class # ====================================================================================== +RenderFnSync = Callable[[], IT] +RenderFnAsync = Callable[[], Awaitable[IT]] +RenderFn = RenderFnSync[IT] | RenderFnAsync[IT] +HandlerFn = Callable[Concatenate[RenderMeta, RenderFnAsync[IT], P], Awaitable[OT]] # A Renderer object is given a user-provided function (`handler_fn`) which returns an @@ -147,12 +159,9 @@ class Renderer(Generic[OT]): Properties ---------- - is_async - If `TRUE`, the app-supplied render function is asynchronous meta A named dictionary of values: `is_async`, `session` (the :class:`~shiny.Session` object), and `name` (the name of the output being rendered) - """ def __call__(self, *_) -> OT: @@ -173,13 +182,13 @@ def __init__(self, *, name: str, doc: str | None) -> None: self.__doc__ = doc @property - def is_async(self) -> bool: + def _is_async(self) -> bool: raise NotImplementedError() @property def meta(self) -> RenderMeta: return RenderMeta( - is_async=self.is_async, + is_async=self._is_async, session=self._session, name=self._name, ) @@ -193,21 +202,13 @@ def _set_metadata(self, session: Session, name: str) -> None: self._name: str = name -# Include class RendererRun(Renderer[OT]): def __init__( self, - # Use single arg to minimize overlap with P.kwargs - _render_args: _RenderArgs[IT, P, OT], - *args: P.args, - **kwargs: P.kwargs, + render_fn: RenderFn[IT], + handler_fn: HandlerFn[IT, P, OT], + params: RendererParams[P], ) -> None: - # `*args` must be in the `__init__` signature - # Make sure there no `args`! - _assert_no_args(args) - - # Unpack args - render_fn, handler_fn = _render_args if not _utils.is_async_callable(handler_fn): raise TypeError( self.__class__.__name__ + " requires an async handler function" @@ -221,9 +222,7 @@ def __init__( # we can act as if `render_fn` and `handler_fn` are always async self._render_fn = _utils.wrap_async(render_fn) self._handler_fn = _utils.wrap_async(handler_fn) - - self._args = args - self._kwargs = kwargs + self._params = params async def _run(self) -> OT: ret = await self._handler_fn( @@ -232,8 +231,8 @@ async def _run(self) -> OT: # Callable[[], Awaitable[IT]] self._render_fn, # P - *self._args, - **self._kwargs, + *self._params.args, + **self._params.kwargs, ) return ret @@ -241,26 +240,24 @@ async def _run(self) -> OT: # Using a second class to help clarify that it is of a particular type class RendererSync(RendererRun[OT]): @property - def is_async(self) -> bool: + def _is_async(self) -> bool: return False def __init__( self, - # Use single arg to minimize overlap with P.kwargs - _render_args: _RenderArgsSync[IT, P, OT], - *args: P.args, - **kwargs: P.kwargs, + render_fn: RenderFnSync[IT], + handler_fn: HandlerFn[IT, P, OT], + params: RendererParams[P], ) -> None: - render_fn = _render_args[0] if _utils.is_async_callable(render_fn): raise TypeError( self.__class__.__name__ + " requires a synchronous render function" ) # super == RendererRun super().__init__( - _render_args, - *args, - **kwargs, + render_fn, + handler_fn, + params, ) def __call__(self, *_) -> OT: @@ -272,26 +269,24 @@ def __call__(self, *_) -> OT: # be either sync or async. class RendererAsync(RendererRun[OT]): @property - def is_async(self) -> bool: + def _is_async(self) -> bool: return True def __init__( self, - # Use single arg to minimize overlap with P.kwargs - _render_args: _RenderArgsAsync[IT, P, OT], - *args: P.args, - **kwargs: P.kwargs, + render_fn: RenderFnAsync[IT], + handler_fn: HandlerFn[IT, P, OT], + params: RendererParams[P], ) -> None: - render_fn = _render_args[0] if not _utils.is_async_callable(render_fn): raise TypeError( self.__class__.__name__ + " requires an asynchronous render function" ) # super == RendererRun super().__init__( - _render_args, - *args, - **kwargs, + render_fn, + handler_fn, + params, ) async def __call__( # pyright: ignore[reportIncompatibleMethodOverride] @@ -305,11 +300,6 @@ async def __call__( # pyright: ignore[reportIncompatibleMethodOverride] # ====================================================================================== -def _assert_no_args(args: tuple[object]) -> None: - if len(args) > 0: - raise RuntimeError("`args` should not be supplied") - - # assert: No variable length positional values; # * We need a way to distinguish between a plain function and args supplied to the next function. This is done by not allowing `*args`. # assert: All kwargs of handler_fn should have a default value @@ -317,11 +307,24 @@ def _assert_no_args(args: tuple[object]) -> None: def _assert_handler_fn(handler_fn: HandlerFn[IT, P, OT]) -> None: params = inspect.Signature.from_callable(handler_fn).parameters + if len(params) < 2: + raise TypeError( + "`handler_fn=` must have 2 positional parameters which have type `RenderMeta` and `RenderFnAsync` respectively" + ) + for i, param in zip(range(len(params)), params.values()): # # Not a good test as `param.annotation` has type `str`: # if i == 0: - # print(type(param.annotation)) - # assert isinstance(param.annotation, RendererMeta) + # assert param.annotation == "RenderMeta" + # if i == 1: + # assert (param.annotation or "").startswith("RenderFnAsync") + if i < 2 and not ( + param.kind == inspect.Parameter.POSITIONAL_OR_KEYWORD + or param.kind == inspect.Parameter.POSITIONAL_ONLY + ): + raise TypeError( + "`handler_fn=` must have 2 positional parameters which have type `RenderMeta` and `RenderFnAsync` respectively" + ) # Make sure there are no more than 2 positional args if i >= 2 and param.kind == inspect.Parameter.POSITIONAL_OR_KEYWORD: @@ -331,43 +334,37 @@ def _assert_handler_fn(handler_fn: HandlerFn[IT, P, OT]) -> None: # Make sure there are no `*args` if param.kind == inspect.Parameter.VAR_POSITIONAL: raise TypeError( - f"No variadic parameters (e.g. `*args`) can be supplied to `handler_fn=`. Received: `{param.name}`" + f"No variadic parameters (e.g. `*args`) can be supplied to `handler_fn=`. Received: `{param.name}`. Please only use `*`." + ) + # Make sure kwargs have default values + if ( + param.kind == inspect.Parameter.KEYWORD_ONLY + and param.default is inspect.Parameter.empty + ): + raise TypeError( + f"In `handler_fn=`, parameter `{param.name}` did not have a default value" ) - if param.kind == inspect.Parameter.KEYWORD_ONLY: - # Do not allow for a kwarg to be named `_render_fn` or `_render_args` - if param.name == "_render_fn": - raise ValueError( - "In `handler_fn=`, parameters can not be named `_render_fn`" - ) - if param.name == "_render_args": - raise ValueError( - "In `handler_fn=`, parameters can not be named `_render_args`" - ) - # Make sure kwargs have default values - if param.default is inspect.Parameter.empty: - raise TypeError( - f"In `handler_fn=`, parameter `{param.name}` did not have a default value" - ) # ====================================================================================== # Renderer decorator # ====================================================================================== + RendererDeco = Callable[[RenderFn[IT]], Renderer[OT]] RenderImplFn = Callable[ - Concatenate[ + [ Optional[RenderFn[IT]], - P, + RendererParams[P], ], - # RendererSync[OT] | RendererAsync[OT] | RenderDeco[IT, OT], - Renderer[OT] | RenderDeco[IT, OT], + # RendererSync[OT] | RendererAsync[OT] | RendererDeco[IT, OT], + Renderer[OT] | RendererDeco[IT, OT], ] class RendererComponents(Generic[IT, OT, P]): """ - Renderer Decorator class docs go here! + Renderer Components class docs go here! """ @property @@ -390,27 +387,25 @@ def type_impl_fn(self): def type_impl(self): return Renderer[OT] | RendererDeco[IT, OT] - # @property - # def impl(self): - # return self._fn - - # @overload - # def impl( - # self, _render_fn: None = None, *args: P.args, **kwargs: P.kwargs - # ) -> RendererDeco[IT, OT]: - # ... - - # @overload - # def impl(self, _render_fn: RenderFn[IT]) -> Renderer[OT]: - # ... - - def impl( + def params( self, - _render_fn: Optional[RenderFn[IT]] = None, *args: P.args, **kwargs: P.kwargs, + ) -> RendererParams[P]: + return RendererParams(*args, **kwargs) + + def impl( + self, + render_fn: RenderFn[IT] | None, + params: RendererParams[P] | None = None, ) -> Renderer[OT] | RendererDeco[IT, OT]: - return self._fn(_render_fn, *args, **kwargs) + if params is None: + params = self.params() + if not isinstance(params, RendererParams): + raise TypeError( + f"Expected `params` to be of type `RendererParams` but received `{type(params)}`. Please use `.params()` to create a `RendererParams` object." + ) + return self._fn(render_fn, params) def __init__( self, @@ -423,67 +418,34 @@ def renderer_components( handler_fn: HandlerFn[IT, P, OT], ) -> RendererComponents[IT, OT, P]: """\ - Renderer decorator generator + Renderer components decorator TODO-barret; Docs go here! + When defining overloads, if you use `**kwargs: object`, you may get a type error about incompatible signatures. To fix this, you can use `**kwargs: Any` instead or add `_fn: None = None` as a first parameter. """ _assert_handler_fn(handler_fn) - # Ignoring the type issue on the next line of code as the overloads for - # `renderer_deco` are not consistent with the function definition. - # Motivation: - # * https://peps.python.org/pep-0612/ does allow for prepending an arg (e.g. - # `_render_fn`). - # * However, the overload is not happy when both a positional arg (e.g. - # `_render_fn`) is dropped and the variadic args (`*args`) are kept. - # * The variadic args CAN NOT be dropped as PEP612 states that both components of - # the `ParamSpec` must be used in the same function signature. - # * By making assertions on `P.args` to only allow for `*`, we _can_ make overloads - # that use either the single positional arg (e.g. `_render_fn`) or the `P.kwargs` - # (as `P.args` == `*`) - # @functools.wraps( - # handler_fn, assigned=("__module__", "__name__", "__qualname__", "__doc__") - # ) def renderer_decorator( - _render_fn: Optional[RenderFnSync[IT] | RenderFnAsync[IT]] = None, - *args: P.args, # Equivalent to `*` after assertions in `_assert_handler_fn()` - # *, - **kwargs: P.kwargs, - # ) -> RenderImplFn[IT, P, OT]: - ): - # `args` **must** be in `renderer_decorator` definition. - # Make sure there no `args`! - _assert_no_args(args) - + render_fn: RenderFnSync[IT] | RenderFnAsync[IT] | None, + params: RendererParams[P], + ) -> Renderer[OT] | RendererDeco[IT, OT]: def as_render_fn( fn: RenderFnSync[IT] | RenderFnAsync[IT], ) -> Renderer[OT]: if _utils.is_async_callable(fn): - return RendererAsync( - (fn, handler_fn), - *args, - **kwargs, - ) - + return RendererAsync(fn, handler_fn, params) else: - # `fn` is not Async, cast as Sync fn = cast(RenderFnSync[IT], fn) - return RendererSync( - (fn, handler_fn), - *args, - **kwargs, - ) + return RendererSync(fn, handler_fn, params) - if _render_fn is None: + if render_fn is None: return as_render_fn - val = as_render_fn(_render_fn) + val = as_render_fn(render_fn) return val - ret = RendererComponents[IT, OT, P]( - renderer_decorator, - ) - ret.__doc__ = handler_fn.__doc__ - ret.__class__.__doc__ = handler_fn.__doc__ + ret = RendererComponents(renderer_decorator) + # Copy over docs. Even if they do not show up in pylance + # ret.__doc__ = handler_fn.__doc__ return ret @@ -504,7 +466,7 @@ async def _text( @overload -def text(_fn: None = None) -> _text.type_decorator: +def text() -> _text.type_decorator: ... @@ -559,7 +521,6 @@ async def _plot( ppi: float = 96 - # TODO-barret; Q: These variable calls are **after** `self._render_fn()`. Is this ok? inputs = session.root_scope().input # Reactively read some information about the plot. @@ -646,10 +607,9 @@ async def _plot( @overload def plot( - _fn: None = None, *, alt: Optional[str] = None, - **kwargs: object, + **kwargs: Any, ) -> _plot.type_decorator: ... @@ -663,7 +623,7 @@ def plot( _fn: _plot.type_impl_fn = None, *, alt: Optional[str] = None, - **kwargs: object, + **kwargs: Any, ) -> _plot.type_impl: """ Reactively render a plot object as an HTML image. @@ -708,7 +668,7 @@ def plot( ~shiny.ui.output_plot ~shiny.render.image """ - return _plot.impl(_fn, alt=alt, **kwargs) + return _plot.impl(_fn, _plot.params(alt=alt, **kwargs)) # ====================================================================================== @@ -740,7 +700,6 @@ async def _image( @overload def image( - _fn: None = None, *, delete_file: bool = False, ) -> _image.type_decorator: @@ -783,7 +742,7 @@ def image( ~shiny.types.ImgData ~shiny.render.plot """ - return _image.impl(_fn) + return _image.impl(_fn, _image.params(delete_file=delete_file)) # ====================================================================================== @@ -851,12 +810,11 @@ async def _table( @overload def table( - _fn: None = None, *, index: bool = False, classes: str = "table shiny-table w-auto", border: int = 0, - **kwargs: object, + **kwargs: Any, ) -> _table.type_decorator: ... @@ -915,10 +873,12 @@ def table( """ return _table.impl( _fn, - index=index, - classes=classes, - border=border, - **kwargs, + _table.params( + index=index, + classes=classes, + border=border, + **kwargs, + ), ) @@ -938,7 +898,7 @@ async def _ui( @overload -def ui(_fn: None = None) -> _ui.type_decorator: +def ui() -> _ui.type_decorator: ... diff --git a/tests/test_renderer_gen.py b/tests/test_renderer_gen.py index e44a1cefc..814aa4016 100644 --- a/tests/test_renderer_gen.py +++ b/tests/test_renderer_gen.py @@ -1,11 +1,6 @@ from typing import Any, overload -from shiny.render._render import ( - Renderer, - RenderFnAsync, - RenderMeta, - renderer_components, -) +from shiny.render._render import RenderFnAsync, RenderMeta, renderer_components def test_renderer_components_works(): @@ -59,7 +54,10 @@ def test_renderer( *, y: str = "42", ) -> test_components.type_impl: - return test_components.impl(_fn, y=y) + return test_components.impl( + _fn, + test_components.params(y=y), + ) def test_renderer_components_with_pass_through_kwargs(): @@ -92,61 +90,60 @@ def test_renderer( y: str = "42", **kwargs: Any, ) -> test_components.type_impl: - return test_components.impl(_fn, y=y, **kwargs) + return test_components.impl( + _fn, + test_components.params(y=y, **kwargs), + ) -def test_renderer_components_limits_positional_arg_count(): +def test_renderer_components_pos_args(): try: - @renderer_components + @renderer_components # type: ignore async def test_components( meta: RenderMeta, - fn: RenderFnAsync[str], - y: str, ): ... raise RuntimeError() except TypeError as e: - assert "more than 2 positional" in str(e) + assert "must have 2 positional parameters" in str(e) -def test_renderer_components_does_not_allow_args(): +def test_renderer_components_limits_positional_arg_count(): try: @renderer_components async def test_components( meta: RenderMeta, fn: RenderFnAsync[str], - *args: str, + y: str, ): ... raise RuntimeError() - except TypeError as e: - assert "No variadic parameters" in str(e) + assert "more than 2 positional" in str(e) -def test_renderer_components_kwargs_have_defaults(): +def test_renderer_components_does_not_allow_args(): try: @renderer_components async def test_components( meta: RenderMeta, fn: RenderFnAsync[str], - *, - y: str, + *args: str, ): ... raise RuntimeError() except TypeError as e: - assert "did not have a default value" in str(e) + assert "No variadic parameters" in str(e) -def test_renderer_components_kwargs_can_not_be_name_render_fn(): +def test_renderer_components_kwargs_have_defaults(): try: @renderer_components @@ -154,14 +151,14 @@ async def test_components( meta: RenderMeta, fn: RenderFnAsync[str], *, - _render_fn: str, + y: str, ): ... raise RuntimeError() - except ValueError as e: - assert "parameters can not be named `_render_fn`" in str(e) + except TypeError as e: + assert "did not have a default value" in str(e) def test_renderer_components_result_does_not_allow_args(): @@ -176,70 +173,11 @@ async def test_components( def render_fn_sync(*args: str): return " ".join(args) - async def render_fn_async(*args: str): - return " ".join(args) - - try: - test_components( # type: ignore - "X", - "Y", - ).impl(render_fn_sync) - raise RuntimeError() - except RuntimeError as e: - assert "`args` should not be supplied" in str(e) - - try: - test_components( # type: ignore - "X", - "Y", - ).impl(render_fn_async) - except RuntimeError as e: - assert "`args` should not be supplied" in str(e) - - -def test_renderer_components_makes_calls_render_fn_once(): - @renderer_components - async def test_renderer_components_no_calls( - meta: RenderMeta, - fn: RenderFnAsync[str], - ): - # Does not call `fn` - return "Not 42" - - @renderer_components - async def test_renderer_components_multiple_calls( - meta: RenderMeta, - fn: RenderFnAsync[str], - ): - # Calls `fn` > 1 times - return f"{await fn()} - {await fn()}" - - # Test that args can **not** be supplied - def render_fn(): - return "42" - - renderer_fn_none = test_renderer_components_no_calls.impl(render_fn) - renderer_fn_none._set_metadata(None, "test_out") # type: ignore - if not isinstance(renderer_fn_none, Renderer): - raise RuntimeError() try: - renderer_fn_none() - raise RuntimeError() - except RuntimeError as e: - assert ( - str(e) - == "The total number of calls (`0`) to 'render_fn' in the 'test_renderer_components_no_calls' handler did not equal `1`." + test_components.impl( + render_fn_sync, + "X", # type: ignore ) - - renderer_fn_multiple = test_renderer_components_multiple_calls.impl(render_fn) - renderer_fn_multiple._set_metadata(None, "test_out") # type: ignore - if not isinstance(renderer_fn_multiple, Renderer): - raise RuntimeError() - try: - renderer_fn_multiple() raise RuntimeError() - except RuntimeError as e: - assert ( - str(e) - == "The total number of calls (`2`) to 'render_fn' in the 'test_renderer_components_multiple_calls' handler did not equal `1`." - ) + except TypeError as e: + assert "Expected `params` to be of type `RendererParams`" in str(e) From 5c7c4185657d7ca56a916264395691a21868a039 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 1 Aug 2023 11:17:47 -0400 Subject: [PATCH 22/64] Rename files --- e2e/server/{renderer_gen => renderer}/app.py | 0 .../test_renderer_gen.py => renderer/test_renderer.py} | 0 tests/{test_renderer_gen.py => test_renderer.py} | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename e2e/server/{renderer_gen => renderer}/app.py (100%) rename e2e/server/{renderer_gen/test_renderer_gen.py => renderer/test_renderer.py} (100%) rename tests/{test_renderer_gen.py => test_renderer.py} (100%) diff --git a/e2e/server/renderer_gen/app.py b/e2e/server/renderer/app.py similarity index 100% rename from e2e/server/renderer_gen/app.py rename to e2e/server/renderer/app.py diff --git a/e2e/server/renderer_gen/test_renderer_gen.py b/e2e/server/renderer/test_renderer.py similarity index 100% rename from e2e/server/renderer_gen/test_renderer_gen.py rename to e2e/server/renderer/test_renderer.py diff --git a/tests/test_renderer_gen.py b/tests/test_renderer.py similarity index 100% rename from tests/test_renderer_gen.py rename to tests/test_renderer.py From 4444e71d282eeecca4c6eab8e54377fefef717c6 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 1 Aug 2023 12:59:39 -0400 Subject: [PATCH 23/64] Docs. Still more docs to come --- shiny/render/__init__.py | 18 +++-- shiny/render/_render.py | 157 ++++++++++++++++++++++++++++++++------- 2 files changed, 142 insertions(+), 33 deletions(-) diff --git a/shiny/render/__init__.py b/shiny/render/__init__.py index d88a61d19..d8007b011 100644 --- a/shiny/render/__init__.py +++ b/shiny/render/__init__.py @@ -3,22 +3,28 @@ """ from ._render import ( # noqa: F401 - # Import these values, but do not give autocomplete hints for `shiny.render.FOO` - RenderMeta as RenderMeta, - RenderFnAsync as RenderFnAsync, - RendererParams as RendererParams, - RendererComponents as RendererComponents, - renderer_components as renderer_components, + # Exported. Give autocomplete hints for `shiny.render.FOO` text, plot, image, table, ui, + # Manual imports. Do not give autocomplete hints. + RenderMeta as RenderMeta, + RenderFnAsync as RenderFnAsync, + RendererParams as RendererParams, + RendererComponents as RendererComponents, + renderer_components as renderer_components, + # Deprecated / legacy classes + RenderFunction as RenderFunction, + RenderFunctionAsync as RenderFunctionAsync, ) from ._dataframe import ( # noqa: F401 + # Manual imports DataGrid as DataGrid, DataTable as DataTable, + # Exported data_frame, ) diff --git a/shiny/render/_render.py b/shiny/render/_render.py index 3e977fa14..e70c7b264 100644 --- a/shiny/render/_render.py +++ b/shiny/render/_render.py @@ -3,10 +3,8 @@ from __future__ import annotations # TODO-barret; Revert base classes and use the original classes? -# TODO-barret; Changelog - that RenderFunction no longer exists. +# TODO-barret; Changelog - that RenderFunction no longer exists or deprecated -# TODO-barret; Test for `"`@reactive.event()` must be applied before `@render.xx` .\n"`` -# TODO-barret; Test for `"`@output` must be applied to a `@render.xx` function.\n"` __all__ = ( "text", @@ -145,26 +143,32 @@ class Renderer(Generic[OT]): """ Output Renderer - Base class to build :class:`~shiny.render.RendererSync` and :class:`~shiny.render.RendererAsync`. + Base class to build up :class:`~shiny.render.RendererSync` and + :class:`~shiny.render.RendererAsync`. - - When the `.__call__` method is invoked, the handler function (which defined by + When the `.__call__` method is invoked, the handler function (typically defined by package authors) is called. The handler function is given `meta` information, the - (app-supplied) render function, and any keyword arguments supplied to the decorator. + (app-supplied) render function, and any keyword arguments supplied to the render + decorator. - The render function should return type `IT` and has parameter specification of type - `P`. The handler function should return type `OT`. Note that in many cases but not - all, `IT` and `OT` will be the same. `None` values must always be defined in `IT` and `OT`. + The (app-supplied) render function should return type `IT`. The handler function + (defined by package authors) defines the parameter specification of type `P` and + should asynchronously return an object of type `OT`. Note that in many cases but not + all, `IT` and `OT` will be the same. `None` values must always be defined in `IT` + and `OT`. - Properties - ---------- - meta - A named dictionary of values: `is_async`, `session` (the :class:`~shiny.Session` - object), and `name` (the name of the output being rendered) + See Also + -------- + * :class:`~shiny.render.RendererRun` + * :class:`~shiny.render.RendererSync` + * :class:`~shiny.render.RendererAsync` """ def __call__(self, *_) -> OT: + """ + Executes the renderer as a function. Must be implemented by subclasses. + """ raise NotImplementedError def __init__(self, *, name: str, doc: str | None) -> None: @@ -176,11 +180,44 @@ def __init__(self, *, name: str, doc: str | None) -> None: name Name of original output function. Ex: `my_txt` doc - Documentation of the output function. Ex: `"My text output will be displayed verbatim". + Documentation of the output function. Ex: `"My text output will be displayed + verbatim". """ self.__name__ = name self.__doc__ = doc + 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 + + +class RendererRun(Renderer[OT]): + """ + Convenience class to define a `_run` method + + This class is used to define a `_run` method that is called by the `.__call__` + method in subclasses. + + Properties + ---------- + _is_async + If `TRUE`, the app-supplied render function is asynchronous. Must be implemented + in subclasses. + meta + A named dictionary of values: `is_async`, `session` (the :class:`~shiny.Session` + object), and `name` (the name of the output being rendered) + + See Also + -------- + * :class:`~shiny.render.Renderer` + * :class:`~shiny.render.RendererSync` + * :class:`~shiny.render.RendererAsync` + """ + @property def _is_async(self) -> bool: raise NotImplementedError() @@ -193,16 +230,6 @@ def meta(self) -> RenderMeta: name=self._name, ) - 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 - - -class RendererRun(Renderer[OT]): def __init__( self, render_fn: RenderFn[IT], @@ -225,6 +252,14 @@ def __init__( self._params = params async def _run(self) -> OT: + """ + Executes the (async) handler function + + The handler function will receive the following arguments: `meta` of type :class:`~shiny.render.RenderMeta`, an app-defined render function of type :class:`~shiny.render.RenderFnAsync`, and `*args` and `**kwargs` of type `P`. + + Note: The app-defined render function will always be upgraded to be an async function. + Note: `*args` will always be empty as it is an expansion of :class:`~shiny.render.RendererParams` which does not allow positional arguments. + """ ret = await self._handler_fn( # RendererMeta self.meta, @@ -239,6 +274,24 @@ async def _run(self) -> OT: # Using a second class to help clarify that it is of a particular type class RendererSync(RendererRun[OT]): + """ + Output Renderer (Synchronous) + + This class is used to define a synchronous renderer. The `.__call__` method is + implemented to call the `._run` method synchronously. + + Properties + ---------- + _is_async + Returns `FALSE` as this is a synchronous renderer. + + See Also + -------- + * :class:`~shiny.render.Renderer` + * :class:`~shiny.render.RendererRun` + * :class:`~shiny.render.RendererAsync` + """ + @property def _is_async(self) -> bool: return False @@ -295,6 +348,37 @@ async def __call__( # pyright: ignore[reportIncompatibleMethodOverride] return await self._run() +# ====================================================================================== +# Deprecated classes +# ====================================================================================== + + +# A RenderFunction object is given a user-provided function which returns an IT. When +# the .__call___ method is invoked, it calls the user-provided function (which returns +# an IT), then converts the IT to an OT. Note that in many cases but not all, IT and OT +# will be the same. +class RenderFunction(Generic[IT, OT], Renderer[OT]): + """ + Deprecated. Please use :func:`~shiny.render.renderer_components` instead. + """ + + def __init__(self, fn: Callable[[], IT]) -> None: + self.__name__ = fn.__name__ + self.__doc__ = fn.__doc__ + + +# 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], RendererAsync[OT]): + """ + Deprecated. Please use :func:`~shiny.render.renderer_components` instead. + """ + + async def __call__(self) -> OT: # pyright: ignore[reportIncompatibleMethodOverride] + raise NotImplementedError + + # ====================================================================================== # Restrict the value function # ====================================================================================== @@ -364,7 +448,26 @@ def _assert_handler_fn(handler_fn: HandlerFn[IT, P, OT]) -> None: class RendererComponents(Generic[IT, OT, P]): """ - Renderer Components class docs go here! + Renderer Component class + + Properties + ---------- + type_decorator + The return type for the renderer decorator wrapper function. This should be used when the app-defined render function is `None` and extra parameters are being supplied. + type_renderer_fn + The (non-`None`) type for the renderer function's first argument that accepts an app-defined render function. This type should be paired with the return type: `type_renderer`. + type_renderer + The type for the return value of the renderer decorator function. This should be used when the app-defined render function is not `None`. + type_impl_fn + The type for the implementation function's first argument. This value handles both app-defined render functions and `None` and returns values appropriate for both cases. `type_impl_fn` should be paired with `type_impl`. + type_impl + The type for the return value of the implementation function. This value handles both app-defined render functions and `None` and returns values appropriate for both cases. + + See Also + -------- + * :func:`~shiny.render.renderer_components` + * :class:`~shiny.render.RendererParams` + * :class:`~shiny.render.Renderer` """ @property From daae487eda20723c9a714a1b0cb1487c71642eb7 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 2 Aug 2023 14:53:52 -0400 Subject: [PATCH 24/64] More docs and an example app --- e2e/server/renderer/app.py | 8 +- shiny/api-examples/renderer_components/app.py | 125 +++++++++++++++++ shiny/render/_dataframe.py | 6 +- shiny/render/_render.py | 126 +++++++++++++----- tests/test_renderer.py | 32 ++--- 5 files changed, 240 insertions(+), 57 deletions(-) create mode 100644 shiny/api-examples/renderer_components/app.py diff --git a/e2e/server/renderer/app.py b/e2e/server/renderer/app.py index f4ea347bb..05382c0e9 100644 --- a/e2e/server/renderer/app.py +++ b/e2e/server/renderer/app.py @@ -10,15 +10,15 @@ @renderer_components async def _render_test_text_components( - meta: RenderMeta, - fn: RenderFnAsync[str | None], + _meta: RenderMeta, + _fn: RenderFnAsync[str | None], *, extra_txt: Optional[str] = None, ) -> str | None: - value = await fn() + value = await _fn() value = str(value) value += "; " - value += "async" if meta["is_async"] else "sync" + value += "async" if _meta.is_async else "sync" if extra_txt: value = value + "; " + str(extra_txt) return value diff --git a/shiny/api-examples/renderer_components/app.py b/shiny/api-examples/renderer_components/app.py new file mode 100644 index 000000000..e574e6b2d --- /dev/null +++ b/shiny/api-examples/renderer_components/app.py @@ -0,0 +1,125 @@ +# Needed for types imported only during TYPE_CHECKING with Python 3.7 - 3.9 +# See https://www.python.org/dev/peps/pep-0655/#usage-in-python-3-11 +from __future__ import annotations + +from typing import Literal, overload + +from shiny import App, Inputs, Outputs, Session, ui +from shiny.render import RenderFnAsync, RenderMeta, renderer_components + +####### +# Package authors can create their own renderer methods by leveraging +# `renderer_components` helper method +# +# This example is kept simple for demonstration purposes, but the handler function supplied to +# `renderer_components` can be much more complex (e.g. shiny.render.plotly) +####### + + +# Create renderer components from the async handler function: `capitalize_components()` +@renderer_components +async def capitalize_components( + # Contains information about the render call: `name`, `session`, `is_async` + _meta: RenderMeta, + # An async form of the app-supplied render function + _fn: RenderFnAsync[str | None], + *, + # Extra parameters that app authors can supply (e.g. `render_capitalize(to="upper")`) + to: Literal["upper", "lower"] = "upper", +) -> str | None: + # Get the value + value = await _fn() + # Quit early if value is `None` + if value is None: + return None + + if to == "upper": + return value.upper() + if to == "lower": + return value.lower() + raise ValueError(f"Invalid value for `to`: {to}") + + +# First, create an overload where users can supply the extra parameters. +# Example of usage: +# ``` +# @output +# @render_capitalize(to="upper") +# def value(): +# return input.caption() +# ``` +# Note: Return type is `type_decorator` +@overload +def render_capitalize( + *, + to: Literal["upper", "lower"] = "upper", +) -> capitalize_components.type_decorator: + ... + + +# Second, create an overload where users are not using parenthesis to the method. +# While it doesn't look necessary, it is needed for the type checker. +# Example of usage: +# ``` +# @output +# @render_capitalize +# def value(): +# return input.caption() +# ``` +# Note: `_fn` type is `type_renderer_fn` +# Note: Return type is `type_renderer` +@overload +def render_capitalize( + _fn: capitalize_components.type_renderer_fn, +) -> capitalize_components.type_renderer: + ... + + +# Lastly, implement the renderer. +# Note: `_fn` type is `type_impl_fn` +# Note: Return type is `type_impl` +def render_capitalize( + _fn: capitalize_components.type_impl_fn = None, + *, + to: Literal["upper", "lower"] = "upper", +) -> capitalize_components.type_impl: + return capitalize_components.impl( + _fn, + capitalize_components.params(to=to), + ) + + +####### +# End of package author code +####### + +app_ui = ui.page_fluid( + ui.h1("Capitalization renderer"), + ui.input_text("caption", "Caption:", "Data summary"), + "No parenthesis:", + ui.output_text_verbatim("no_parens"), + "To upper:", + ui.output_text_verbatim("to_upper"), + "To lower:", + ui.output_text_verbatim("to_lower"), +) + + +def server(input: Inputs, output: Outputs, session: Session): + @output + @render_capitalize + def no_parens(): + return input.caption() + + @output + @render_capitalize(to="upper") + def to_upper(): + return input.caption() + + @output + @render_capitalize(to="lower") + def to_lower(): + return input.caption() + + +app = App(app_ui, server) diff --git a/shiny/render/_dataframe.py b/shiny/render/_dataframe.py index a9d66663e..65c1cb7e1 100644 --- a/shiny/render/_dataframe.py +++ b/shiny/render/_dataframe.py @@ -206,10 +206,10 @@ def to_payload(self) -> object: # TODO-barret; Port `__name__` and `__docs__` of `value_fn` @renderer_components async def _data_frame( - meta: RenderMeta, - fn: RenderFnAsync[DataFrameResult | None], + _meta: RenderMeta, + _fn: RenderFnAsync[DataFrameResult | None], ) -> object | None: - x = await fn() + x = await _fn() if x is None: return None diff --git a/shiny/render/_render.py b/shiny/render/_render.py index e70c7b264..d53f2318c 100644 --- a/shiny/render/_render.py +++ b/shiny/render/_render.py @@ -2,8 +2,7 @@ # See https://www.python.org/dev/peps/pep-0655/#usage-in-python-3-11 from __future__ import annotations -# TODO-barret; Revert base classes and use the original classes? -# TODO-barret; Changelog - that RenderFunction no longer exists or deprecated +# TODO-barret; Changelog entry __all__ = ( @@ -25,6 +24,7 @@ Awaitable, Callable, Generic, + NamedTuple, Optional, Protocol, TypeVar, @@ -42,6 +42,7 @@ import pandas as pd from .. import _utils +from .._docstring import add_example from .._namespaces import ResolvedId from .._typing_extensions import Concatenate, ParamSpec, TypedDict from ..types import ImgData @@ -61,7 +62,7 @@ # Meta information to give `hander()` some context -class RenderMeta(TypedDict): +class RenderMeta(NamedTuple): """ Renderer meta information @@ -101,10 +102,10 @@ class RendererParams(Generic[P]): # Motivation for using this class: # * https://peps.python.org/pep-0612/ does allow for prepending an arg (e.g. # `render_fn`). - # * However, the overload is not happy when both a positional arg (e.g. - # `render_fn`) is dropped and the variadic args (`*args`) are kept. - # * The variadic args (`*args`) CAN NOT be dropped as PEP612 states that both - # components of the `ParamSpec` must be used in the same function signature. + # * However, the overload is not happy when both a positional arg (e.g. `render_fn`) + # is dropped and the variadic positional args (`*args`) are kept. + # * The variadic positional args (`*args`) CAN NOT be dropped as PEP612 states that + # both components of the `ParamSpec` must be used in the same function signature. # * By making assertions on `P.args` to only allow for `*`, we _can_ make overloads # that use either the single positional arg (e.g. `render_fn`) or the `P.kwargs` # (as `P.args` == `*`) @@ -255,13 +256,19 @@ async def _run(self) -> OT: """ Executes the (async) handler function - The handler function will receive the following arguments: `meta` of type :class:`~shiny.render.RenderMeta`, an app-defined render function of type :class:`~shiny.render.RenderFnAsync`, and `*args` and `**kwargs` of type `P`. + The handler function will receive the following arguments: meta information of + type :class:`~shiny.render.RenderMeta`, an app-defined render function of type + :class:`~shiny.render.RenderFnAsync`, and `*args` and `**kwargs` of type `P`. - Note: The app-defined render function will always be upgraded to be an async function. - Note: `*args` will always be empty as it is an expansion of :class:`~shiny.render.RendererParams` which does not allow positional arguments. + Notes: + * The app-defined render function will always be upgraded to be an async + function. + * `*args` will always be empty as it is an expansion of + :class:`~shiny.render.RendererParams` which does not allow positional + arguments. """ ret = await self._handler_fn( - # RendererMeta + # RenderMeta self.meta, # Callable[[], Awaitable[IT]] self._render_fn, @@ -418,7 +425,7 @@ def _assert_handler_fn(handler_fn: HandlerFn[IT, P, OT]) -> None: # Make sure there are no `*args` if param.kind == inspect.Parameter.VAR_POSITIONAL: raise TypeError( - f"No variadic parameters (e.g. `*args`) can be supplied to `handler_fn=`. Received: `{param.name}`. Please only use `*`." + f"No variadic positional parameters (e.g. `*args`) can be supplied to `handler_fn=`. Received: `{param.name}`. Please only use `*`." ) # Make sure kwargs have default values if ( @@ -517,14 +524,65 @@ def __init__( self._fn = fn +@add_example() def renderer_components( handler_fn: HandlerFn[IT, P, OT], ) -> RendererComponents[IT, OT, P]: """\ - Renderer components decorator + # Renderer components decorator + + This decorator method is a convenience method to generate the appropriate types and + internal implementation for an overloaded renderer method. This method will provide + you with all the necessary types to define two different overloads: one for when the + decorator is called without parenthesis and another for when it is called with + parenthesis where app authors can pass in parameters to the renderer. + + ## Handler function + + The renderer's asynchronous handler function (`handler_fn`) is the key building + block for `renderer_components`. + + The handler function is supplied meta renderer information, the (app-supplied) + render function, and any keyword arguments supplied to the renderer decorator: + * The first parameter to the handler function has the class + :class:`~shiny.render.RenderMeta` and is typically called (e.g. `_meta`). This information + gives context the to the handler while trying to resolve the app-supplied render + function (e.g. `_fn`). + * The second parameter is the app-defined render function (e.g. `_fn`). It's return + type (`IT`) determines what types can be returned by the app-supplied render + function. For example, if `_fn` has the type `RenderFnAsync[str | None]`, both the + `str` and `None` types are allowed to be returned from the app-supplied render + function. + * The remaining parameters are the keyword arguments (e.g. `alt:Optional[str] = + None` or `**kwargs: Any`) that app authors may supply to the renderer (when the + renderer decorator is called with parenthesis). Variadic positional parameters + (e.g. `*args`) are not allowed. All keyword arguments should have a type and + default value (except for `**kwargs: Any`). + + The handler's return type (`OT`) determines the output type of the renderer. Note + that in many cases (but not all!) `IT` and `OT` will be the same. The `None` type + should typically be defined in both `IT` and `OT`. If `IT` allows for `None` values, + it (typically) signals that nothing should be rendered. If `OT` allows for `None` + and returns a `None` value, shiny will not render the output. + + + Notes + ----- + + When defining the renderer decorator overloads, if you use `**kwargs: object`, you may get a type error about incompatible signatures. To fix this, you can use `**kwargs: Any` instead or add `_fn: None = None` as a first parameter. - TODO-barret; Docs go here! - When defining overloads, if you use `**kwargs: object`, you may get a type error about incompatible signatures. To fix this, you can use `**kwargs: Any` instead or add `_fn: None = None` as a first parameter. + Parameters + ---------- + handler_fn + Asynchronous function used to determine the app-supplied value type (`IT`), the rendered type (`OT`), and the parameters app authors can supply to the renderer. + + Returns + ------- + : + A :class:`~shiny.render.RendererComponents` object that can be used to define + two overloads for your renderer function. One overload is for when the renderer + is called without parenthesis and the other is for when the renderer is called + with parenthesis. """ _assert_handler_fn(handler_fn) @@ -559,10 +617,10 @@ def as_render_fn( @renderer_components async def _text( - meta: RenderMeta, - fn: RenderFnAsync[str | None], + _meta: RenderMeta, + _fn: RenderFnAsync[str | None], ) -> str | None: - value = await fn() + value = await _fn() if value is None: return None return str(value) @@ -612,15 +670,15 @@ def text( # a nontrivial amount of overhead. So for now, we're just using `object`. @renderer_components async def _plot( - meta: RenderMeta, - fn: RenderFnAsync[ImgData | None], + _meta: RenderMeta, + _fn: RenderFnAsync[ImgData | None], *, alt: Optional[str] = None, **kwargs: object, ) -> ImgData | None: - is_userfn_async = meta["is_async"] - name = meta["name"] - session = meta["session"] + is_userfn_async = _meta.is_async + name = _meta.name + session = _meta.session ppi: float = 96 @@ -637,7 +695,7 @@ async def _plot( float, inputs[ResolvedId(f".clientdata_output_{name}_height")]() ) - x = await fn() + x = await _fn() # Note that x might be None; it could be a matplotlib.pyplot @@ -779,12 +837,12 @@ def plot( # ====================================================================================== @renderer_components async def _image( - meta: RenderMeta, - fn: RenderFnAsync[ImgData | None], + _meta: RenderMeta, + _fn: RenderFnAsync[ImgData | None], *, delete_file: bool = False, ) -> ImgData | None: - res = await fn() + res = await _fn() if res is None: return None @@ -865,15 +923,15 @@ def to_pandas(self) -> "pd.DataFrame": @renderer_components async def _table( - meta: RenderMeta, - fn: RenderFnAsync[TableResult | None], + _meta: RenderMeta, + _fn: RenderFnAsync[TableResult | None], *, index: bool = False, classes: str = "table shiny-table w-auto", border: int = 0, **kwargs: object, ) -> RenderedDeps | None: - x = await fn() + x = await _fn() if x is None: return None @@ -990,14 +1048,14 @@ def table( # ====================================================================================== @renderer_components async def _ui( - meta: RenderMeta, - fn: RenderFnAsync[TagChild], + _meta: RenderMeta, + _fn: RenderFnAsync[TagChild], ) -> RenderedDeps | None: - ui = await fn() + ui = await _fn() if ui is None: return None - return meta["session"]._process_ui(ui) + return _meta.session._process_ui(ui) @overload diff --git a/tests/test_renderer.py b/tests/test_renderer.py index 814aa4016..e9b4d5197 100644 --- a/tests/test_renderer.py +++ b/tests/test_renderer.py @@ -7,8 +7,8 @@ def test_renderer_components_works(): # No args works @renderer_components async def test_components( - meta: RenderMeta, - fn: RenderFnAsync[str], + _meta: RenderMeta, + _fn: RenderFnAsync[str], ): ... @@ -32,8 +32,8 @@ def test_renderer_components_kwargs_are_allowed(): # Test that kwargs can be allowed @renderer_components async def test_components( - meta: RenderMeta, - fn: RenderFnAsync[str], + _meta: RenderMeta, + _fn: RenderFnAsync[str], *, y: str = "42", ): @@ -64,8 +64,8 @@ def test_renderer_components_with_pass_through_kwargs(): # No args works @renderer_components async def test_components( - meta: RenderMeta, - fn: RenderFnAsync[str], + _meta: RenderMeta, + _fn: RenderFnAsync[str], *, y: str = "42", **kwargs: float, @@ -101,7 +101,7 @@ def test_renderer_components_pos_args(): @renderer_components # type: ignore async def test_components( - meta: RenderMeta, + _meta: RenderMeta, ): ... @@ -115,8 +115,8 @@ def test_renderer_components_limits_positional_arg_count(): @renderer_components async def test_components( - meta: RenderMeta, - fn: RenderFnAsync[str], + _meta: RenderMeta, + _fn: RenderFnAsync[str], y: str, ): ... @@ -131,8 +131,8 @@ def test_renderer_components_does_not_allow_args(): @renderer_components async def test_components( - meta: RenderMeta, - fn: RenderFnAsync[str], + _meta: RenderMeta, + _fn: RenderFnAsync[str], *args: str, ): ... @@ -140,7 +140,7 @@ async def test_components( raise RuntimeError() except TypeError as e: - assert "No variadic parameters" in str(e) + assert "No variadic positional parameters" in str(e) def test_renderer_components_kwargs_have_defaults(): @@ -148,8 +148,8 @@ def test_renderer_components_kwargs_have_defaults(): @renderer_components async def test_components( - meta: RenderMeta, - fn: RenderFnAsync[str], + _meta: RenderMeta, + _fn: RenderFnAsync[str], *, y: str, ): @@ -164,8 +164,8 @@ async def test_components( def test_renderer_components_result_does_not_allow_args(): @renderer_components async def test_components( - meta: RenderMeta, - fn: RenderFnAsync[str], + _meta: RenderMeta, + _fn: RenderFnAsync[str], ): ... From d900a17906a0a99414bebab756a32566d2fd6245 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 2 Aug 2023 16:14:40 -0400 Subject: [PATCH 25/64] Add comment about `RendererParams`'s `args` field --- shiny/render/_render.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/shiny/render/_render.py b/shiny/render/_render.py index d53f2318c..f16029eda 100644 --- a/shiny/render/_render.py +++ b/shiny/render/_render.py @@ -120,11 +120,14 @@ def __init__(self, *args: P.args, **kwargs: P.kwargs) -> None: Keyword arguments for the corresponding renderer function. """ - # `*args` must be defined with `**kwargs` # Make sure there no `args` when running! + # This check is related to `_assert_handler_fn` not accepting any `args` if len(args) > 0: raise RuntimeError("`args` should not be supplied") + # `*args` must be defined with `**kwargs` (as per PEP612) + # (even when expanded later when calling the handler function) + # So we store them, even if we know they are empty self.args = args self.kwargs = kwargs From aa5b8972eb6b944e659176258d19930297f74d42 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 2 Aug 2023 16:17:20 -0400 Subject: [PATCH 26/64] Add comments explaining `RenderFn` and `HandlerFn` --- shiny/render/_render.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/shiny/render/_render.py b/shiny/render/_render.py index f16029eda..cb0a83674 100644 --- a/shiny/render/_render.py +++ b/shiny/render/_render.py @@ -135,9 +135,14 @@ def __init__(self, *args: P.args, **kwargs: P.kwargs) -> None: # ====================================================================================== # Renderer / RendererSync / RendererAsync base class # ====================================================================================== + +# A `RenderFn` function is an app-supplied function which returns an IT. +# It can be either synchronous or asynchronous RenderFnSync = Callable[[], IT] RenderFnAsync = Callable[[], Awaitable[IT]] RenderFn = RenderFnSync[IT] | RenderFnAsync[IT] + +# `HandlerFn` is a package author function that transforms an object of type `IT` into type `OT`. HandlerFn = Callable[Concatenate[RenderMeta, RenderFnAsync[IT], P], Awaitable[OT]] From 64f8f022e2d98b8da6ec6f5f1a40b5676aa40243 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 2 Aug 2023 16:18:00 -0400 Subject: [PATCH 27/64] Remove comment --- shiny/render/_render.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/shiny/render/_render.py b/shiny/render/_render.py index cb0a83674..b6e35610d 100644 --- a/shiny/render/_render.py +++ b/shiny/render/_render.py @@ -146,8 +146,6 @@ def __init__(self, *args: P.args, **kwargs: P.kwargs) -> None: HandlerFn = Callable[Concatenate[RenderMeta, RenderFnAsync[IT], P], Awaitable[OT]] -# A Renderer object is given a user-provided function (`handler_fn`) which returns an -# `OT`. class Renderer(Generic[OT]): """ Output Renderer From e7b64e44bb36083b3c9b2cca9de8c9645462ea95 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 2 Aug 2023 16:19:47 -0400 Subject: [PATCH 28/64] Remove `__doc__` param --- shiny/render/_render.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/shiny/render/_render.py b/shiny/render/_render.py index b6e35610d..e047298ba 100644 --- a/shiny/render/_render.py +++ b/shiny/render/_render.py @@ -44,7 +44,7 @@ from .. import _utils from .._docstring import add_example from .._namespaces import ResolvedId -from .._typing_extensions import Concatenate, ParamSpec, TypedDict +from .._typing_extensions import Concatenate, ParamSpec from ..types import ImgData from ._try_render_plot import try_render_matplotlib, try_render_pil, try_render_plotnine @@ -178,7 +178,7 @@ def __call__(self, *_) -> OT: """ raise NotImplementedError - def __init__(self, *, name: str, doc: str | None) -> None: + def __init__(self, *, name: str) -> None: """\ Renderer init method @@ -191,7 +191,6 @@ def __init__(self, *, name: str, doc: str | None) -> None: verbatim". """ self.__name__ = name - self.__doc__ = doc def _set_metadata(self, session: Session, name: str) -> None: """\ @@ -249,7 +248,6 @@ def __init__( ) super().__init__( name=render_fn.__name__, - doc=render_fn.__doc__, ) # Given we use `_utils.run_coro_sync(self._run())` to call our method, From 7a7da1762c56bcc681ffd3d96870869efc9721b0 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 2 Aug 2023 16:21:33 -0400 Subject: [PATCH 29/64] Make `.meta` property into `._meta()` method --- shiny/render/_render.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/shiny/render/_render.py b/shiny/render/_render.py index e047298ba..047418189 100644 --- a/shiny/render/_render.py +++ b/shiny/render/_render.py @@ -228,8 +228,7 @@ class RendererRun(Renderer[OT]): def _is_async(self) -> bool: raise NotImplementedError() - @property - def meta(self) -> RenderMeta: + def _meta(self) -> RenderMeta: return RenderMeta( is_async=self._is_async, session=self._session, @@ -273,7 +272,7 @@ async def _run(self) -> OT: """ ret = await self._handler_fn( # RenderMeta - self.meta, + self._meta(), # Callable[[], Awaitable[IT]] self._render_fn, # P From b5ecaf3a073710b73fd37317f1be4c8af244d62f Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 2 Aug 2023 16:24:37 -0400 Subject: [PATCH 30/64] Drop unnecessary `*_` arg --- shiny/render/_render.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/shiny/render/_render.py b/shiny/render/_render.py index 047418189..4514359fa 100644 --- a/shiny/render/_render.py +++ b/shiny/render/_render.py @@ -172,7 +172,7 @@ class Renderer(Generic[OT]): * :class:`~shiny.render.RendererAsync` """ - def __call__(self, *_) -> OT: + def __call__(self) -> OT: """ Executes the renderer as a function. Must be implemented by subclasses. """ @@ -323,7 +323,7 @@ def __init__( params, ) - def __call__(self, *_) -> OT: + def __call__(self) -> OT: return _utils.run_coro_sync(self._run()) @@ -352,9 +352,7 @@ def __init__( params, ) - async def __call__( # pyright: ignore[reportIncompatibleMethodOverride] - self, *_ - ) -> OT: + async def __call__(self) -> OT: # pyright: ignore[reportIncompatibleMethodOverride] return await self._run() From ba43bcf0a8542b4ed507a619bb89787d93aa8b85 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 2 Aug 2023 16:24:59 -0400 Subject: [PATCH 31/64] Make `_is_async` property into `_is_async()` method --- shiny/render/_render.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/shiny/render/_render.py b/shiny/render/_render.py index 4514359fa..ba0d878ea 100644 --- a/shiny/render/_render.py +++ b/shiny/render/_render.py @@ -208,12 +208,12 @@ class RendererRun(Renderer[OT]): This class is used to define a `_run` method that is called by the `.__call__` method in subclasses. - Properties - ---------- + Methods + ------- _is_async If `TRUE`, the app-supplied render function is asynchronous. Must be implemented in subclasses. - meta + _meta A named dictionary of values: `is_async`, `session` (the :class:`~shiny.Session` object), and `name` (the name of the output being rendered) @@ -224,13 +224,12 @@ class RendererRun(Renderer[OT]): * :class:`~shiny.render.RendererAsync` """ - @property def _is_async(self) -> bool: raise NotImplementedError() def _meta(self) -> RenderMeta: return RenderMeta( - is_async=self._is_async, + is_async=self._is_async(), session=self._session, name=self._name, ) @@ -290,8 +289,8 @@ class RendererSync(RendererRun[OT]): This class is used to define a synchronous renderer. The `.__call__` method is implemented to call the `._run` method synchronously. - Properties - ---------- + Methods + ------- _is_async Returns `FALSE` as this is a synchronous renderer. @@ -302,7 +301,6 @@ class RendererSync(RendererRun[OT]): * :class:`~shiny.render.RendererAsync` """ - @property def _is_async(self) -> bool: return False @@ -331,7 +329,7 @@ def __call__(self) -> OT: # method is marked here as async; you can't have a single class where one method could # be either sync or async. class RendererAsync(RendererRun[OT]): - @property + # TODO-barret; docs def _is_async(self) -> bool: return True From ff8134243542da33f02960788c47bd3feb050fe8 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 2 Aug 2023 16:26:21 -0400 Subject: [PATCH 32/64] Remove newline escape --- shiny/render/_render.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/shiny/render/_render.py b/shiny/render/_render.py index ba0d878ea..c26ca5ea6 100644 --- a/shiny/render/_render.py +++ b/shiny/render/_render.py @@ -179,7 +179,7 @@ def __call__(self) -> OT: raise NotImplementedError def __init__(self, *, name: str) -> None: - """\ + """ Renderer init method Arguments @@ -193,7 +193,7 @@ def __init__(self, *, name: str) -> None: self.__name__ = name 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. """ @@ -527,8 +527,8 @@ def __init__( def renderer_components( handler_fn: HandlerFn[IT, P, OT], ) -> RendererComponents[IT, OT, P]: - """\ - # Renderer components decorator + """ + Renderer components decorator This decorator method is a convenience method to generate the appropriate types and internal implementation for an overloaded renderer method. This method will provide From a2a8c28a3905622c3c51bc111e6aff09b619fe31 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 2 Aug 2023 16:27:15 -0400 Subject: [PATCH 33/64] Remove unnecessary async wrap --- shiny/render/_render.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shiny/render/_render.py b/shiny/render/_render.py index c26ca5ea6..7a1283362 100644 --- a/shiny/render/_render.py +++ b/shiny/render/_render.py @@ -251,7 +251,7 @@ def __init__( # Given we use `_utils.run_coro_sync(self._run())` to call our method, # we can act as if `render_fn` and `handler_fn` are always async self._render_fn = _utils.wrap_async(render_fn) - self._handler_fn = _utils.wrap_async(handler_fn) + self._handler_fn = handler_fn self._params = params async def _run(self) -> OT: From df843d0e467c248df22a05b07f045fae367ea471 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 2 Aug 2023 16:29:44 -0400 Subject: [PATCH 34/64] Update comment about async render_fn function in RendererRun --- shiny/render/_render.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/shiny/render/_render.py b/shiny/render/_render.py index 7a1283362..9ea88778e 100644 --- a/shiny/render/_render.py +++ b/shiny/render/_render.py @@ -248,8 +248,9 @@ def __init__( name=render_fn.__name__, ) - # Given we use `_utils.run_coro_sync(self._run())` to call our method, - # we can act as if `render_fn` and `handler_fn` are always async + # `render_fn` is not required to be async. For consistency, we wrapped in an + # async function so that when it's passed in to `handler_fn`, `render_fn` is + # **always** an async function. self._render_fn = _utils.wrap_async(render_fn) self._handler_fn = handler_fn self._params = params From 05e93e77a85ec9940c6bcd517bba15bf85076fbf Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 2 Aug 2023 16:54:12 -0400 Subject: [PATCH 35/64] Merger `RendererRun` into `Renderer`. Use ABC to mark some methods as abstract. --- shiny/render/_render.py | 145 +++++++++++++++++++--------------------- 1 file changed, 68 insertions(+), 77 deletions(-) diff --git a/shiny/render/_render.py b/shiny/render/_render.py index 9ea88778e..a46a81325 100644 --- a/shiny/render/_render.py +++ b/shiny/render/_render.py @@ -18,6 +18,7 @@ import os import sys import typing +from abc import ABC, abstractmethod from typing import ( TYPE_CHECKING, Any, @@ -146,39 +147,55 @@ def __init__(self, *args: P.args, **kwargs: P.kwargs) -> None: HandlerFn = Callable[Concatenate[RenderMeta, RenderFnAsync[IT], P], Awaitable[OT]] -class Renderer(Generic[OT]): +class Renderer(Generic[OT], ABC): """ Output Renderer - Base class to build up :class:`~shiny.render.RendererSync` and + Base class for classes :class:`~shiny.render.RendererSync` and :class:`~shiny.render.RendererAsync`. - When the `.__call__` method is invoked, the handler function (typically defined by - package authors) is called. The handler function is given `meta` information, the - (app-supplied) render function, and any keyword arguments supplied to the render - decorator. + When the `.__call__` method is invoked, the handler function (`handler_fn`) + (typically defined by package authors) is asynchronously called. The handler + function is given `meta` information, the (app-supplied) render function, and any + keyword arguments supplied to the render decorator. For consistency, the first two + parameters have been (arbitrarily) implemented as `_meta` and `_fn`. - The (app-supplied) render function should return type `IT`. The handler function - (defined by package authors) defines the parameter specification of type `P` and - should asynchronously return an object of type `OT`. Note that in many cases but not - all, `IT` and `OT` will be the same. `None` values must always be defined in `IT` - and `OT`. + The (app-supplied) render function (`render_fn`) returns type `IT`. The handler + function (defined by package authors) defines the parameter specification of type + `P` and asynchronously returns an object of type `OT`. Note that in many cases but + not all, `IT` and `OT` will be the same. `None` values should always be defined in + `IT` and `OT`. + Methods + ------- + _is_async + If `TRUE`, the app-supplied render function is asynchronous. Must be implemented + in subclasses. + _meta + A named tuple of values: `is_async`, `session` (the :class:`~shiny.Session` + object), and `name` (the name of the output being rendered) + See Also -------- - * :class:`~shiny.render.RendererRun` * :class:`~shiny.render.RendererSync` * :class:`~shiny.render.RendererAsync` """ + @abstractmethod def __call__(self) -> OT: """ Executes the renderer as a function. Must be implemented by subclasses. """ - raise NotImplementedError + ... - def __init__(self, *, name: str) -> None: + def __init__( + self, + *, + render_fn: RenderFn[IT], + handler_fn: HandlerFn[IT, P, OT], + params: RendererParams[P], + ) -> None: """ Renderer init method @@ -190,7 +207,21 @@ def __init__(self, *, name: str) -> None: Documentation of the output function. Ex: `"My text output will be displayed verbatim". """ - self.__name__ = name + # Copy over function name as it is consistent with how Session and Output + # retrieve function names + self.__name__ = render_fn.__name__ + + if not _utils.is_async_callable(handler_fn): + raise TypeError( + self.__class__.__name__ + " requires an async handler function" + ) + + # `render_fn` is not required to be async. For consistency, we wrapped in an + # async function so that when it's passed in to `handler_fn`, `render_fn` is + # **always** an async function. + self._render_fn = _utils.wrap_async(render_fn) + self._handler_fn = handler_fn + self._params = params def _set_metadata(self, session: Session, name: str) -> None: """ @@ -200,33 +231,6 @@ def _set_metadata(self, session: Session, name: str) -> None: self._session: Session = session self._name: str = name - -class RendererRun(Renderer[OT]): - """ - Convenience class to define a `_run` method - - This class is used to define a `_run` method that is called by the `.__call__` - method in subclasses. - - Methods - ------- - _is_async - If `TRUE`, the app-supplied render function is asynchronous. Must be implemented - in subclasses. - _meta - A named dictionary of values: `is_async`, `session` (the :class:`~shiny.Session` - object), and `name` (the name of the output being rendered) - - See Also - -------- - * :class:`~shiny.render.Renderer` - * :class:`~shiny.render.RendererSync` - * :class:`~shiny.render.RendererAsync` - """ - - def _is_async(self) -> bool: - raise NotImplementedError() - def _meta(self) -> RenderMeta: return RenderMeta( is_async=self._is_async(), @@ -234,26 +238,9 @@ def _meta(self) -> RenderMeta: name=self._name, ) - def __init__( - self, - render_fn: RenderFn[IT], - handler_fn: HandlerFn[IT, P, OT], - params: RendererParams[P], - ) -> None: - if not _utils.is_async_callable(handler_fn): - raise TypeError( - self.__class__.__name__ + " requires an async handler function" - ) - super().__init__( - name=render_fn.__name__, - ) - - # `render_fn` is not required to be async. For consistency, we wrapped in an - # async function so that when it's passed in to `handler_fn`, `render_fn` is - # **always** an async function. - self._render_fn = _utils.wrap_async(render_fn) - self._handler_fn = handler_fn - self._params = params + @abstractmethod + def _is_async(self) -> bool: + ... async def _run(self) -> OT: """ @@ -283,7 +270,7 @@ async def _run(self) -> OT: # Using a second class to help clarify that it is of a particular type -class RendererSync(RendererRun[OT]): +class RendererSync(Renderer[OT]): """ Output Renderer (Synchronous) @@ -298,7 +285,6 @@ class RendererSync(RendererRun[OT]): See Also -------- * :class:`~shiny.render.Renderer` - * :class:`~shiny.render.RendererRun` * :class:`~shiny.render.RendererAsync` """ @@ -315,11 +301,11 @@ def __init__( raise TypeError( self.__class__.__name__ + " requires a synchronous render function" ) - # super == RendererRun + # super == Renderer super().__init__( - render_fn, - handler_fn, - params, + render_fn=render_fn, + handler_fn=handler_fn, + params=params, ) def __call__(self) -> OT: @@ -329,7 +315,7 @@ def __call__(self) -> OT: # 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 RendererAsync(RendererRun[OT]): +class RendererAsync(Renderer[OT]): # TODO-barret; docs def _is_async(self) -> bool: return True @@ -344,11 +330,11 @@ def __init__( raise TypeError( self.__class__.__name__ + " requires an asynchronous render function" ) - # super == RendererRun + # super == Renderer super().__init__( - render_fn, - handler_fn, - params, + render_fn=render_fn, + handler_fn=handler_fn, + params=params, ) async def __call__(self) -> OT: # pyright: ignore[reportIncompatibleMethodOverride] @@ -360,10 +346,10 @@ async def __call__(self) -> OT: # pyright: ignore[reportIncompatibleMethodOverr # ====================================================================================== -# A RenderFunction object is given a user-provided function which returns an IT. When -# the .__call___ method is invoked, it calls the user-provided function (which returns -# an IT), then converts the IT to an OT. Note that in many cases but not all, IT and OT -# will be the same. +# 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], Renderer[OT]): """ Deprecated. Please use :func:`~shiny.render.renderer_components` instead. @@ -372,6 +358,7 @@ class RenderFunction(Generic[IT, OT], Renderer[OT]): def __init__(self, fn: Callable[[], IT]) -> None: self.__name__ = fn.__name__ self.__doc__ = fn.__doc__ + # TODO-barret; call super and make a __call__ method # The reason for having a separate RenderFunctionAsync class is because the __call__ @@ -442,7 +429,11 @@ def _assert_handler_fn(handler_fn: HandlerFn[IT, P, OT]) -> None: # ====================================================================================== +# Signature of a renderer decorator function RendererDeco = Callable[[RenderFn[IT]], Renderer[OT]] +# Signature of a decorator that can be called with and without parenthesis +# With parens returns a `Renderer[OT]` +# Without parens returns a `RendererDeco[IT, OT]` RenderImplFn = Callable[ [ Optional[RenderFn[IT]], From 913b40301b2af7b53d480ba595f364052a371611 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 2 Aug 2023 17:10:25 -0400 Subject: [PATCH 36/64] better comment about which variables are found during imports --- shiny/render/__init__.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/shiny/render/__init__.py b/shiny/render/__init__.py index d8007b011..d867151fe 100644 --- a/shiny/render/__init__.py +++ b/shiny/render/__init__.py @@ -3,13 +3,20 @@ """ from ._render import ( # noqa: F401 - # Exported. Give autocomplete hints for `shiny.render.FOO` + # Values declared in `__all__` will give autocomplete hints / resolve. + # E.g. `from shiny import render; render.text` but not `render.RenderMeta` + # These values do not need ` as FOO` as the variable is used in `__all__` text, plot, image, table, ui, - # Manual imports. Do not give autocomplete hints. + # Renamed values (in addition to the __all__values) are exposed when importing + # directly from `render` module just like a regular variable. + # E.g `from shiny.render import RenderMeta, RenderFnAsync, renderer_components` + # These values need ` as FOO` as the variable is not used in `__all__` and causes an + # reportUnusedImport error from pylance. + # Using the same name is allowed. RenderMeta as RenderMeta, RenderFnAsync as RenderFnAsync, RendererParams as RendererParams, @@ -21,10 +28,10 @@ ) from ._dataframe import ( # noqa: F401 - # Manual imports + # Renamed values DataGrid as DataGrid, DataTable as DataTable, - # Exported + # Values declared in `__all__` data_frame, ) From c71c53003ac4d9f334acd3af3bbc5caff9c2ce09 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 2 Aug 2023 17:13:10 -0400 Subject: [PATCH 37/64] typing lints --- shiny/render/_render.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/shiny/render/_render.py b/shiny/render/_render.py index d54a45658..3d6a2494f 100644 --- a/shiny/render/_render.py +++ b/shiny/render/_render.py @@ -141,7 +141,7 @@ def __init__(self, *args: P.args, **kwargs: P.kwargs) -> None: # It can be either synchronous or asynchronous RenderFnSync = Callable[[], IT] RenderFnAsync = Callable[[], Awaitable[IT]] -RenderFn = RenderFnSync[IT] | RenderFnAsync[IT] +RenderFn = Union[RenderFnSync[IT], RenderFnAsync[IT]] # `HandlerFn` is a package author function that transforms an object of type `IT` into type `OT`. HandlerFn = Callable[Concatenate[RenderMeta, RenderFnAsync[IT], P], Awaitable[OT]] @@ -439,8 +439,7 @@ def _assert_handler_fn(handler_fn: HandlerFn[IT, P, OT]) -> None: Optional[RenderFn[IT]], RendererParams[P], ], - # RendererSync[OT] | RendererAsync[OT] | RendererDeco[IT, OT], - Renderer[OT] | RendererDeco[IT, OT], + Union[Renderer[OT], RendererDeco[IT, OT]], ] @@ -486,7 +485,7 @@ def type_impl_fn(self): @property def type_impl(self): - return Renderer[OT] | RendererDeco[IT, OT] + return Union[Renderer[OT], RendererDeco[IT, OT]] def params( self, From ae5c1b707401d489de003bc060115e9f5bf68ce6 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Thu, 3 Aug 2023 10:17:18 -0400 Subject: [PATCH 38/64] Implement methods for `RenderFunction` and `RenderFunctionAsync` --- shiny/render/_render.py | 53 +++++++++++++++++++++++++++++++++++------ 1 file changed, 46 insertions(+), 7 deletions(-) diff --git a/shiny/render/_render.py b/shiny/render/_render.py index 3d6a2494f..5b89ac6dd 100644 --- a/shiny/render/_render.py +++ b/shiny/render/_render.py @@ -132,6 +132,13 @@ def __init__(self, *args: P.args, **kwargs: P.kwargs) -> None: self.args = args self.kwargs = kwargs + @staticmethod + def empty_params() -> RendererParams[P]: + def inner(*args: P.args, **kwargs: P.kwargs) -> RendererParams[P]: + return RendererParams[P](*args, **kwargs) + + return inner() + # ====================================================================================== # Renderer / RendererSync / RendererAsync base class @@ -350,27 +357,59 @@ async def __call__(self) -> OT: # pyright: ignore[reportIncompatibleMethodOverr # 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], Renderer[OT]): +class RenderFunction(Generic[IT, OT], RendererSync[OT], ABC): """ Deprecated. Please use :func:`~shiny.render.renderer_components` instead. """ - def __init__(self, fn: Callable[[], IT]) -> None: - self.__name__ = fn.__name__ - self.__doc__ = fn.__doc__ - # TODO-barret; call super and make a __call__ method + @abstractmethod + def __call__(self) -> OT: + ... + + @abstractmethod + async def run(self) -> OT: + ... + + def __init__(self, fn: RenderFnSync[IT]) -> None: + async def handler_fn(_meta: RenderMeta, _fn: RenderFnAsync[IT]) -> OT: + ret = await self.run() + return ret + + super().__init__( + render_fn=fn, + handler_fn=handler_fn, + params=RendererParams.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], RendererAsync[OT]): +class RenderFunctionAsync(Generic[IT, OT], RendererAsync[OT], ABC): """ Deprecated. Please use :func:`~shiny.render.renderer_components` instead. """ + @abstractmethod async def __call__(self) -> OT: # pyright: ignore[reportIncompatibleMethodOverride] - raise NotImplementedError + ... + + @abstractmethod + async def run(self) -> OT: + ... + + def __init__(self, fn: RenderFnAsync[IT]) -> None: + async def handler_fn(_meta: RenderMeta, _fn: RenderFnAsync[IT]) -> OT: + ret = await self.run() + return ret + + super().__init__( + render_fn=fn, + handler_fn=handler_fn, + params=RendererParams.empty_params(), + ) + self._fn = fn # ====================================================================================== From 6fc09803d857470e9ea4e2de0100fb8e53befbab Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Thu, 3 Aug 2023 10:29:07 -0400 Subject: [PATCH 39/64] Docs / comments --- shiny/render/_render.py | 80 +++++++++++++++++++++++++++++------------ 1 file changed, 58 insertions(+), 22 deletions(-) diff --git a/shiny/render/_render.py b/shiny/render/_render.py index 5b89ac6dd..01b83d2c4 100644 --- a/shiny/render/_render.py +++ b/shiny/render/_render.py @@ -284,11 +284,6 @@ class RendererSync(Renderer[OT]): This class is used to define a synchronous renderer. The `.__call__` method is implemented to call the `._run` method synchronously. - Methods - ------- - _is_async - Returns `FALSE` as this is a synchronous renderer. - See Also -------- * :class:`~shiny.render.Renderer` @@ -296,6 +291,14 @@ class RendererSync(Renderer[OT]): """ def _is_async(self) -> bool: + """ + Meta information about the renderer being asynchronous or not. + + Returns + ------- + : + Returns `FALSE` as this is a synchronous renderer. + """ return False def __init__( @@ -323,8 +326,27 @@ def __call__(self) -> OT: # method is marked here as async; you can't have a single class where one method could # be either sync or async. class RendererAsync(Renderer[OT]): - # TODO-barret; docs + """ + 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.Renderer` + * :class:`~shiny.render.RendererSync` + """ + def _is_async(self) -> bool: + """ + Meta information about the renderer being asynchronous or not. + + Returns + ------- + : + Returns `TRUE` as this is an asynchronous renderer. + """ return True def __init__( @@ -418,7 +440,8 @@ async def handler_fn(_meta: RenderMeta, _fn: RenderFnAsync[IT]) -> OT: # assert: No variable length positional values; -# * We need a way to distinguish between a plain function and args supplied to the next function. This is done by not allowing `*args`. +# * We need a way to distinguish between a plain function and args supplied to the next +# function. This is done by not allowing `*args`. # assert: All kwargs of handler_fn should have a default value # * This makes calling the method with both `()` and without `()` possible / consistent. def _assert_handler_fn(handler_fn: HandlerFn[IT, P, OT]) -> None: @@ -430,7 +453,9 @@ def _assert_handler_fn(handler_fn: HandlerFn[IT, P, OT]) -> None: ) for i, param in zip(range(len(params)), params.values()): - # # Not a good test as `param.annotation` has type `str`: + # # Not a good test as `param.annotation` has type `str` and the type could + # # have been renamed. We need to do an `isinstance` check but do not have + # # access to the objects # if i == 0: # assert param.annotation == "RenderMeta" # if i == 1: @@ -489,15 +514,24 @@ class RendererComponents(Generic[IT, OT, P]): Properties ---------- type_decorator - The return type for the renderer decorator wrapper function. This should be used when the app-defined render function is `None` and extra parameters are being supplied. + The return type for the renderer decorator wrapper function. This should be used + when the app-defined render function is `None` and extra parameters are being + supplied. type_renderer_fn - The (non-`None`) type for the renderer function's first argument that accepts an app-defined render function. This type should be paired with the return type: `type_renderer`. + The (non-`None`) type for the renderer function's first argument that accepts an + app-defined render function. This type should be paired with the return type: + `type_renderer`. type_renderer - The type for the return value of the renderer decorator function. This should be used when the app-defined render function is not `None`. + The type for the return value of the renderer decorator function. This should be + used when the app-defined render function is not `None`. type_impl_fn - The type for the implementation function's first argument. This value handles both app-defined render functions and `None` and returns values appropriate for both cases. `type_impl_fn` should be paired with `type_impl`. + The type for the implementation function's first argument. This value handles + both app-defined render functions and `None` and returns values appropriate for + both cases. `type_impl_fn` should be paired with `type_impl`. type_impl - The type for the return value of the implementation function. This value handles both app-defined render functions and `None` and returns values appropriate for both cases. + The type for the return value of the implementation function. This value handles + both app-defined render functions and `None` and returns values appropriate for + both cases. See Also -------- @@ -574,9 +608,9 @@ def renderer_components( The handler function is supplied meta renderer information, the (app-supplied) render function, and any keyword arguments supplied to the renderer decorator: * The first parameter to the handler function has the class - :class:`~shiny.render.RenderMeta` and is typically called (e.g. `_meta`). This information - gives context the to the handler while trying to resolve the app-supplied render - function (e.g. `_fn`). + :class:`~shiny.render.RenderMeta` and is typically called (e.g. `_meta`). This + information gives context the to the handler while trying to resolve the + app-supplied render function (e.g. `_fn`). * The second parameter is the app-defined render function (e.g. `_fn`). It's return type (`IT`) determines what types can be returned by the app-supplied render function. For example, if `_fn` has the type `RenderFnAsync[str | None]`, both the @@ -598,12 +632,17 @@ def renderer_components( Notes ----- - When defining the renderer decorator overloads, if you use `**kwargs: object`, you may get a type error about incompatible signatures. To fix this, you can use `**kwargs: Any` instead or add `_fn: None = None` as a first parameter. + When defining the renderer decorator overloads, if you have extra parameters of + `**kwargs: object`, you may get a type error about incompatible signatures. To fix + this, you can use `**kwargs: Any` instead or add `_fn: None = None` as the first + parameter in the overload containing the `**kwargs: object`. Parameters ---------- handler_fn - Asynchronous function used to determine the app-supplied value type (`IT`), the rendered type (`OT`), and the parameters app authors can supply to the renderer. + Asynchronous function used to determine the app-supplied value type (`IT`), the + rendered type (`OT`), and the parameters (`P`) app authors can supply to the + renderer. Returns ------- @@ -633,10 +672,7 @@ def as_render_fn( val = as_render_fn(render_fn) return val - ret = RendererComponents(renderer_decorator) - # Copy over docs. Even if they do not show up in pylance - # ret.__doc__ = handler_fn.__doc__ - return ret + return RendererComponents(renderer_decorator) # ====================================================================================== From 22ed3bf845d42b42f5a4649a005aacdcf940464f Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Thu, 3 Aug 2023 10:49:37 -0400 Subject: [PATCH 40/64] Add changelog entry --- CHANGELOG.md | 11 +++++++++++ shiny/render/_render.py | 2 -- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 01ac08115..f03cc2531 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [UNRELEASED] + +### New features + +* Added `shiny.render.renderer_components` decorator to help create new output renderers. (#621) + +### Bug fixes + +### Other changes + + ## [0.5.0] - 2023-08-01 ### New features diff --git a/shiny/render/_render.py b/shiny/render/_render.py index 01b83d2c4..4ff2abe50 100644 --- a/shiny/render/_render.py +++ b/shiny/render/_render.py @@ -2,8 +2,6 @@ # See https://www.python.org/dev/peps/pep-0655/#usage-in-python-3-11 from __future__ import annotations -# TODO-barret; Changelog entry - __all__ = ( "text", From 322bb0e66323423f3a9f978933bc0b1d8ff18a91 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Thu, 3 Aug 2023 10:49:56 -0400 Subject: [PATCH 41/64] Bump to dev version: 0.5.0.9000 --- shiny/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shiny/__init__.py b/shiny/__init__.py index 4e8c21ddb..28c5f7ddd 100644 --- a/shiny/__init__.py +++ b/shiny/__init__.py @@ -1,6 +1,6 @@ """A package for building reactive web applications.""" -__version__ = "0.5.0" +__version__ = "0.5.0.9000" from ._shinyenv import is_pyodide as _is_pyodide From 4e568a9b160d28a3522b4e95c89cff85b43ba02f Mon Sep 17 00:00:00 2001 From: Winston Chang Date: Fri, 4 Aug 2023 15:38:17 -0500 Subject: [PATCH 42/64] Partial code review changes --- e2e/server/renderer/app.py | 18 +- examples/event/app.py | 1 - shiny/api-examples/renderer_components/app.py | 18 +- shiny/reactive/_reactives.py | 4 +- shiny/render/__init__.py | 10 +- shiny/render/_dataframe.py | 16 +- shiny/render/_render.py | 341 +++++++++--------- shiny/session/_session.py | 14 +- tests/test_renderer.py | 78 ++-- 9 files changed, 251 insertions(+), 249 deletions(-) diff --git a/e2e/server/renderer/app.py b/e2e/server/renderer/app.py index 05382c0e9..2fcf15e2a 100644 --- a/e2e/server/renderer/app.py +++ b/e2e/server/renderer/app.py @@ -5,13 +5,13 @@ from typing import Optional, overload from shiny import App, Inputs, Outputs, Session, ui -from shiny.render._render import RenderFnAsync, RenderMeta, renderer_components +from shiny.render._render import TransformerMetadata, ValueFnAsync, output_transformer -@renderer_components +@output_transformer async def _render_test_text_components( - _meta: RenderMeta, - _fn: RenderFnAsync[str | None], + _meta: TransformerMetadata, + _fn: ValueFnAsync[str | None], *, extra_txt: Optional[str] = None, ) -> str | None: @@ -27,22 +27,22 @@ async def _render_test_text_components( @overload def render_test_text( *, extra_txt: Optional[str] = None -) -> _render_test_text_components.type_decorator: +) -> _render_test_text_components.OutputRendererDecorator: ... @overload def render_test_text( - _fn: _render_test_text_components.type_renderer_fn, -) -> _render_test_text_components.type_renderer: + _fn: _render_test_text_components.ValueFn, +) -> _render_test_text_components.OutputRenderer: ... def render_test_text( - _fn: _render_test_text_components.type_impl_fn = None, + _fn: _render_test_text_components.ValueFnOrNone = None, *, extra_txt: Optional[str] = None, -) -> _render_test_text_components.type_impl: +) -> _render_test_text_components.OutputRendererOrDecorator: return _render_test_text_components.impl( _fn, _render_test_text_components.params(extra_txt=extra_txt), diff --git a/examples/event/app.py b/examples/event/app.py index 412c25b3b..c96f7ca85 100644 --- a/examples/event/app.py +++ b/examples/event/app.py @@ -41,7 +41,6 @@ def btn() -> int: def _(): print("@calc() event: ", str(btn())) - @output @render.ui @reactive.event(input.btn) def btn_value(): diff --git a/shiny/api-examples/renderer_components/app.py b/shiny/api-examples/renderer_components/app.py index e574e6b2d..1ed2829bf 100644 --- a/shiny/api-examples/renderer_components/app.py +++ b/shiny/api-examples/renderer_components/app.py @@ -5,7 +5,7 @@ from typing import Literal, overload from shiny import App, Inputs, Outputs, Session, ui -from shiny.render import RenderFnAsync, RenderMeta, renderer_components +from shiny.render import TransformerMetadata, ValueFnAsync, output_transformer ####### # Package authors can create their own renderer methods by leveraging @@ -17,12 +17,12 @@ # Create renderer components from the async handler function: `capitalize_components()` -@renderer_components +@output_transformer async def capitalize_components( # Contains information about the render call: `name`, `session`, `is_async` - _meta: RenderMeta, + _meta: TransformerMetadata, # An async form of the app-supplied render function - _fn: RenderFnAsync[str | None], + _fn: ValueFnAsync[str | None], *, # Extra parameters that app authors can supply (e.g. `render_capitalize(to="upper")`) to: Literal["upper", "lower"] = "upper", @@ -53,7 +53,7 @@ async def capitalize_components( def render_capitalize( *, to: Literal["upper", "lower"] = "upper", -) -> capitalize_components.type_decorator: +) -> capitalize_components.OutputRendererDecorator: ... @@ -70,8 +70,8 @@ def render_capitalize( # Note: Return type is `type_renderer` @overload def render_capitalize( - _fn: capitalize_components.type_renderer_fn, -) -> capitalize_components.type_renderer: + _fn: capitalize_components.ValueFn, +) -> capitalize_components.OutputRenderer: ... @@ -79,10 +79,10 @@ def render_capitalize( # Note: `_fn` type is `type_impl_fn` # Note: Return type is `type_impl` def render_capitalize( - _fn: capitalize_components.type_impl_fn = None, + _fn: capitalize_components.ValueFnOrNone = None, *, to: Literal["upper", "lower"] = "upper", -) -> capitalize_components.type_impl: +) -> capitalize_components.OutputRendererOrDecorator: return capitalize_components.impl( _fn, capitalize_components.params(to=to), diff --git a/shiny/reactive/_reactives.py b/shiny/reactive/_reactives.py index 614d58da5..c314b64ed 100644 --- a/shiny/reactive/_reactives.py +++ b/shiny/reactive/_reactives.py @@ -22,7 +22,7 @@ from .._docstring import add_example from .._utils import is_async_callable, run_coro_sync from .._validation import req -from ..render._render import Renderer +from ..render._render import OutputRenderer from ..types import MISSING, MISSING_TYPE, ActionButtonValue, SilentException from ._core import Context, Dependents, ReactiveWarning, isolate @@ -782,7 +782,7 @@ def decorator(user_fn: Callable[[], T]) -> Callable[[], T]: + "In other words, `@reactive.Calc` must be above `@reactive.event()`." ) - if isinstance(user_fn, Renderer): + if isinstance(user_fn, OutputRenderer): # At some point in the future, we may allow this condition, if we find an # use case. For now we'll disallow it, for simplicity. raise TypeError( diff --git a/shiny/render/__init__.py b/shiny/render/__init__.py index d867151fe..734625319 100644 --- a/shiny/render/__init__.py +++ b/shiny/render/__init__.py @@ -17,11 +17,11 @@ # These values need ` as FOO` as the variable is not used in `__all__` and causes an # reportUnusedImport error from pylance. # Using the same name is allowed. - RenderMeta as RenderMeta, - RenderFnAsync as RenderFnAsync, - RendererParams as RendererParams, - RendererComponents as RendererComponents, - renderer_components as renderer_components, + TransformerMetadata as TransformerMetadata, + ValueFnAsync as ValueFnAsync, + TransformerParams as TransformerParams, + OutputTransformer as OutputTransformer, + output_transformer as output_transformer, # Deprecated / legacy classes RenderFunction as RenderFunction, RenderFunctionAsync as RenderFunctionAsync, diff --git a/shiny/render/_dataframe.py b/shiny/render/_dataframe.py index e0c7165f2..afc82ea13 100644 --- a/shiny/render/_dataframe.py +++ b/shiny/render/_dataframe.py @@ -14,7 +14,7 @@ ) from .._docstring import add_example -from . import RenderFnAsync, RenderMeta, renderer_components +from . import TransformerMetadata, ValueFnAsync, output_transformer from ._dataframe_unsafe import serialize_numpy_dtypes if TYPE_CHECKING: @@ -216,10 +216,10 @@ def serialize_pandas_df(df: "pd.DataFrame") -> dict[str, Any]: # TODO-barret; Port `__name__` and `__docs__` of `value_fn` -@renderer_components +@output_transformer async def _data_frame( - _meta: RenderMeta, - _fn: RenderFnAsync[DataFrameResult | None], + _meta: TransformerMetadata, + _fn: ValueFnAsync[DataFrameResult | None], ) -> object | None: x = await _fn() if x is None: @@ -235,17 +235,19 @@ async def _data_frame( @overload -def data_frame() -> _data_frame.type_decorator: +def data_frame() -> _data_frame.OutputRendererDecorator: ... @overload -def data_frame(_fn: _data_frame.type_renderer_fn) -> _data_frame.type_renderer: +def data_frame(_fn: _data_frame.ValueFn) -> _data_frame.OutputRenderer: ... @add_example() -def data_frame(_fn: _data_frame.type_impl_fn = None) -> _data_frame.type_impl: +def data_frame( + _fn: _data_frame.ValueFnOrNone = None, +) -> _data_frame.OutputRendererOrDecorator: """ Reactively render a Pandas data frame object (or similar) as a basic HTML table. diff --git a/shiny/render/_render.py b/shiny/render/_render.py index 4ff2abe50..43ba09ec7 100644 --- a/shiny/render/_render.py +++ b/shiny/render/_render.py @@ -2,7 +2,6 @@ # See https://www.python.org/dev/peps/pep-0655/#usage-in-python-3-11 from __future__ import annotations - __all__ = ( "text", "plot", @@ -51,7 +50,7 @@ IT = TypeVar("IT") # Output type after the Renderer.__call__ method is called on the IT object. OT = TypeVar("OT") -# Param specification for render_fn function +# Param specification for value_fn function P = ParamSpec("P") @@ -61,11 +60,11 @@ # Meta information to give `hander()` some context -class RenderMeta(NamedTuple): +class TransformerMetadata(NamedTuple): """ - Renderer meta information + Transformer metadata - This class is used to hold meta information for a renderer handler function. + This class is used to hold meta information for a transformer function. Properties ---------- @@ -82,11 +81,11 @@ class RenderMeta(NamedTuple): name: str -class RendererParams(Generic[P]): +class TransformerParams(Generic[P]): """ - Parameters for a renderer function + Parameters for a transformer function - This class is used to hold the parameters for a renderer function. It is used to + This class is used to hold the parameters for a transformer function. It is used to enforce that the parameters are used in the correct order. Properties @@ -95,18 +94,18 @@ class RendererParams(Generic[P]): No positional arguments should be supplied. Only keyword arguments should be supplied. **kwargs - Keyword arguments for the corresponding renderer function. + Keyword arguments for the corresponding transformer function. """ # Motivation for using this class: # * https://peps.python.org/pep-0612/ does allow for prepending an arg (e.g. - # `render_fn`). - # * However, the overload is not happy when both a positional arg (e.g. `render_fn`) + # `value_fn`). + # * However, the overload is not happy when both a positional arg (e.g. `value_fn`) # is dropped and the variadic positional args (`*args`) are kept. # * The variadic positional args (`*args`) CAN NOT be dropped as PEP612 states that # both components of the `ParamSpec` must be used in the same function signature. # * By making assertions on `P.args` to only allow for `*`, we _can_ make overloads - # that use either the single positional arg (e.g. `render_fn`) or the `P.kwargs` + # that use either the single positional arg (e.g. `value_fn`) or the `P.kwargs` # (as `P.args` == `*`) def __init__(self, *args: P.args, **kwargs: P.kwargs) -> None: """ @@ -120,7 +119,7 @@ def __init__(self, *args: P.args, **kwargs: P.kwargs) -> None: """ # Make sure there no `args` when running! - # This check is related to `_assert_handler_fn` not accepting any `args` + # This check is related to `_assert_transform_fn` not accepting any `args` if len(args) > 0: raise RuntimeError("`args` should not be supplied") @@ -131,9 +130,9 @@ def __init__(self, *args: P.args, **kwargs: P.kwargs) -> None: self.kwargs = kwargs @staticmethod - def empty_params() -> RendererParams[P]: - def inner(*args: P.args, **kwargs: P.kwargs) -> RendererParams[P]: - return RendererParams[P](*args, **kwargs) + def empty_params() -> TransformerParams[P]: + def inner(*args: P.args, **kwargs: P.kwargs) -> TransformerParams[P]: + return TransformerParams[P](*args, **kwargs) return inner() @@ -142,30 +141,32 @@ def inner(*args: P.args, **kwargs: P.kwargs) -> RendererParams[P]: # Renderer / RendererSync / RendererAsync base class # ====================================================================================== -# A `RenderFn` function is an app-supplied function which returns an IT. +# A `ValueFn` function is an app-supplied function which returns an IT. # It can be either synchronous or asynchronous -RenderFnSync = Callable[[], IT] -RenderFnAsync = Callable[[], Awaitable[IT]] -RenderFn = Union[RenderFnSync[IT], RenderFnAsync[IT]] +ValueFnSync = Callable[[], IT] +ValueFnAsync = Callable[[], Awaitable[IT]] +ValueFn = Union[ValueFnSync[IT], ValueFnAsync[IT]] -# `HandlerFn` is a package author function that transforms an object of type `IT` into type `OT`. -HandlerFn = Callable[Concatenate[RenderMeta, RenderFnAsync[IT], P], Awaitable[OT]] +# `TransformFn` is a package author function that transforms an object of type `IT` into type `OT`. +TransformFn = Callable[ + Concatenate[TransformerMetadata, ValueFnAsync[IT], P], Awaitable[OT] +] -class Renderer(Generic[OT], ABC): +class OutputRenderer(Generic[OT], ABC): """ Output Renderer Base class for classes :class:`~shiny.render.RendererSync` and :class:`~shiny.render.RendererAsync`. - When the `.__call__` method is invoked, the handler function (`handler_fn`) + When the `.__call__` method is invoked, the handler function (`transform_fn`) (typically defined by package authors) is asynchronously called. The handler function is given `meta` information, the (app-supplied) render function, and any keyword arguments supplied to the render decorator. For consistency, the first two parameters have been (arbitrarily) implemented as `_meta` and `_fn`. - The (app-supplied) render function (`render_fn`) returns type `IT`. The handler + The (app-supplied) value function (`value_fn`) returns type `IT`. The handler function (defined by package authors) defines the parameter specification of type `P` and asynchronously returns an object of type `OT`. Note that in many cases but not all, `IT` and `OT` will be the same. `None` values should always be defined in @@ -197,15 +198,16 @@ def __call__(self) -> OT: def __init__( self, *, - render_fn: RenderFn[IT], - handler_fn: HandlerFn[IT, P, OT], - params: RendererParams[P], + value_fn: ValueFn[IT], + transform_fn: TransformFn[IT, P, OT], + params: TransformerParams[P], ) -> None: """ Renderer init method - Arguments - --------- + TODO: Barret + Parameters + ---------- name Name of original output function. Ex: `my_txt` doc @@ -214,18 +216,18 @@ def __init__( """ # Copy over function name as it is consistent with how Session and Output # retrieve function names - self.__name__ = render_fn.__name__ + self.__name__ = value_fn.__name__ - if not _utils.is_async_callable(handler_fn): + if not _utils.is_async_callable(transform_fn): raise TypeError( self.__class__.__name__ + " requires an async handler function" ) - # `render_fn` is not required to be async. For consistency, we wrapped in an - # async function so that when it's passed in to `handler_fn`, `render_fn` is + # `value_fn` is not required to be async. For consistency, we wrapped in an + # async function so that when it's passed in to `transform_fn`, `value_fn` is # **always** an async function. - self._render_fn = _utils.wrap_async(render_fn) - self._handler_fn = handler_fn + self._value_fn = _utils.wrap_async(value_fn) + self._transformer = transform_fn self._params = params def _set_metadata(self, session: Session, name: str) -> None: @@ -236,8 +238,8 @@ def _set_metadata(self, session: Session, name: str) -> None: self._session: Session = session self._name: str = name - def _meta(self) -> RenderMeta: - return RenderMeta( + def _meta(self) -> TransformerMetadata: + return TransformerMetadata( is_async=self._is_async(), session=self._session, name=self._name, @@ -262,11 +264,11 @@ async def _run(self) -> OT: :class:`~shiny.render.RendererParams` which does not allow positional arguments. """ - ret = await self._handler_fn( + ret = await self._transformer( # RenderMeta self._meta(), # Callable[[], Awaitable[IT]] - self._render_fn, + self._value_fn, # P *self._params.args, **self._params.kwargs, @@ -275,7 +277,7 @@ async def _run(self) -> OT: # Using a second class to help clarify that it is of a particular type -class RendererSync(Renderer[OT]): +class OutputRendererSync(OutputRenderer[OT]): """ Output Renderer (Synchronous) @@ -301,18 +303,18 @@ def _is_async(self) -> bool: def __init__( self, - render_fn: RenderFnSync[IT], - handler_fn: HandlerFn[IT, P, OT], - params: RendererParams[P], + value_fn: ValueFnSync[IT], + transform_fn: TransformFn[IT, P, OT], + params: TransformerParams[P], ) -> None: - if _utils.is_async_callable(render_fn): + if _utils.is_async_callable(value_fn): raise TypeError( self.__class__.__name__ + " requires a synchronous render function" ) # super == Renderer super().__init__( - render_fn=render_fn, - handler_fn=handler_fn, + value_fn=value_fn, + transform_fn=transform_fn, params=params, ) @@ -323,7 +325,7 @@ def __call__(self) -> OT: # 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 RendererAsync(Renderer[OT]): +class OutputRendererAsync(OutputRenderer[OT]): """ Output Renderer (Asynchronous) @@ -349,18 +351,18 @@ def _is_async(self) -> bool: def __init__( self, - render_fn: RenderFnAsync[IT], - handler_fn: HandlerFn[IT, P, OT], - params: RendererParams[P], + value_fn: ValueFnAsync[IT], + transform_fn: TransformFn[IT, P, OT], + params: TransformerParams[P], ) -> None: - if not _utils.is_async_callable(render_fn): + if not _utils.is_async_callable(value_fn): raise TypeError( self.__class__.__name__ + " requires an asynchronous render function" ) # super == Renderer super().__init__( - render_fn=render_fn, - handler_fn=handler_fn, + value_fn=value_fn, + transform_fn=transform_fn, params=params, ) @@ -377,7 +379,7 @@ async def __call__(self) -> OT: # pyright: ignore[reportIncompatibleMethodOverr # 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], RendererSync[OT], ABC): +class RenderFunction(Generic[IT, OT], OutputRendererSync[OT], ABC): """ Deprecated. Please use :func:`~shiny.render.renderer_components` instead. """ @@ -390,15 +392,15 @@ def __call__(self) -> OT: async def run(self) -> OT: ... - def __init__(self, fn: RenderFnSync[IT]) -> None: - async def handler_fn(_meta: RenderMeta, _fn: RenderFnAsync[IT]) -> OT: + def __init__(self, fn: ValueFnSync[IT]) -> None: + async def transformer(_meta: TransformerMetadata, _fn: ValueFnAsync[IT]) -> OT: ret = await self.run() return ret super().__init__( - render_fn=fn, - handler_fn=handler_fn, - params=RendererParams.empty_params(), + value_fn=fn, + transform_fn=transformer, + params=TransformerParams.empty_params(), ) self._fn = fn @@ -406,7 +408,7 @@ async def handler_fn(_meta: RenderMeta, _fn: RenderFnAsync[IT]) -> OT: # 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], RendererAsync[OT], ABC): +class RenderFunctionAsync(Generic[IT, OT], OutputRendererAsync[OT], ABC): """ Deprecated. Please use :func:`~shiny.render.renderer_components` instead. """ @@ -419,15 +421,15 @@ async def __call__(self) -> OT: # pyright: ignore[reportIncompatibleMethodOverr async def run(self) -> OT: ... - def __init__(self, fn: RenderFnAsync[IT]) -> None: - async def handler_fn(_meta: RenderMeta, _fn: RenderFnAsync[IT]) -> OT: + def __init__(self, fn: ValueFnAsync[IT]) -> None: + async def transformer(_meta: TransformerMetadata, _fn: ValueFnAsync[IT]) -> OT: ret = await self.run() return ret super().__init__( - render_fn=fn, - handler_fn=handler_fn, - params=RendererParams.empty_params(), + value_fn=fn, + transform_fn=transformer, + params=TransformerParams.empty_params(), ) self._fn = fn @@ -440,14 +442,15 @@ async def handler_fn(_meta: RenderMeta, _fn: RenderFnAsync[IT]) -> OT: # assert: No variable length positional values; # * We need a way to distinguish between a plain function and args supplied to the next # function. This is done by not allowing `*args`. -# assert: All kwargs of handler_fn should have a default value +# assert: All kwargs of transformer should have a default value # * This makes calling the method with both `()` and without `()` possible / consistent. -def _assert_handler_fn(handler_fn: HandlerFn[IT, P, OT]) -> None: - params = inspect.Signature.from_callable(handler_fn).parameters +def _assert_transformer(transform_fn: TransformFn[IT, P, OT]) -> None: + params = inspect.Signature.from_callable(transform_fn).parameters if len(params) < 2: raise TypeError( - "`handler_fn=` must have 2 positional parameters which have type `RenderMeta` and `RenderFnAsync` respectively" + "`transformer=` must have 2 positional parameters which have type " + "`RenderMeta` and `RenderFnAsync` respectively" ) for i, param in zip(range(len(params)), params.values()): @@ -463,18 +466,20 @@ def _assert_handler_fn(handler_fn: HandlerFn[IT, P, OT]) -> None: or param.kind == inspect.Parameter.POSITIONAL_ONLY ): raise TypeError( - "`handler_fn=` must have 2 positional parameters which have type `RenderMeta` and `RenderFnAsync` respectively" + "`transformer=` must have 2 positional parameters which have type " + "`RenderMeta` and `RenderFnAsync` respectively" ) # Make sure there are no more than 2 positional args if i >= 2 and param.kind == inspect.Parameter.POSITIONAL_OR_KEYWORD: raise TypeError( - "`handler_fn=` must not contain more than 2 positional parameters" + "`transformer=` must not contain more than 2 positional parameters" ) # Make sure there are no `*args` if param.kind == inspect.Parameter.VAR_POSITIONAL: raise TypeError( - f"No variadic positional parameters (e.g. `*args`) can be supplied to `handler_fn=`. Received: `{param.name}`. Please only use `*`." + "No variadic positional parameters (e.g. `*args`) can be supplied to " + f"`transformer=`. Received: `{param.name}`. Please only use `*`." ) # Make sure kwargs have default values if ( @@ -482,7 +487,7 @@ def _assert_handler_fn(handler_fn: HandlerFn[IT, P, OT]) -> None: and param.default is inspect.Parameter.empty ): raise TypeError( - f"In `handler_fn=`, parameter `{param.name}` did not have a default value" + f"In `transformer=`, parameter `{param.name}` did not have a default value" ) @@ -492,22 +497,26 @@ def _assert_handler_fn(handler_fn: HandlerFn[IT, P, OT]) -> None: # Signature of a renderer decorator function -RendererDeco = Callable[[RenderFn[IT]], Renderer[OT]] -# Signature of a decorator that can be called with and without parenthesis +OutputRendererDecorator = Callable[[ValueFn[IT]], OutputRenderer[OT]] +# Signature of a decorator that can be called with and without parentheses # With parens returns a `Renderer[OT]` # Without parens returns a `RendererDeco[IT, OT]` -RenderImplFn = Callable[ +OutputRendererImplFn = Callable[ [ - Optional[RenderFn[IT]], - RendererParams[P], + Optional[ValueFn[IT]], + TransformerParams[P], ], - Union[Renderer[OT], RendererDeco[IT, OT]], + Union[OutputRenderer[OT], OutputRendererDecorator[IT, OT]], ] -class RendererComponents(Generic[IT, OT, P]): +class OutputTransformer(Generic[IT, OT, P]): """ - Renderer Component class + Output Transformer class + + A Transfomer takes the value returned from the user's render function, passes it + through the component author's transformer function, and returns the result. TODO: + clean up Properties ---------- @@ -538,57 +547,46 @@ class RendererComponents(Generic[IT, OT, P]): * :class:`~shiny.render.Renderer` """ - @property - def type_decorator(self): - return RendererDeco[IT, OT] - - @property - def type_renderer_fn(self): - return RenderFn[IT] - - @property - def type_renderer(self): - return Renderer[OT] - - @property - def type_impl_fn(self): - return Optional[RenderFn[IT]] - - @property - def type_impl(self): - return Union[Renderer[OT], RendererDeco[IT, OT]] - def params( self, *args: P.args, **kwargs: P.kwargs, - ) -> RendererParams[P]: - return RendererParams(*args, **kwargs) + ) -> TransformerParams[P]: + return TransformerParams(*args, **kwargs) + # TODO: convert to __call__ def impl( self, - render_fn: RenderFn[IT] | None, - params: RendererParams[P] | None = None, - ) -> Renderer[OT] | RendererDeco[IT, OT]: + value_fn: ValueFn[IT] | None, + params: TransformerParams[P] | None = None, + ) -> OutputRenderer[OT] | OutputRendererDecorator[IT, OT]: if params is None: params = self.params() - if not isinstance(params, RendererParams): + if not isinstance(params, TransformerParams): raise TypeError( f"Expected `params` to be of type `RendererParams` but received `{type(params)}`. Please use `.params()` to create a `RendererParams` object." ) - return self._fn(render_fn, params) + return self._fn(value_fn, params) def __init__( self, - fn: RenderImplFn[IT, P, OT], + fn: OutputRendererImplFn[IT, P, OT], ) -> None: self._fn = fn + self.ValueFn = ValueFn[IT] + self.OutputRenderer = OutputRenderer[OT] + self.OutputRendererDecorator = OutputRendererDecorator[IT, OT] + # TODO: Remove the following types + self.ValueFnOrNone = Union[ValueFn[IT], None] + self.OutputRendererOrDecorator = Union[ + OutputRenderer[OT], OutputRendererDecorator[IT, OT] + ] @add_example() -def renderer_components( - handler_fn: HandlerFn[IT, P, OT], -) -> RendererComponents[IT, OT, P]: +def output_transformer( + transform_fn: TransformFn[IT, P, OT], +) -> OutputTransformer[IT, OT, P]: """ Renderer components decorator @@ -600,7 +598,7 @@ def renderer_components( ## Handler function - The renderer's asynchronous handler function (`handler_fn`) is the key building + The renderer's asynchronous handler function (`transform_fn`) is the key building block for `renderer_components`. The handler function is supplied meta renderer information, the (app-supplied) @@ -637,7 +635,7 @@ def renderer_components( Parameters ---------- - handler_fn + transform_fn Asynchronous function used to determine the app-supplied value type (`IT`), the rendered type (`OT`), and the parameters (`P`) app authors can supply to the renderer. @@ -650,27 +648,27 @@ def renderer_components( is called without parenthesis and the other is for when the renderer is called with parenthesis. """ - _assert_handler_fn(handler_fn) + _assert_transformer(transform_fn) def renderer_decorator( - render_fn: RenderFnSync[IT] | RenderFnAsync[IT] | None, - params: RendererParams[P], - ) -> Renderer[OT] | RendererDeco[IT, OT]: - def as_render_fn( - fn: RenderFnSync[IT] | RenderFnAsync[IT], - ) -> Renderer[OT]: + value_fn: ValueFnSync[IT] | ValueFnAsync[IT] | None, + params: TransformerParams[P], + ) -> OutputRenderer[OT] | OutputRendererDecorator[IT, OT]: + def as_value_fn( + fn: ValueFnSync[IT] | ValueFnAsync[IT], + ) -> OutputRenderer[OT]: if _utils.is_async_callable(fn): - return RendererAsync(fn, handler_fn, params) + return OutputRendererAsync(fn, transform_fn, params) else: - fn = cast(RenderFnSync[IT], fn) - return RendererSync(fn, handler_fn, params) + fn = cast(ValueFnSync[IT], fn) + return OutputRendererSync(fn, transform_fn, params) - if render_fn is None: - return as_render_fn - val = as_render_fn(render_fn) + if value_fn is None: + return as_value_fn + val = as_value_fn(value_fn) return val - return RendererComponents(renderer_decorator) + return OutputTransformer(renderer_decorator) # ====================================================================================== @@ -678,30 +676,30 @@ def as_render_fn( # ====================================================================================== -@renderer_components -async def _text( - _meta: RenderMeta, - _fn: RenderFnAsync[str | None], +@output_transformer +async def TextTransformer( + _meta: TransformerMetadata, + _afn: ValueFnAsync[str | None], ) -> str | None: - value = await _fn() + value = await _afn() if value is None: return None return str(value) @overload -def text() -> _text.type_decorator: +def text() -> TextTransformer.OutputRendererDecorator: ... @overload -def text(_fn: _text.type_renderer_fn) -> _text.type_renderer: +def text(_fn: TextTransformer.ValueFn) -> TextTransformer.OutputRenderer: ... def text( - _fn: _text.type_impl_fn = None, -) -> _text.type_impl: + _fn: TextTransformer.ValueFn | None = None, +) -> TextTransformer.OutputRenderer | TextTransformer.OutputRendererDecorator: """ Reactively render text. @@ -721,7 +719,7 @@ def text( -------- ~shiny.ui.output_text """ - return _text.impl(_fn) + return TextTransformer.impl(_fn) # ====================================================================================== @@ -731,10 +729,10 @@ def text( # 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`. -@renderer_components -async def _plot( - _meta: RenderMeta, - _fn: RenderFnAsync[ImgData | None], +@output_transformer +async def PlotTransformer( + _meta: TransformerMetadata, + _afn: ValueFnAsync[ImgData | None], *, alt: Optional[str] = None, **kwargs: object, @@ -758,7 +756,7 @@ async def _plot( float, inputs[ResolvedId(f".clientdata_output_{name}_height")]() ) - x = await _fn() + x = await _afn() # Note that x might be None; it could be a matplotlib.pyplot @@ -834,21 +832,21 @@ def plot( *, alt: Optional[str] = None, **kwargs: Any, -) -> _plot.type_decorator: +) -> PlotTransformer.OutputRendererDecorator: ... @overload -def plot(_fn: _plot.type_renderer_fn) -> _plot.type_renderer: +def plot(_fn: PlotTransformer.ValueFn) -> PlotTransformer.OutputRenderer: ... def plot( - _fn: _plot.type_impl_fn = None, + _fn: PlotTransformer.ValueFn | None = None, *, alt: Optional[str] = None, **kwargs: Any, -) -> _plot.type_impl: +) -> PlotTransformer.OutputRenderer | PlotTransformer.OutputRendererDecorator: """ Reactively render a plot object as an HTML image. @@ -892,16 +890,16 @@ def plot( ~shiny.ui.output_plot ~shiny.render.image """ - return _plot.impl(_fn, _plot.params(alt=alt, **kwargs)) + return PlotTransformer.impl(_fn, PlotTransformer.params(alt=alt, **kwargs)) # ====================================================================================== # RenderImage # ====================================================================================== -@renderer_components +@output_transformer async def _image( - _meta: RenderMeta, - _fn: RenderFnAsync[ImgData | None], + _meta: TransformerMetadata, + _fn: ValueFnAsync[ImgData | None], *, delete_file: bool = False, ) -> ImgData | None: @@ -926,20 +924,23 @@ async def _image( def image( *, delete_file: bool = False, -) -> _image.type_decorator: +) -> OutputRendererDecorator[ImgData | None, ImgData | None]: ... @overload -def image(_fn: _image.type_renderer_fn) -> _image.type_renderer: +def image(_fn: ValueFn[ImgData | None]) -> OutputRenderer[ImgData | None]: ... def image( - _fn: _image.type_impl_fn = None, + _fn: Optional[ValueFn[ImgData | None]] = None, *, delete_file: bool = False, -) -> _image.type_impl: +) -> Union[ + OutputRenderer[ImgData | None], + OutputRendererDecorator[ImgData | None, ImgData | None], +]: """ Reactively render a image file as an HTML image. @@ -984,10 +985,10 @@ def to_pandas(self) -> "pd.DataFrame": TableResult = Union["pd.DataFrame", PandasCompatible, None] -@renderer_components +@output_transformer async def _table( - _meta: RenderMeta, - _fn: RenderFnAsync[TableResult | None], + _meta: TransformerMetadata, + _fn: ValueFnAsync[TableResult | None], *, index: bool = False, classes: str = "table shiny-table w-auto", @@ -1039,23 +1040,23 @@ def table( classes: str = "table shiny-table w-auto", border: int = 0, **kwargs: Any, -) -> _table.type_decorator: +) -> _table.OutputRendererDecorator: ... @overload -def table(_fn: _table.type_renderer_fn) -> _table.type_renderer: +def table(_fn: _table.ValueFn) -> _table.OutputRenderer: ... def table( - _fn: _table.type_impl_fn = None, + _fn: _table.ValueFnOrNone = None, *, index: bool = False, classes: str = "table shiny-table w-auto", border: int = 0, **kwargs: object, -) -> _table.type_impl: +) -> _table.OutputRendererOrDecorator: """ Reactively render a Pandas data frame object (or similar) as a basic HTML table. @@ -1114,10 +1115,10 @@ def table( # ====================================================================================== # RenderUI # ====================================================================================== -@renderer_components +@output_transformer async def _ui( - _meta: RenderMeta, - _fn: RenderFnAsync[TagChild], + _meta: TransformerMetadata, + _fn: ValueFnAsync[TagChild], ) -> RenderedDeps | None: ui = await _fn() if ui is None: @@ -1127,18 +1128,18 @@ async def _ui( @overload -def ui() -> _ui.type_decorator: +def ui() -> _ui.OutputRendererDecorator: ... @overload -def ui(_fn: _ui.type_renderer_fn) -> _ui.type_renderer: +def ui(_fn: _ui.ValueFn) -> _ui.OutputRenderer: ... def ui( - _fn: _ui.type_impl_fn = None, -) -> _ui.type_impl: + _fn: _ui.ValueFnOrNone = None, +) -> _ui.OutputRendererOrDecorator: """ Reactively render HTML content. diff --git a/shiny/session/_session.py b/shiny/session/_session.py index 81f7c22f8..ab911bc6e 100644 --- a/shiny/session/_session.py +++ b/shiny/session/_session.py @@ -47,7 +47,7 @@ from ..input_handler import input_handlers from ..reactive import Effect, Effect_, Value, flush, isolate from ..reactive._core import lock, on_flushed -from ..render._render import Renderer +from ..render._render import OutputRenderer from ..types import SafeException, SilentCancelOutputException, SilentException from ._utils import RenderedDeps, read_thunk_opt, session_context @@ -955,7 +955,7 @@ def __init__( self._suspend_when_hidden = suspend_when_hidden @overload - def __call__(self, renderer_fn: Renderer[Any]) -> None: + def __call__(self, renderer_fn: OutputRenderer[Any]) -> None: ... @overload @@ -966,18 +966,18 @@ def __call__( suspend_when_hidden: bool = True, priority: int = 0, name: Optional[str] = None, - ) -> Callable[[Renderer[Any]], None]: + ) -> Callable[[OutputRenderer[Any]], None]: ... def __call__( self, - renderer_fn: Optional[Renderer[OT]] = None, + renderer_fn: Optional[OutputRenderer[OT]] = None, *, id: Optional[str] = None, suspend_when_hidden: bool = True, priority: int = 0, name: Optional[str] = None, - ) -> None | Callable[[Renderer[OT]], None]: + ) -> None | Callable[[OutputRenderer[OT]], None]: if name is not None: from .. import _deprecated @@ -986,11 +986,11 @@ def __call__( ) id = name - def set_renderer(renderer_fn: Renderer[OT]) -> None: + def set_renderer(renderer_fn: OutputRenderer[OT]) -> None: # Get the (possibly namespaced) output id output_name = self._ns(id or renderer_fn.__name__) - if not isinstance(renderer_fn, Renderer): + if not isinstance(renderer_fn, OutputRenderer): raise TypeError( "`@output` must be applied to a `@render.xx` function.\n" + "In other words, `@output` must be above `@render.xx`." diff --git a/tests/test_renderer.py b/tests/test_renderer.py index e9b4d5197..a96b1c93c 100644 --- a/tests/test_renderer.py +++ b/tests/test_renderer.py @@ -1,59 +1,59 @@ from typing import Any, overload -from shiny.render._render import RenderFnAsync, RenderMeta, renderer_components +from shiny.render._render import TransformerMetadata, ValueFnAsync, output_transformer def test_renderer_components_works(): # No args works - @renderer_components + @output_transformer async def test_components( - _meta: RenderMeta, - _fn: RenderFnAsync[str], + _meta: TransformerMetadata, + _fn: ValueFnAsync[str], ): ... @overload - def test_renderer() -> test_components.type_decorator: + def test_renderer() -> test_components.OutputRendererDecorator: ... @overload def test_renderer( - _fn: test_components.type_renderer_fn, - ) -> test_components.type_renderer: + _fn: test_components.ValueFn, + ) -> test_components.OutputRenderer: ... def test_renderer( - _fn: test_components.type_impl_fn = None, - ) -> test_components.type_impl: + _fn: test_components.ValueFnOrNone = None, + ) -> test_components.OutputRendererOrDecorator: return test_components.impl(_fn) def test_renderer_components_kwargs_are_allowed(): # Test that kwargs can be allowed - @renderer_components + @output_transformer async def test_components( - _meta: RenderMeta, - _fn: RenderFnAsync[str], + _meta: TransformerMetadata, + _fn: ValueFnAsync[str], *, y: str = "42", ): ... @overload - def test_renderer(*, y: str = "42") -> test_components.type_decorator: + def test_renderer(*, y: str = "42") -> test_components.OutputRendererDecorator: ... @overload def test_renderer( - _fn: test_components.type_renderer_fn, - ) -> test_components.type_renderer: + _fn: test_components.ValueFn, + ) -> test_components.OutputRenderer: ... def test_renderer( - _fn: test_components.type_impl_fn = None, + _fn: test_components.ValueFnOrNone = None, *, y: str = "42", - ) -> test_components.type_impl: + ) -> test_components.OutputRendererOrDecorator: return test_components.impl( _fn, test_components.params(y=y), @@ -62,10 +62,10 @@ def test_renderer( def test_renderer_components_with_pass_through_kwargs(): # No args works - @renderer_components + @output_transformer async def test_components( - _meta: RenderMeta, - _fn: RenderFnAsync[str], + _meta: TransformerMetadata, + _fn: ValueFnAsync[str], *, y: str = "42", **kwargs: float, @@ -75,21 +75,21 @@ async def test_components( @overload def test_renderer( *, y: str = "42", **kwargs: Any - ) -> test_components.type_decorator: + ) -> test_components.OutputRendererDecorator: ... @overload def test_renderer( - _fn: test_components.type_renderer_fn, - ) -> test_components.type_renderer: + _fn: test_components.ValueFn, + ) -> test_components.OutputRenderer: ... def test_renderer( - _fn: test_components.type_impl_fn = None, + _fn: test_components.ValueFnOrNone = None, *, y: str = "42", **kwargs: Any, - ) -> test_components.type_impl: + ) -> test_components.OutputRendererOrDecorator: return test_components.impl( _fn, test_components.params(y=y, **kwargs), @@ -99,9 +99,9 @@ def test_renderer( def test_renderer_components_pos_args(): try: - @renderer_components # type: ignore + @output_transformer # type: ignore async def test_components( - _meta: RenderMeta, + _meta: TransformerMetadata, ): ... @@ -113,10 +113,10 @@ async def test_components( def test_renderer_components_limits_positional_arg_count(): try: - @renderer_components + @output_transformer async def test_components( - _meta: RenderMeta, - _fn: RenderFnAsync[str], + _meta: TransformerMetadata, + _fn: ValueFnAsync[str], y: str, ): ... @@ -129,10 +129,10 @@ async def test_components( def test_renderer_components_does_not_allow_args(): try: - @renderer_components + @output_transformer async def test_components( - _meta: RenderMeta, - _fn: RenderFnAsync[str], + _meta: TransformerMetadata, + _fn: ValueFnAsync[str], *args: str, ): ... @@ -146,10 +146,10 @@ async def test_components( def test_renderer_components_kwargs_have_defaults(): try: - @renderer_components + @output_transformer async def test_components( - _meta: RenderMeta, - _fn: RenderFnAsync[str], + _meta: TransformerMetadata, + _fn: ValueFnAsync[str], *, y: str, ): @@ -162,10 +162,10 @@ async def test_components( def test_renderer_components_result_does_not_allow_args(): - @renderer_components + @output_transformer async def test_components( - _meta: RenderMeta, - _fn: RenderFnAsync[str], + _meta: TransformerMetadata, + _fn: ValueFnAsync[str], ): ... From f5cda40f60f05b78c96771bd9f29dcb762f15b4f Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Mon, 7 Aug 2023 08:41:18 -0400 Subject: [PATCH 43/64] Add test for having an async handler? --- tests/test_renderer.py | 65 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/tests/test_renderer.py b/tests/test_renderer.py index e9b4d5197..bdd749770 100644 --- a/tests/test_renderer.py +++ b/tests/test_renderer.py @@ -1,5 +1,9 @@ +import asyncio from typing import Any, overload +import pytest + +from shiny._utils import is_async_callable from shiny.render._render import RenderFnAsync, RenderMeta, renderer_components @@ -181,3 +185,64 @@ def render_fn_sync(*args: str): raise RuntimeError() except TypeError as e: assert "Expected `params` to be of type `RendererParams`" in str(e) + + +@pytest.mark.asyncio +async def test_renderer_handler_fn_can_be_async(): + @renderer_components + async def async_handler( + _meta: RenderMeta, + _fn: RenderFnAsync[str], + ) -> str: + # Actually sleep to test that the handler is truely async + await asyncio.sleep(0.1) + ret = await _fn() + return ret + + @overload + def async_renderer() -> async_handler.type_decorator: + ... + + @overload + def async_renderer( + _fn: async_handler.type_renderer_fn, + ) -> async_handler.type_renderer: + ... + + def async_renderer( + _fn: async_handler.type_impl_fn = None, + ) -> async_handler.type_impl: + return async_handler.impl(_fn) + + test_val = "Test: Hello World!" + + def app_render_fn() -> str: + return test_val + + renderer_sync = async_renderer(app_render_fn) + renderer_sync._set_metadata( + None, # pyright: ignore[reportGeneralTypeIssues] + "renderer_sync", + ) + if is_async_callable(renderer_sync): + raise RuntimeError("Expected `renderer_sync` to be a sync function") + + ret = renderer_sync() + assert ret == test_val + + async_test_val = "Async: Hello World!" + + async def async_app_render_fn() -> str: + await asyncio.sleep(0.1) + return async_test_val + + renderer_async = async_renderer(async_app_render_fn) + renderer_async._set_metadata( + None, # pyright: ignore[reportGeneralTypeIssues] + "renderer_async", + ) + if not is_async_callable(renderer_async): + raise RuntimeError("Expected `renderer_async` to be a coro function") + + ret = await renderer_async() + assert ret == test_val From 5ee0500f086efc4dc8e0b15f6acd72b925a95e81 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Mon, 7 Aug 2023 08:57:07 -0400 Subject: [PATCH 44/64] Make transformer methods consistent. Make `.impl` into `.__call__` --- e2e/server/renderer/app.py | 20 ++--- shiny/api-examples/renderer_components/app.py | 22 +++--- shiny/render/_dataframe.py | 18 +++-- shiny/render/_render.py | 59 +++++++------- tests/test_renderer.py | 78 +++++++++---------- 5 files changed, 97 insertions(+), 100 deletions(-) diff --git a/e2e/server/renderer/app.py b/e2e/server/renderer/app.py index 2fcf15e2a..4d2282a77 100644 --- a/e2e/server/renderer/app.py +++ b/e2e/server/renderer/app.py @@ -9,13 +9,13 @@ @output_transformer -async def _render_test_text_components( +async def TestTextTransformer( _meta: TransformerMetadata, - _fn: ValueFnAsync[str | None], + _afn: ValueFnAsync[str | None], *, extra_txt: Optional[str] = None, ) -> str | None: - value = await _fn() + value = await _afn() value = str(value) value += "; " value += "async" if _meta.is_async else "sync" @@ -27,25 +27,25 @@ async def _render_test_text_components( @overload def render_test_text( *, extra_txt: Optional[str] = None -) -> _render_test_text_components.OutputRendererDecorator: +) -> TestTextTransformer.OutputRendererDecorator: ... @overload def render_test_text( - _fn: _render_test_text_components.ValueFn, -) -> _render_test_text_components.OutputRenderer: + _fn: TestTextTransformer.ValueFn, +) -> TestTextTransformer.OutputRenderer: ... def render_test_text( - _fn: _render_test_text_components.ValueFnOrNone = None, + _fn: TestTextTransformer.ValueFn | None = None, *, extra_txt: Optional[str] = None, -) -> _render_test_text_components.OutputRendererOrDecorator: - return _render_test_text_components.impl( +) -> TestTextTransformer.OutputRenderer | TestTextTransformer.OutputRendererDecorator: + return TestTextTransformer( _fn, - _render_test_text_components.params(extra_txt=extra_txt), + TestTextTransformer.params(extra_txt=extra_txt), ) diff --git a/shiny/api-examples/renderer_components/app.py b/shiny/api-examples/renderer_components/app.py index 1ed2829bf..92c266c37 100644 --- a/shiny/api-examples/renderer_components/app.py +++ b/shiny/api-examples/renderer_components/app.py @@ -18,17 +18,17 @@ # Create renderer components from the async handler function: `capitalize_components()` @output_transformer -async def capitalize_components( +async def CapitalizeTransformer( # Contains information about the render call: `name`, `session`, `is_async` _meta: TransformerMetadata, # An async form of the app-supplied render function - _fn: ValueFnAsync[str | None], + _afn: ValueFnAsync[str | None], *, # Extra parameters that app authors can supply (e.g. `render_capitalize(to="upper")`) to: Literal["upper", "lower"] = "upper", ) -> str | None: # Get the value - value = await _fn() + value = await _afn() # Quit early if value is `None` if value is None: return None @@ -53,7 +53,7 @@ async def capitalize_components( def render_capitalize( *, to: Literal["upper", "lower"] = "upper", -) -> capitalize_components.OutputRendererDecorator: +) -> CapitalizeTransformer.OutputRendererDecorator: ... @@ -70,8 +70,8 @@ def render_capitalize( # Note: Return type is `type_renderer` @overload def render_capitalize( - _fn: capitalize_components.ValueFn, -) -> capitalize_components.OutputRenderer: + _fn: CapitalizeTransformer.ValueFn, +) -> CapitalizeTransformer.OutputRenderer: ... @@ -79,13 +79,15 @@ def render_capitalize( # Note: `_fn` type is `type_impl_fn` # Note: Return type is `type_impl` def render_capitalize( - _fn: capitalize_components.ValueFnOrNone = None, + _fn: CapitalizeTransformer.ValueFn | None = None, *, to: Literal["upper", "lower"] = "upper", -) -> capitalize_components.OutputRendererOrDecorator: - return capitalize_components.impl( +) -> ( + CapitalizeTransformer.OutputRenderer | CapitalizeTransformer.OutputRendererDecorator +): + return CapitalizeTransformer( _fn, - capitalize_components.params(to=to), + CapitalizeTransformer.params(to=to), ) diff --git a/shiny/render/_dataframe.py b/shiny/render/_dataframe.py index afc82ea13..06c8d7cdd 100644 --- a/shiny/render/_dataframe.py +++ b/shiny/render/_dataframe.py @@ -217,11 +217,11 @@ def serialize_pandas_df(df: "pd.DataFrame") -> dict[str, Any]: # TODO-barret; Port `__name__` and `__docs__` of `value_fn` @output_transformer -async def _data_frame( +async def DataFrameTransformer( _meta: TransformerMetadata, - _fn: ValueFnAsync[DataFrameResult | None], + _afn: ValueFnAsync[DataFrameResult | None], ) -> object | None: - x = await _fn() + x = await _afn() if x is None: return None @@ -235,19 +235,21 @@ async def _data_frame( @overload -def data_frame() -> _data_frame.OutputRendererDecorator: +def data_frame() -> DataFrameTransformer.OutputRendererDecorator: ... @overload -def data_frame(_fn: _data_frame.ValueFn) -> _data_frame.OutputRenderer: +def data_frame( + _fn: DataFrameTransformer.ValueFn, +) -> DataFrameTransformer.OutputRenderer: ... @add_example() def data_frame( - _fn: _data_frame.ValueFnOrNone = None, -) -> _data_frame.OutputRendererOrDecorator: + _fn: DataFrameTransformer.ValueFn | None = None, +) -> DataFrameTransformer.OutputRenderer | DataFrameTransformer.OutputRendererDecorator: """ Reactively render a Pandas data frame object (or similar) as a basic HTML table. @@ -279,7 +281,7 @@ def data_frame( -------- ~shiny.ui.output_data_frame """ - return _data_frame.impl(_fn) + return DataFrameTransformer(_fn) @runtime_checkable diff --git a/shiny/render/_render.py b/shiny/render/_render.py index 43ba09ec7..c9ff3bef5 100644 --- a/shiny/render/_render.py +++ b/shiny/render/_render.py @@ -205,7 +205,7 @@ def __init__( """ Renderer init method - TODO: Barret + TODO-barret: docs Parameters ---------- name @@ -515,8 +515,7 @@ class OutputTransformer(Generic[IT, OT, P]): Output Transformer class A Transfomer takes the value returned from the user's render function, passes it - through the component author's transformer function, and returns the result. TODO: - clean up + through the component author's transformer function, and returns the result. TODO-barret: cleanup docs Properties ---------- @@ -554,8 +553,7 @@ def params( ) -> TransformerParams[P]: return TransformerParams(*args, **kwargs) - # TODO: convert to __call__ - def impl( + def __call__( self, value_fn: ValueFn[IT] | None, params: TransformerParams[P] | None = None, @@ -576,11 +574,6 @@ def __init__( self.ValueFn = ValueFn[IT] self.OutputRenderer = OutputRenderer[OT] self.OutputRendererDecorator = OutputRendererDecorator[IT, OT] - # TODO: Remove the following types - self.ValueFnOrNone = Union[ValueFn[IT], None] - self.OutputRendererOrDecorator = Union[ - OutputRenderer[OT], OutputRendererDecorator[IT, OT] - ] @add_example() @@ -719,7 +712,7 @@ def text( -------- ~shiny.ui.output_text """ - return TextTransformer.impl(_fn) + return TextTransformer(_fn) # ====================================================================================== @@ -890,20 +883,20 @@ def plot( ~shiny.ui.output_plot ~shiny.render.image """ - return PlotTransformer.impl(_fn, PlotTransformer.params(alt=alt, **kwargs)) + return PlotTransformer(_fn, PlotTransformer.params(alt=alt, **kwargs)) # ====================================================================================== # RenderImage # ====================================================================================== @output_transformer -async def _image( +async def ImageTransformer( _meta: TransformerMetadata, - _fn: ValueFnAsync[ImgData | None], + _afn: ValueFnAsync[ImgData | None], *, delete_file: bool = False, ) -> ImgData | None: - res = await _fn() + res = await _afn() if res is None: return None @@ -967,7 +960,7 @@ def image( ~shiny.types.ImgData ~shiny.render.plot """ - return _image.impl(_fn, _image.params(delete_file=delete_file)) + return ImageTransformer(_fn, ImageTransformer.params(delete_file=delete_file)) # ====================================================================================== @@ -986,16 +979,16 @@ def to_pandas(self) -> "pd.DataFrame": @output_transformer -async def _table( +async def TableTransformer( _meta: TransformerMetadata, - _fn: ValueFnAsync[TableResult | None], + _afn: ValueFnAsync[TableResult | None], *, index: bool = False, classes: str = "table shiny-table w-auto", border: int = 0, **kwargs: object, ) -> RenderedDeps | None: - x = await _fn() + x = await _afn() if x is None: return None @@ -1040,23 +1033,23 @@ def table( classes: str = "table shiny-table w-auto", border: int = 0, **kwargs: Any, -) -> _table.OutputRendererDecorator: +) -> TableTransformer.OutputRendererDecorator: ... @overload -def table(_fn: _table.ValueFn) -> _table.OutputRenderer: +def table(_fn: TableTransformer.ValueFn) -> TableTransformer.OutputRenderer: ... def table( - _fn: _table.ValueFnOrNone = None, + _fn: TableTransformer.ValueFn | None = None, *, index: bool = False, classes: str = "table shiny-table w-auto", border: int = 0, **kwargs: object, -) -> _table.OutputRendererOrDecorator: +) -> TableTransformer.OutputRenderer | TableTransformer.OutputRendererDecorator: """ Reactively render a Pandas data frame object (or similar) as a basic HTML table. @@ -1101,9 +1094,9 @@ def table( -------- ~shiny.ui.output_table for the corresponding UI component to this render function. """ - return _table.impl( + return TableTransformer( _fn, - _table.params( + TableTransformer.params( index=index, classes=classes, border=border, @@ -1116,11 +1109,11 @@ def table( # RenderUI # ====================================================================================== @output_transformer -async def _ui( +async def UiTransformer( _meta: TransformerMetadata, - _fn: ValueFnAsync[TagChild], + _afn: ValueFnAsync[TagChild], ) -> RenderedDeps | None: - ui = await _fn() + ui = await _afn() if ui is None: return None @@ -1128,18 +1121,18 @@ async def _ui( @overload -def ui() -> _ui.OutputRendererDecorator: +def ui() -> UiTransformer.OutputRendererDecorator: ... @overload -def ui(_fn: _ui.ValueFn) -> _ui.OutputRenderer: +def ui(_fn: UiTransformer.ValueFn) -> UiTransformer.OutputRenderer: ... def ui( - _fn: _ui.ValueFnOrNone = None, -) -> _ui.OutputRendererOrDecorator: + _fn: UiTransformer.ValueFn | None = None, +) -> UiTransformer.OutputRenderer | UiTransformer.OutputRendererDecorator: """ Reactively render HTML content. @@ -1159,4 +1152,4 @@ def ui( -------- ~shiny.ui.output_ui """ - return _ui.impl(_fn) + return UiTransformer(_fn) diff --git a/tests/test_renderer.py b/tests/test_renderer.py index b33419556..37cd0f46f 100644 --- a/tests/test_renderer.py +++ b/tests/test_renderer.py @@ -10,66 +10,66 @@ def test_output_transformer_works(): # No args works @output_transformer - async def test_components( + async def TestTransformer( _meta: TransformerMetadata, - _fn: ValueFnAsync[str], + _afn: ValueFnAsync[str], ): ... @overload - def test_renderer() -> test_components.OutputRendererDecorator: + def test_renderer() -> TestTransformer.OutputRendererDecorator: ... @overload def test_renderer( - _fn: test_components.ValueFn, - ) -> test_components.OutputRenderer: + _fn: TestTransformer.ValueFn, + ) -> TestTransformer.OutputRenderer: ... def test_renderer( - _fn: test_components.ValueFnOrNone = None, - ) -> test_components.OutputRendererOrDecorator: - return test_components.impl(_fn) + _fn: TestTransformer.ValueFn | None = None, + ) -> TestTransformer.OutputRenderer | TestTransformer.OutputRendererDecorator: + return TestTransformer(_fn) def test_output_transformer_kwargs_are_allowed(): # Test that kwargs can be allowed @output_transformer - async def test_components( + async def TestTransformer( _meta: TransformerMetadata, - _fn: ValueFnAsync[str], + _afn: ValueFnAsync[str], *, y: str = "42", ): ... @overload - def test_renderer(*, y: str = "42") -> test_components.OutputRendererDecorator: + def test_renderer(*, y: str = "42") -> TestTransformer.OutputRendererDecorator: ... @overload def test_renderer( - _fn: test_components.ValueFn, - ) -> test_components.OutputRenderer: + _fn: TestTransformer.ValueFn, + ) -> TestTransformer.OutputRenderer: ... def test_renderer( - _fn: test_components.ValueFnOrNone = None, + _fn: TestTransformer.ValueFn | None = None, *, y: str = "42", - ) -> test_components.OutputRendererOrDecorator: - return test_components.impl( + ) -> TestTransformer.OutputRenderer | TestTransformer.OutputRendererDecorator: + return TestTransformer( _fn, - test_components.params(y=y), + TestTransformer.params(y=y), ) def test_output_transformer_with_pass_through_kwargs(): # No args works @output_transformer - async def test_components( + async def TestTransformer( _meta: TransformerMetadata, - _fn: ValueFnAsync[str], + _afn: ValueFnAsync[str], *, y: str = "42", **kwargs: float, @@ -79,24 +79,24 @@ async def test_components( @overload def test_renderer( *, y: str = "42", **kwargs: Any - ) -> test_components.OutputRendererDecorator: + ) -> TestTransformer.OutputRendererDecorator: ... @overload def test_renderer( - _fn: test_components.ValueFn, - ) -> test_components.OutputRenderer: + _fn: TestTransformer.ValueFn, + ) -> TestTransformer.OutputRenderer: ... def test_renderer( - _fn: test_components.ValueFnOrNone = None, + _fn: TestTransformer.ValueFn | None = None, *, y: str = "42", **kwargs: Any, - ) -> test_components.OutputRendererOrDecorator: - return test_components.impl( + ) -> TestTransformer.OutputRenderer | TestTransformer.OutputRendererDecorator: + return TestTransformer( _fn, - test_components.params(y=y, **kwargs), + TestTransformer.params(y=y, **kwargs), ) @@ -104,7 +104,7 @@ def test_output_transformer_pos_args(): try: @output_transformer # type: ignore - async def test_components( + async def TestTransformer( _meta: TransformerMetadata, ): ... @@ -118,9 +118,9 @@ def test_output_transformer_limits_positional_arg_count(): try: @output_transformer - async def test_components( + async def TestTransformer( _meta: TransformerMetadata, - _fn: ValueFnAsync[str], + _afn: ValueFnAsync[str], y: str, ): ... @@ -134,9 +134,9 @@ def test_output_transformer_does_not_allow_args(): try: @output_transformer - async def test_components( + async def TestTransformer( _meta: TransformerMetadata, - _fn: ValueFnAsync[str], + _afn: ValueFnAsync[str], *args: str, ): ... @@ -151,9 +151,9 @@ def test_output_transformer_kwargs_have_defaults(): try: @output_transformer - async def test_components( + async def TestTransformer( _meta: TransformerMetadata, - _fn: ValueFnAsync[str], + _afn: ValueFnAsync[str], *, y: str, ): @@ -167,9 +167,9 @@ async def test_components( def test_output_transformer_result_does_not_allow_args(): @output_transformer - async def test_components( + async def TestTransformer( _meta: TransformerMetadata, - _fn: ValueFnAsync[str], + _afn: ValueFnAsync[str], ): ... @@ -178,7 +178,7 @@ def render_fn_sync(*args: str): return " ".join(args) try: - test_components.impl( + TestTransformer( render_fn_sync, "X", # type: ignore ) @@ -192,11 +192,11 @@ async def test_renderer_handler_fn_can_be_async(): @output_transformer async def async_handler( _meta: TransformerMetadata, - _fn: ValueFnAsync[str], + _afn: ValueFnAsync[str], ) -> str: # Actually sleep to test that the handler is truely async await asyncio.sleep(0.1) - ret = await _fn() + ret = await _afn() return ret @overload @@ -212,7 +212,7 @@ def async_renderer( def async_renderer( _fn: async_handler.ValueFn | None = None, ) -> async_handler.OutputRenderer | async_handler.OutputRendererDecorator: - return async_handler.impl(_fn) + return async_handler(_fn) test_val = "Test: Hello World!" From 1c2a4514ee33de532d929da0774dc22dd5eb8ff0 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Mon, 7 Aug 2023 09:06:04 -0400 Subject: [PATCH 45/64] Skip impossible test --- tests/test_renderer.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/tests/test_renderer.py b/tests/test_renderer.py index 37cd0f46f..d1644369a 100644 --- a/tests/test_renderer.py +++ b/tests/test_renderer.py @@ -103,7 +103,7 @@ def test_renderer( def test_output_transformer_pos_args(): try: - @output_transformer # type: ignore + @output_transformer # pyright: ignore[reportGeneralTypeIssues] async def TestTransformer( _meta: TransformerMetadata, ): @@ -189,8 +189,12 @@ def render_fn_sync(*args: str): @pytest.mark.asyncio async def test_renderer_handler_fn_can_be_async(): + @pytest.skip( + "Currently, `ValueFnAsync` can not be truely async and " + "support sync render methods" + ) @output_transformer - async def async_handler( + async def AsyncTransformer( _meta: TransformerMetadata, _afn: ValueFnAsync[str], ) -> str: @@ -200,19 +204,19 @@ async def async_handler( return ret @overload - def async_renderer() -> async_handler.OutputRendererDecorator: + def async_renderer() -> AsyncTransformer.OutputRendererDecorator: ... @overload def async_renderer( - _fn: async_handler.ValueFn, - ) -> async_handler.OutputRenderer: + _fn: AsyncTransformer.ValueFn, + ) -> AsyncTransformer.OutputRenderer: ... def async_renderer( - _fn: async_handler.ValueFn | None = None, - ) -> async_handler.OutputRenderer | async_handler.OutputRendererDecorator: - return async_handler(_fn) + _fn: AsyncTransformer.ValueFn | None = None, + ) -> AsyncTransformer.OutputRenderer | AsyncTransformer.OutputRendererDecorator: + return AsyncTransformer(_fn) test_val = "Test: Hello World!" @@ -227,6 +231,7 @@ def app_render_fn() -> str: if is_async_callable(renderer_sync): raise RuntimeError("Expected `renderer_sync` to be a sync function") + # !! This line is currently not possible !! ret = renderer_sync() assert ret == test_val From db66eee6ba8fb72d35008cbd274cc3e064324e6b Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 8 Aug 2023 10:33:47 -0400 Subject: [PATCH 46/64] Hide test so that pylance doesn't get confused --- tests/test_renderer.py | 126 ++++++++++++++++++++--------------------- 1 file changed, 62 insertions(+), 64 deletions(-) diff --git a/tests/test_renderer.py b/tests/test_renderer.py index d1644369a..2d8eaed14 100644 --- a/tests/test_renderer.py +++ b/tests/test_renderer.py @@ -187,67 +187,65 @@ def render_fn_sync(*args: str): assert "Expected `params` to be of type `RendererParams`" in str(e) -@pytest.mark.asyncio -async def test_renderer_handler_fn_can_be_async(): - @pytest.skip( - "Currently, `ValueFnAsync` can not be truely async and " - "support sync render methods" - ) - @output_transformer - async def AsyncTransformer( - _meta: TransformerMetadata, - _afn: ValueFnAsync[str], - ) -> str: - # Actually sleep to test that the handler is truely async - await asyncio.sleep(0.1) - ret = await _afn() - return ret - - @overload - def async_renderer() -> AsyncTransformer.OutputRendererDecorator: - ... - - @overload - def async_renderer( - _fn: AsyncTransformer.ValueFn, - ) -> AsyncTransformer.OutputRenderer: - ... - - def async_renderer( - _fn: AsyncTransformer.ValueFn | None = None, - ) -> AsyncTransformer.OutputRenderer | AsyncTransformer.OutputRendererDecorator: - return AsyncTransformer(_fn) - - test_val = "Test: Hello World!" - - def app_render_fn() -> str: - return test_val - - renderer_sync = async_renderer(app_render_fn) - renderer_sync._set_metadata( - None, # pyright: ignore[reportGeneralTypeIssues] - "renderer_sync", - ) - if is_async_callable(renderer_sync): - raise RuntimeError("Expected `renderer_sync` to be a sync function") - - # !! This line is currently not possible !! - ret = renderer_sync() - assert ret == test_val - - async_test_val = "Async: Hello World!" - - async def async_app_render_fn() -> str: - await asyncio.sleep(0.1) - return async_test_val - - renderer_async = async_renderer(async_app_render_fn) - renderer_async._set_metadata( - None, # pyright: ignore[reportGeneralTypeIssues] - "renderer_async", - ) - if not is_async_callable(renderer_async): - raise RuntimeError("Expected `renderer_async` to be a coro function") - - ret = await renderer_async() - assert ret == test_val +# # "Currently, `ValueFnAsync` can not be truely async and " +# # "support sync render methods" +# @pytest.mark.asyncio +# async def test_renderer_handler_fn_can_be_async(): +# @output_transformer +# async def AsyncTransformer( +# _meta: TransformerMetadata, +# _afn: ValueFnAsync[str], +# ) -> str: +# # Actually sleep to test that the handler is truely async +# await asyncio.sleep(0.1) +# ret = await _afn() +# return ret + +# @overload +# def async_renderer() -> AsyncTransformer.OutputRendererDecorator: +# ... + +# @overload +# def async_renderer( +# _fn: AsyncTransformer.ValueFn, +# ) -> AsyncTransformer.OutputRenderer: +# ... + +# def async_renderer( +# _fn: AsyncTransformer.ValueFn | None = None, +# ) -> AsyncTransformer.OutputRenderer | AsyncTransformer.OutputRendererDecorator: +# return AsyncTransformer(_fn) + +# test_val = "Test: Hello World!" + +# def app_render_fn() -> str: +# return test_val + +# renderer_sync = async_renderer(app_render_fn) +# renderer_sync._set_metadata( +# None, # pyright: ignore[reportGeneralTypeIssues] +# "renderer_sync", +# ) +# if is_async_callable(renderer_sync): +# raise RuntimeError("Expected `renderer_sync` to be a sync function") + +# # !! This line is currently not possible !! +# ret = renderer_sync() +# assert ret == test_val + +# async_test_val = "Async: Hello World!" + +# async def async_app_render_fn() -> str: +# await asyncio.sleep(0.1) +# return async_test_val + +# renderer_async = async_renderer(async_app_render_fn) +# renderer_async._set_metadata( +# None, # pyright: ignore[reportGeneralTypeIssues] +# "renderer_async", +# ) +# if not is_async_callable(renderer_async): +# raise RuntimeError("Expected `renderer_async` to be a coro function") + +# ret = await renderer_async() +# assert ret == test_val From fc02a24cdd57b2a2b1a5a1983c7a77e0ec9eef41 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 8 Aug 2023 16:06:13 -0400 Subject: [PATCH 47/64] parentheses and type comment --- shiny/api-examples/renderer_components/app.py | 4 ++-- tests/test_renderer.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/shiny/api-examples/renderer_components/app.py b/shiny/api-examples/renderer_components/app.py index 92c266c37..584f605e2 100644 --- a/shiny/api-examples/renderer_components/app.py +++ b/shiny/api-examples/renderer_components/app.py @@ -57,7 +57,7 @@ def render_capitalize( ... -# Second, create an overload where users are not using parenthesis to the method. +# 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: # ``` @@ -98,7 +98,7 @@ def render_capitalize( app_ui = ui.page_fluid( ui.h1("Capitalization renderer"), ui.input_text("caption", "Caption:", "Data summary"), - "No parenthesis:", + "No parentheses:", ui.output_text_verbatim("no_parens"), "To upper:", ui.output_text_verbatim("to_upper"), diff --git a/tests/test_renderer.py b/tests/test_renderer.py index 2d8eaed14..bfeb07818 100644 --- a/tests/test_renderer.py +++ b/tests/test_renderer.py @@ -180,7 +180,7 @@ def render_fn_sync(*args: str): try: TestTransformer( render_fn_sync, - "X", # type: ignore + "X", # pyright: ignore[reportGeneralTypeIssues] ) raise RuntimeError() except TypeError as e: From 99bf57ede547f6b83268675ae124368d6195fcdf Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 8 Aug 2023 16:07:23 -0400 Subject: [PATCH 48/64] Move transformer methods to `shiny.render.transformer` folder Also move `RenderFunction` methods into `shiny.render._deprecated` --- shiny/render/_dataframe.py | 2 +- shiny/render/_deprecated.py | 80 +++ shiny/render/_render.py | 640 +---------------------- shiny/render/transformer/__init__.py | 19 + shiny/render/transformer/_transformer.py | 586 +++++++++++++++++++++ 5 files changed, 691 insertions(+), 636 deletions(-) create mode 100644 shiny/render/_deprecated.py create mode 100644 shiny/render/transformer/__init__.py create mode 100644 shiny/render/transformer/_transformer.py diff --git a/shiny/render/_dataframe.py b/shiny/render/_dataframe.py index 06c8d7cdd..cf5e6d964 100644 --- a/shiny/render/_dataframe.py +++ b/shiny/render/_dataframe.py @@ -14,8 +14,8 @@ ) from .._docstring import add_example -from . import TransformerMetadata, ValueFnAsync, output_transformer from ._dataframe_unsafe import serialize_numpy_dtypes +from .transformer import TransformerMetadata, ValueFnAsync, output_transformer if TYPE_CHECKING: import pandas as pd diff --git a/shiny/render/_deprecated.py b/shiny/render/_deprecated.py new file mode 100644 index 000000000..3d37b7d15 --- /dev/null +++ b/shiny/render/_deprecated.py @@ -0,0 +1,80 @@ +# Needed for types imported only during TYPE_CHECKING with Python 3.7 - 3.9 +# See https://www.python.org/dev/peps/pep-0655/#usage-in-python-3-11 +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Generic + +from transformer._transformer import ( + IT, + OT, + OutputRendererAsync, + OutputRendererSync, + TransformerMetadata, + TransformerParams, + ValueFnAsync, + ValueFnSync, +) + +# ====================================================================================== +# Deprecated classes +# ====================================================================================== + + +# A RenderFunction object is given a app-supplied function which returns an `IT`. When +# the .__call__ method is invoked, it calls the app-supplied function (which returns an +# `IT`), then converts the `IT` to an `OT`. Note that in many cases but not all, `IT` +# and `OT` will be the same. +class RenderFunction(Generic[IT, OT], OutputRendererSync[OT], ABC): + """ + Deprecated. Please use :func:`~shiny.render.renderer_components` instead. + """ + + @abstractmethod + def __call__(self) -> OT: + ... + + @abstractmethod + async def run(self) -> OT: + ... + + def __init__(self, fn: ValueFnSync[IT]) -> None: + async def transformer(_meta: TransformerMetadata, _fn: ValueFnAsync[IT]) -> OT: + ret = await self.run() + return ret + + super().__init__( + value_fn=fn, + transform_fn=transformer, + params=TransformerParams.empty_params(), + ) + self._fn = fn + + +# The reason for having a separate RenderFunctionAsync class is because the __call__ +# method is marked here as async; you can't have a single class where one method could +# be either sync or async. +class RenderFunctionAsync(Generic[IT, OT], OutputRendererAsync[OT], ABC): + """ + Deprecated. Please use :func:`~shiny.render.renderer_components` instead. + """ + + @abstractmethod + async def __call__(self) -> OT: # pyright: ignore[reportIncompatibleMethodOverride] + ... + + @abstractmethod + async def run(self) -> OT: + ... + + def __init__(self, fn: ValueFnAsync[IT]) -> None: + async def transformer(_meta: TransformerMetadata, _fn: ValueFnAsync[IT]) -> OT: + ret = await self.run() + return ret + + super().__init__( + value_fn=fn, + transform_fn=transformer, + params=TransformerParams.empty_params(), + ) + self._fn = fn diff --git a/shiny/render/_render.py b/shiny/render/_render.py index c9ff3bef5..289247da0 100644 --- a/shiny/render/_render.py +++ b/shiny/render/_render.py @@ -11,21 +11,14 @@ ) import base64 -import inspect import os import sys import typing -from abc import ABC, abstractmethod from typing import ( TYPE_CHECKING, Any, - Awaitable, - Callable, - Generic, - NamedTuple, Optional, Protocol, - TypeVar, Union, cast, overload, @@ -35,634 +28,14 @@ from htmltools import TagChild if TYPE_CHECKING: - from ..session import Session from ..session._utils import RenderedDeps import pandas as pd from .. import _utils -from .._docstring import add_example from .._namespaces import ResolvedId -from .._typing_extensions import Concatenate, ParamSpec from ..types import ImgData from ._try_render_plot import try_render_matplotlib, try_render_pil, try_render_plotnine - -# Input type for the user-spplied function that is passed to a render.xx -IT = TypeVar("IT") -# Output type after the Renderer.__call__ method is called on the IT object. -OT = TypeVar("OT") -# Param specification for value_fn function -P = ParamSpec("P") - - -# ====================================================================================== -# Helper classes -# ====================================================================================== - - -# Meta information to give `hander()` some context -class TransformerMetadata(NamedTuple): - """ - Transformer metadata - - This class is used to hold meta information for a transformer function. - - Properties - ---------- - is_async - If `TRUE`, the app-supplied render function is asynchronous. - session - The :class:`~shiny.Session` object of the current render function. - name - The name of the output being rendered. - """ - - is_async: bool - session: Session - name: str - - -class TransformerParams(Generic[P]): - """ - Parameters for a transformer function - - This class is used to hold the parameters for a transformer function. It is used to - enforce that the parameters are used in the correct order. - - Properties - ---------- - *args - No positional arguments should be supplied. Only keyword arguments should be - supplied. - **kwargs - Keyword arguments for the corresponding transformer function. - """ - - # Motivation for using this class: - # * https://peps.python.org/pep-0612/ does allow for prepending an arg (e.g. - # `value_fn`). - # * However, the overload is not happy when both a positional arg (e.g. `value_fn`) - # is dropped and the variadic positional args (`*args`) are kept. - # * The variadic positional args (`*args`) CAN NOT be dropped as PEP612 states that - # both components of the `ParamSpec` must be used in the same function signature. - # * By making assertions on `P.args` to only allow for `*`, we _can_ make overloads - # that use either the single positional arg (e.g. `value_fn`) or the `P.kwargs` - # (as `P.args` == `*`) - def __init__(self, *args: P.args, **kwargs: P.kwargs) -> None: - """ - Properties - ---------- - *args - No positional arguments should be supplied. Only keyword arguments should be - supplied. - **kwargs - Keyword arguments for the corresponding renderer function. - """ - - # Make sure there no `args` when running! - # This check is related to `_assert_transform_fn` not accepting any `args` - if len(args) > 0: - raise RuntimeError("`args` should not be supplied") - - # `*args` must be defined with `**kwargs` (as per PEP612) - # (even when expanded later when calling the handler function) - # So we store them, even if we know they are empty - self.args = args - self.kwargs = kwargs - - @staticmethod - def empty_params() -> TransformerParams[P]: - def inner(*args: P.args, **kwargs: P.kwargs) -> TransformerParams[P]: - return TransformerParams[P](*args, **kwargs) - - return inner() - - -# ====================================================================================== -# Renderer / RendererSync / RendererAsync base class -# ====================================================================================== - -# A `ValueFn` function is an app-supplied function which returns an IT. -# It can be either synchronous or asynchronous -ValueFnSync = Callable[[], IT] -ValueFnAsync = Callable[[], Awaitable[IT]] -ValueFn = Union[ValueFnSync[IT], ValueFnAsync[IT]] - -# `TransformFn` is a package author function that transforms an object of type `IT` into type `OT`. -TransformFn = Callable[ - Concatenate[TransformerMetadata, ValueFnAsync[IT], P], Awaitable[OT] -] - - -class OutputRenderer(Generic[OT], ABC): - """ - Output Renderer - - Base class for classes :class:`~shiny.render.RendererSync` and - :class:`~shiny.render.RendererAsync`. - - When the `.__call__` method is invoked, the handler function (`transform_fn`) - (typically defined by package authors) is asynchronously called. The handler - function is given `meta` information, the (app-supplied) render function, and any - keyword arguments supplied to the render decorator. For consistency, the first two - parameters have been (arbitrarily) implemented as `_meta` and `_fn`. - - The (app-supplied) value function (`value_fn`) returns type `IT`. The handler - function (defined by package authors) defines the parameter specification of type - `P` and asynchronously returns an object of type `OT`. Note that in many cases but - not all, `IT` and `OT` will be the same. `None` values should always be defined in - `IT` and `OT`. - - - Methods - ------- - _is_async - If `TRUE`, the app-supplied render function is asynchronous. Must be implemented - in subclasses. - _meta - A named tuple of values: `is_async`, `session` (the :class:`~shiny.Session` - object), and `name` (the name of the output being rendered) - - See Also - -------- - * :class:`~shiny.render.RendererSync` - * :class:`~shiny.render.RendererAsync` - """ - - @abstractmethod - def __call__(self) -> OT: - """ - Executes the renderer as a function. Must be implemented by subclasses. - """ - ... - - def __init__( - self, - *, - value_fn: ValueFn[IT], - transform_fn: TransformFn[IT, P, OT], - params: TransformerParams[P], - ) -> None: - """ - Renderer init method - - TODO-barret: docs - Parameters - ---------- - name - Name of original output function. Ex: `my_txt` - doc - Documentation of the output function. Ex: `"My text output will be displayed - verbatim". - """ - # Copy over function name as it is consistent with how Session and Output - # retrieve function names - self.__name__ = value_fn.__name__ - - if not _utils.is_async_callable(transform_fn): - raise TypeError( - self.__class__.__name__ + " requires an async handler function" - ) - - # `value_fn` is not required to be async. For consistency, we wrapped in an - # async function so that when it's passed in to `transform_fn`, `value_fn` is - # **always** an async function. - self._value_fn = _utils.wrap_async(value_fn) - self._transformer = transform_fn - self._params = params - - def _set_metadata(self, session: Session, name: str) -> None: - """ - When `Renderer`s are assigned to Output object slots, this method is used to - pass along Session and name information. - """ - self._session: Session = session - self._name: str = name - - def _meta(self) -> TransformerMetadata: - return TransformerMetadata( - is_async=self._is_async(), - session=self._session, - name=self._name, - ) - - @abstractmethod - def _is_async(self) -> bool: - ... - - async def _run(self) -> OT: - """ - Executes the (async) handler function - - The handler function will receive the following arguments: meta information of - type :class:`~shiny.render.RenderMeta`, an app-defined render function of type - :class:`~shiny.render.RenderFnAsync`, and `*args` and `**kwargs` of type `P`. - - Notes: - * The app-defined render function will always be upgraded to be an async - function. - * `*args` will always be empty as it is an expansion of - :class:`~shiny.render.RendererParams` which does not allow positional - arguments. - """ - ret = await self._transformer( - # RenderMeta - self._meta(), - # Callable[[], Awaitable[IT]] - self._value_fn, - # P - *self._params.args, - **self._params.kwargs, - ) - return ret - - -# Using a second class to help clarify that it is of a particular type -class OutputRendererSync(OutputRenderer[OT]): - """ - Output Renderer (Synchronous) - - This class is used to define a synchronous renderer. The `.__call__` method is - implemented to call the `._run` method synchronously. - - See Also - -------- - * :class:`~shiny.render.Renderer` - * :class:`~shiny.render.RendererAsync` - """ - - def _is_async(self) -> bool: - """ - Meta information about the renderer being asynchronous or not. - - Returns - ------- - : - Returns `FALSE` as this is a synchronous renderer. - """ - return False - - def __init__( - self, - value_fn: ValueFnSync[IT], - transform_fn: TransformFn[IT, P, OT], - params: TransformerParams[P], - ) -> None: - if _utils.is_async_callable(value_fn): - raise TypeError( - self.__class__.__name__ + " requires a synchronous render function" - ) - # super == Renderer - super().__init__( - value_fn=value_fn, - transform_fn=transform_fn, - params=params, - ) - - def __call__(self) -> OT: - return _utils.run_coro_sync(self._run()) - - -# The reason for having a separate RendererAsync class is because the __call__ -# method is marked here as async; you can't have a single class where one method could -# be either sync or async. -class OutputRendererAsync(OutputRenderer[OT]): - """ - Output Renderer (Asynchronous) - - This class is used to define an asynchronous renderer. The `.__call__` method is - implemented to call the `._run` method asynchronously. - - See Also - -------- - * :class:`~shiny.render.Renderer` - * :class:`~shiny.render.RendererSync` - """ - - def _is_async(self) -> bool: - """ - Meta information about the renderer being asynchronous or not. - - Returns - ------- - : - Returns `TRUE` as this is an asynchronous renderer. - """ - return True - - def __init__( - self, - value_fn: ValueFnAsync[IT], - transform_fn: TransformFn[IT, P, OT], - params: TransformerParams[P], - ) -> None: - if not _utils.is_async_callable(value_fn): - raise TypeError( - self.__class__.__name__ + " requires an asynchronous render function" - ) - # super == Renderer - super().__init__( - value_fn=value_fn, - transform_fn=transform_fn, - params=params, - ) - - async def __call__(self) -> OT: # pyright: ignore[reportIncompatibleMethodOverride] - return await self._run() - - -# ====================================================================================== -# 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: ValueFnAsync[IT]) -> OT: - ret = await self.run() - return ret - - super().__init__( - value_fn=fn, - transform_fn=transformer, - params=TransformerParams.empty_params(), - ) - self._fn = fn - - -# The reason for having a separate RenderFunctionAsync class is because the __call__ -# method is marked here as async; you can't have a single class where one method could -# be either sync or async. -class RenderFunctionAsync(Generic[IT, OT], OutputRendererAsync[OT], ABC): - """ - Deprecated. Please use :func:`~shiny.render.renderer_components` instead. - """ - - @abstractmethod - async def __call__(self) -> OT: # pyright: ignore[reportIncompatibleMethodOverride] - ... - - @abstractmethod - async def run(self) -> OT: - ... - - def __init__(self, fn: ValueFnAsync[IT]) -> None: - async def transformer(_meta: TransformerMetadata, _fn: ValueFnAsync[IT]) -> OT: - ret = await self.run() - return ret - - super().__init__( - value_fn=fn, - transform_fn=transformer, - params=TransformerParams.empty_params(), - ) - self._fn = fn - - -# ====================================================================================== -# Restrict the value function -# ====================================================================================== - - -# assert: No variable length positional values; -# * We need a way to distinguish between a plain function and args supplied to the next -# function. This is done by not allowing `*args`. -# assert: All kwargs of transformer should have a default value -# * This makes calling the method with both `()` and without `()` possible / consistent. -def _assert_transformer(transform_fn: TransformFn[IT, P, OT]) -> None: - params = inspect.Signature.from_callable(transform_fn).parameters - - if len(params) < 2: - raise TypeError( - "`transformer=` must have 2 positional parameters which have type " - "`RenderMeta` and `RenderFnAsync` respectively" - ) - - for i, param in zip(range(len(params)), params.values()): - # # Not a good test as `param.annotation` has type `str` and the type could - # # have been renamed. We need to do an `isinstance` check but do not have - # # access to the objects - # if i == 0: - # assert param.annotation == "RenderMeta" - # if i == 1: - # assert (param.annotation or "").startswith("RenderFnAsync") - if i < 2 and not ( - param.kind == inspect.Parameter.POSITIONAL_OR_KEYWORD - or param.kind == inspect.Parameter.POSITIONAL_ONLY - ): - raise TypeError( - "`transformer=` must have 2 positional parameters which have type " - "`RenderMeta` and `RenderFnAsync` respectively" - ) - - # Make sure there are no more than 2 positional args - if i >= 2 and param.kind == inspect.Parameter.POSITIONAL_OR_KEYWORD: - raise TypeError( - "`transformer=` must not contain more than 2 positional parameters" - ) - # Make sure there are no `*args` - if param.kind == inspect.Parameter.VAR_POSITIONAL: - raise TypeError( - "No variadic positional parameters (e.g. `*args`) can be supplied to " - f"`transformer=`. Received: `{param.name}`. Please only use `*`." - ) - # Make sure kwargs have default values - if ( - param.kind == inspect.Parameter.KEYWORD_ONLY - and param.default is inspect.Parameter.empty - ): - raise TypeError( - f"In `transformer=`, parameter `{param.name}` did not have a default value" - ) - - -# ====================================================================================== -# Renderer decorator -# ====================================================================================== - - -# Signature of a renderer decorator function -OutputRendererDecorator = Callable[[ValueFn[IT]], OutputRenderer[OT]] -# Signature of a decorator that can be called with and without parentheses -# With parens returns a `Renderer[OT]` -# Without parens returns a `RendererDeco[IT, OT]` -OutputRendererImplFn = Callable[ - [ - Optional[ValueFn[IT]], - TransformerParams[P], - ], - Union[OutputRenderer[OT], OutputRendererDecorator[IT, OT]], -] - - -class OutputTransformer(Generic[IT, OT, P]): - """ - Output Transformer class - - A Transfomer takes the value returned from the user's render function, passes it - through the component author's transformer function, and returns the result. TODO-barret: cleanup docs - - Properties - ---------- - type_decorator - The return type for the renderer decorator wrapper function. This should be used - when the app-defined render function is `None` and extra parameters are being - supplied. - type_renderer_fn - The (non-`None`) type for the renderer function's first argument that accepts an - app-defined render function. This type should be paired with the return type: - `type_renderer`. - type_renderer - The type for the return value of the renderer decorator function. This should be - used when the app-defined render function is not `None`. - type_impl_fn - The type for the implementation function's first argument. This value handles - both app-defined render functions and `None` and returns values appropriate for - both cases. `type_impl_fn` should be paired with `type_impl`. - type_impl - The type for the return value of the implementation function. This value handles - both app-defined render functions and `None` and returns values appropriate for - both cases. - - See Also - -------- - * :func:`~shiny.render.renderer_components` - * :class:`~shiny.render.RendererParams` - * :class:`~shiny.render.Renderer` - """ - - def params( - self, - *args: P.args, - **kwargs: P.kwargs, - ) -> TransformerParams[P]: - return TransformerParams(*args, **kwargs) - - def __call__( - self, - value_fn: ValueFn[IT] | None, - params: TransformerParams[P] | None = None, - ) -> OutputRenderer[OT] | OutputRendererDecorator[IT, OT]: - if params is None: - params = self.params() - if not isinstance(params, TransformerParams): - raise TypeError( - f"Expected `params` to be of type `RendererParams` but received `{type(params)}`. Please use `.params()` to create a `RendererParams` object." - ) - return self._fn(value_fn, params) - - def __init__( - self, - fn: OutputRendererImplFn[IT, P, OT], - ) -> None: - self._fn = fn - self.ValueFn = ValueFn[IT] - self.OutputRenderer = OutputRenderer[OT] - self.OutputRendererDecorator = OutputRendererDecorator[IT, OT] - - -@add_example() -def output_transformer( - transform_fn: TransformFn[IT, P, OT], -) -> OutputTransformer[IT, OT, P]: - """ - Renderer components decorator - - This decorator method is a convenience method to generate the appropriate types and - internal implementation for an overloaded renderer method. This method will provide - you with all the necessary types to define two different overloads: one for when the - decorator is called without parenthesis and another for when it is called with - parenthesis where app authors can pass in parameters to the renderer. - - ## Handler function - - The renderer's asynchronous handler function (`transform_fn`) is the key building - block for `renderer_components`. - - The handler function is supplied meta renderer information, the (app-supplied) - render function, and any keyword arguments supplied to the renderer decorator: - * The first parameter to the handler function has the class - :class:`~shiny.render.RenderMeta` and is typically called (e.g. `_meta`). This - information gives context the to the handler while trying to resolve the - app-supplied render function (e.g. `_fn`). - * The second parameter is the app-defined render function (e.g. `_fn`). It's return - type (`IT`) determines what types can be returned by the app-supplied render - function. For example, if `_fn` has the type `RenderFnAsync[str | None]`, both the - `str` and `None` types are allowed to be returned from the app-supplied render - function. - * The remaining parameters are the keyword arguments (e.g. `alt:Optional[str] = - None` or `**kwargs: Any`) that app authors may supply to the renderer (when the - renderer decorator is called with parenthesis). Variadic positional parameters - (e.g. `*args`) are not allowed. All keyword arguments should have a type and - default value (except for `**kwargs: Any`). - - The handler's return type (`OT`) determines the output type of the renderer. Note - that in many cases (but not all!) `IT` and `OT` will be the same. The `None` type - should typically be defined in both `IT` and `OT`. If `IT` allows for `None` values, - it (typically) signals that nothing should be rendered. If `OT` allows for `None` - and returns a `None` value, shiny will not render the output. - - - Notes - ----- - - When defining the renderer decorator overloads, if you have extra parameters of - `**kwargs: object`, you may get a type error about incompatible signatures. To fix - this, you can use `**kwargs: Any` instead or add `_fn: None = None` as the first - parameter in the overload containing the `**kwargs: object`. - - Parameters - ---------- - transform_fn - Asynchronous function used to determine the app-supplied value type (`IT`), the - rendered type (`OT`), and the parameters (`P`) app authors can supply to the - renderer. - - Returns - ------- - : - A :class:`~shiny.render.RendererComponents` object that can be used to define - two overloads for your renderer function. One overload is for when the renderer - is called without parenthesis and the other is for when the renderer is called - with parenthesis. - """ - _assert_transformer(transform_fn) - - def renderer_decorator( - value_fn: ValueFnSync[IT] | ValueFnAsync[IT] | None, - params: TransformerParams[P], - ) -> OutputRenderer[OT] | OutputRendererDecorator[IT, OT]: - def as_value_fn( - fn: ValueFnSync[IT] | ValueFnAsync[IT], - ) -> OutputRenderer[OT]: - if _utils.is_async_callable(fn): - return OutputRendererAsync(fn, transform_fn, params) - else: - fn = cast(ValueFnSync[IT], fn) - return OutputRendererSync(fn, transform_fn, params) - - if value_fn is None: - return as_value_fn - val = as_value_fn(value_fn) - return val - - return OutputTransformer(renderer_decorator) - +from .transformer import TransformerMetadata, ValueFnAsync, output_transformer # ====================================================================================== # RenderText @@ -917,23 +290,20 @@ async def ImageTransformer( def image( *, delete_file: bool = False, -) -> OutputRendererDecorator[ImgData | None, ImgData | None]: +) -> ImageTransformer.OutputRendererDecorator: ... @overload -def image(_fn: ValueFn[ImgData | None]) -> OutputRenderer[ImgData | None]: +def image(_fn: ImageTransformer.ValueFn) -> ImageTransformer.OutputRenderer: ... def image( - _fn: Optional[ValueFn[ImgData | None]] = None, + _fn: ImageTransformer.ValueFn | None = None, *, delete_file: bool = False, -) -> Union[ - OutputRenderer[ImgData | None], - OutputRendererDecorator[ImgData | None, ImgData | None], -]: +) -> ImageTransformer.OutputRendererDecorator | ImageTransformer.OutputRenderer: """ Reactively render a image file as an HTML image. diff --git a/shiny/render/transformer/__init__.py b/shiny/render/transformer/__init__.py new file mode 100644 index 000000000..844ddda38 --- /dev/null +++ b/shiny/render/transformer/__init__.py @@ -0,0 +1,19 @@ +from _transformer import ( + TransformerMetadata, + TransformerParams, + OutputRenderer, + OutputTransformer, + ValueFn, + ValueFnAsync, + output_transformer, +) + +__all__ = ( + "TransformerMetadata", + "TransformerParams", + "OutputRenderer", + "OutputTransformer", + "ValueFn", + "ValueFnAsync", + "output_transformer", +) diff --git a/shiny/render/transformer/_transformer.py b/shiny/render/transformer/_transformer.py new file mode 100644 index 000000000..e1281c27a --- /dev/null +++ b/shiny/render/transformer/_transformer.py @@ -0,0 +1,586 @@ +# Needed for types imported only during TYPE_CHECKING with Python 3.7 - 3.9 +# See https://www.python.org/dev/peps/pep-0655/#usage-in-python-3-11 +from __future__ import annotations + +__all__ = ( + "TransformerMetadata", + "TransformerParams", + "OutputRenderer", + "OutputTransformer", + "ValueFnAsync", + "output_transformer", +) + +import inspect +from abc import ABC, abstractmethod +from typing import ( + TYPE_CHECKING, + Awaitable, + Callable, + Generic, + NamedTuple, + Optional, + TypeVar, + Union, + cast, +) + +if TYPE_CHECKING: + from ...session import Session + +from ... import _utils +from ..._docstring import add_example +from ..._typing_extensions import Concatenate, ParamSpec + +# Input type for the user-spplied function that is passed to a render.xx +IT = TypeVar("IT") +# Output type after the Renderer.__call__ method is called on the IT object. +OT = TypeVar("OT") +# Param specification for value_fn function +P = ParamSpec("P") + + +# ====================================================================================== +# Helper classes +# ====================================================================================== + + +# Meta information to give `hander()` some context +class TransformerMetadata(NamedTuple): + """ + Transformer metadata + + This class is used to hold meta information for a transformer function. + + Properties + ---------- + is_async + If `TRUE`, the app-supplied render function is asynchronous. + session + The :class:`~shiny.Session` object of the current render function. + name + The name of the output being rendered. + """ + + is_async: bool + session: Session + name: str + + +class TransformerParams(Generic[P]): + """ + Parameters for a transformer function + + This class is used to hold the parameters for a transformer function. It is used to + enforce that the parameters are used in the correct order. + + Properties + ---------- + *args + No positional arguments should be supplied. Only keyword arguments should be + supplied. + **kwargs + Keyword arguments for the corresponding transformer function. + """ + + # Motivation for using this class: + # * https://peps.python.org/pep-0612/ does allow for prepending an arg (e.g. + # `value_fn`). + # * However, the overload is not happy when both a positional arg (e.g. `value_fn`) + # is dropped and the variadic positional args (`*args`) are kept. + # * The variadic positional args (`*args`) CAN NOT be dropped as PEP612 states that + # both components of the `ParamSpec` must be used in the same function signature. + # * By making assertions on `P.args` to only allow for `*`, we _can_ make overloads + # that use either the single positional arg (e.g. `value_fn`) or the `P.kwargs` + # (as `P.args` == `*`) + def __init__(self, *args: P.args, **kwargs: P.kwargs) -> None: + """ + Properties + ---------- + *args + No positional arguments should be supplied. Only keyword arguments should be + supplied. + **kwargs + Keyword arguments for the corresponding renderer function. + """ + + # Make sure there no `args` when running! + # This check is related to `_assert_transform_fn` not accepting any `args` + if len(args) > 0: + raise RuntimeError("`args` should not be supplied") + + # `*args` must be defined with `**kwargs` (as per PEP612) + # (even when expanded later when calling the handler function) + # So we store them, even if we know they are empty + self.args = args + self.kwargs = kwargs + + @staticmethod + def empty_params() -> TransformerParams[P]: + def inner(*args: P.args, **kwargs: P.kwargs) -> TransformerParams[P]: + return TransformerParams[P](*args, **kwargs) + + return inner() + + +# ====================================================================================== +# Renderer / RendererSync / RendererAsync base class +# ====================================================================================== + +# A `ValueFn` function is an app-supplied function which returns an IT. +# It can be either synchronous or asynchronous +ValueFnSync = Callable[[], IT] +ValueFnAsync = Callable[[], Awaitable[IT]] +ValueFn = Union[ValueFnSync[IT], ValueFnAsync[IT]] + +# `TransformFn` is a package author function that transforms an object of type `IT` into type `OT`. +TransformFn = Callable[ + Concatenate[TransformerMetadata, ValueFnAsync[IT], P], Awaitable[OT] +] + + +class OutputRenderer(Generic[OT], ABC): + """ + Output Renderer + + Base class for classes :class:`~shiny.render.RendererSync` and + :class:`~shiny.render.RendererAsync`. + + When the `.__call__` method is invoked, the handler function (`transform_fn`) + (typically defined by package authors) is asynchronously called. The handler + function is given `meta` information, the (app-supplied) render function, and any + keyword arguments supplied to the render decorator. For consistency, the first two + parameters have been (arbitrarily) implemented as `_meta` and `_fn`. + + The (app-supplied) value function (`value_fn`) returns type `IT`. The handler + function (defined by package authors) defines the parameter specification of type + `P` and asynchronously returns an object of type `OT`. Note that in many cases but + not all, `IT` and `OT` will be the same. `None` values should always be defined in + `IT` and `OT`. + + + Methods + ------- + _is_async + If `TRUE`, the app-supplied render function is asynchronous. Must be implemented + in subclasses. + _meta + A named tuple of values: `is_async`, `session` (the :class:`~shiny.Session` + object), and `name` (the name of the output being rendered) + + See Also + -------- + * :class:`~shiny.render.RendererSync` + * :class:`~shiny.render.RendererAsync` + """ + + @abstractmethod + def __call__(self) -> OT: + """ + Executes the renderer as a function. Must be implemented by subclasses. + """ + ... + + def __init__( + self, + *, + value_fn: ValueFn[IT], + transform_fn: TransformFn[IT, P, OT], + params: TransformerParams[P], + ) -> None: + """ + Renderer init method + + TODO-barret: docs + Parameters + ---------- + name + Name of original output function. Ex: `my_txt` + doc + Documentation of the output function. Ex: `"My text output will be displayed + verbatim". + """ + # Copy over function name as it is consistent with how Session and Output + # retrieve function names + self.__name__ = value_fn.__name__ + + if not _utils.is_async_callable(transform_fn): + raise TypeError( + self.__class__.__name__ + " requires an async handler function" + ) + + # `value_fn` is not required to be async. For consistency, we wrapped in an + # async function so that when it's passed in to `transform_fn`, `value_fn` is + # **always** an async function. + self._value_fn = _utils.wrap_async(value_fn) + self._transformer = transform_fn + self._params = params + + def _set_metadata(self, session: Session, name: str) -> None: + """ + When `Renderer`s are assigned to Output object slots, this method is used to + pass along Session and name information. + """ + self._session: Session = session + self._name: str = name + + def _meta(self) -> TransformerMetadata: + return TransformerMetadata( + is_async=self._is_async(), + session=self._session, + name=self._name, + ) + + @abstractmethod + def _is_async(self) -> bool: + ... + + async def _run(self) -> OT: + """ + Executes the (async) handler function + + The handler function will receive the following arguments: meta information of + type :class:`~shiny.render.RenderMeta`, an app-defined render function of type + :class:`~shiny.render.RenderFnAsync`, and `*args` and `**kwargs` of type `P`. + + Notes: + * The app-defined render function will always be upgraded to be an async + function. + * `*args` will always be empty as it is an expansion of + :class:`~shiny.render.RendererParams` which does not allow positional + arguments. + """ + ret = await self._transformer( + # RenderMeta + self._meta(), + # Callable[[], Awaitable[IT]] + self._value_fn, + # P + *self._params.args, + **self._params.kwargs, + ) + return ret + + +# Using a second class to help clarify that it is of a particular type +class OutputRendererSync(OutputRenderer[OT]): + """ + Output Renderer (Synchronous) + + This class is used to define a synchronous renderer. The `.__call__` method is + implemented to call the `._run` method synchronously. + + See Also + -------- + * :class:`~shiny.render.Renderer` + * :class:`~shiny.render.RendererAsync` + """ + + def _is_async(self) -> bool: + """ + Meta information about the renderer being asynchronous or not. + + Returns + ------- + : + Returns `FALSE` as this is a synchronous renderer. + """ + return False + + def __init__( + self, + value_fn: ValueFnSync[IT], + transform_fn: TransformFn[IT, P, OT], + params: TransformerParams[P], + ) -> None: + if _utils.is_async_callable(value_fn): + raise TypeError( + self.__class__.__name__ + " requires a synchronous render function" + ) + # super == Renderer + super().__init__( + value_fn=value_fn, + transform_fn=transform_fn, + params=params, + ) + + def __call__(self) -> OT: + return _utils.run_coro_sync(self._run()) + + +# The reason for having a separate RendererAsync class is because the __call__ +# method is marked here as async; you can't have a single class where one method could +# be either sync or async. +class OutputRendererAsync(OutputRenderer[OT]): + """ + Output Renderer (Asynchronous) + + This class is used to define an asynchronous renderer. The `.__call__` method is + implemented to call the `._run` method asynchronously. + + See Also + -------- + * :class:`~shiny.render.Renderer` + * :class:`~shiny.render.RendererSync` + """ + + def _is_async(self) -> bool: + """ + Meta information about the renderer being asynchronous or not. + + Returns + ------- + : + Returns `TRUE` as this is an asynchronous renderer. + """ + return True + + def __init__( + self, + value_fn: ValueFnAsync[IT], + transform_fn: TransformFn[IT, P, OT], + params: TransformerParams[P], + ) -> None: + if not _utils.is_async_callable(value_fn): + raise TypeError( + self.__class__.__name__ + " requires an asynchronous render function" + ) + # super == Renderer + super().__init__( + value_fn=value_fn, + transform_fn=transform_fn, + params=params, + ) + + async def __call__(self) -> OT: # pyright: ignore[reportIncompatibleMethodOverride] + return await self._run() + + +# ====================================================================================== +# Restrict the value function +# ====================================================================================== + + +# assert: No variable length positional values; +# * We need a way to distinguish between a plain function and args supplied to the next +# function. This is done by not allowing `*args`. +# assert: All kwargs of transformer should have a default value +# * This makes calling the method with both `()` and without `()` possible / consistent. +def _assert_transformer(transform_fn: TransformFn[IT, P, OT]) -> None: + params = inspect.Signature.from_callable(transform_fn).parameters + + if len(params) < 2: + raise TypeError( + "`transformer=` must have 2 positional parameters which have type " + "`RenderMeta` and `RenderFnAsync` respectively" + ) + + for i, param in zip(range(len(params)), params.values()): + # # Not a good test as `param.annotation` has type `str` and the type could + # # have been renamed. We need to do an `isinstance` check but do not have + # # access to the objects + # if i == 0: + # assert param.annotation == "RenderMeta" + # if i == 1: + # assert (param.annotation or "").startswith("RenderFnAsync") + if i < 2 and not ( + param.kind == inspect.Parameter.POSITIONAL_OR_KEYWORD + or param.kind == inspect.Parameter.POSITIONAL_ONLY + ): + raise TypeError( + "`transformer=` must have 2 positional parameters which have type " + "`RenderMeta` and `RenderFnAsync` respectively" + ) + + # Make sure there are no more than 2 positional args + if i >= 2 and param.kind == inspect.Parameter.POSITIONAL_OR_KEYWORD: + raise TypeError( + "`transformer=` must not contain more than 2 positional parameters" + ) + # Make sure there are no `*args` + if param.kind == inspect.Parameter.VAR_POSITIONAL: + raise TypeError( + "No variadic positional parameters (e.g. `*args`) can be supplied to " + f"`transformer=`. Received: `{param.name}`. Please only use `*`." + ) + # Make sure kwargs have default values + if ( + param.kind == inspect.Parameter.KEYWORD_ONLY + and param.default is inspect.Parameter.empty + ): + raise TypeError( + f"In `transformer=`, parameter `{param.name}` did not have a default value" + ) + + +# ====================================================================================== +# Renderer decorator +# ====================================================================================== + + +# Signature of a renderer decorator function +OutputRendererDecorator = Callable[[ValueFn[IT]], OutputRenderer[OT]] +# Signature of a decorator that can be called with and without parentheses +# With parens returns a `Renderer[OT]` +# Without parens returns a `RendererDeco[IT, OT]` +OutputRendererImplFn = Callable[ + [ + Optional[ValueFn[IT]], + TransformerParams[P], + ], + Union[OutputRenderer[OT], OutputRendererDecorator[IT, OT]], +] + + +class OutputTransformer(Generic[IT, OT, P]): + """ + Output Transformer class + + A Transfomer takes the value returned from the user's render function, passes it + through the component author's transformer function, and returns the result. TODO-barret: cleanup docs + + Properties + ---------- + type_decorator + The return type for the renderer decorator wrapper function. This should be used + when the app-defined render function is `None` and extra parameters are being + supplied. + type_renderer_fn + The (non-`None`) type for the renderer function's first argument that accepts an + app-defined render function. This type should be paired with the return type: + `type_renderer`. + type_renderer + The type for the return value of the renderer decorator function. This should be + used when the app-defined render function is not `None`. + type_impl_fn + The type for the implementation function's first argument. This value handles + both app-defined render functions and `None` and returns values appropriate for + both cases. `type_impl_fn` should be paired with `type_impl`. + type_impl + The type for the return value of the implementation function. This value handles + both app-defined render functions and `None` and returns values appropriate for + both cases. + + See Also + -------- + * :func:`~shiny.render.renderer_components` + * :class:`~shiny.render.RendererParams` + * :class:`~shiny.render.Renderer` + """ + + def params( + self, + *args: P.args, + **kwargs: P.kwargs, + ) -> TransformerParams[P]: + return TransformerParams(*args, **kwargs) + + def __call__( + self, + value_fn: ValueFn[IT] | None, + params: TransformerParams[P] | None = None, + ) -> OutputRenderer[OT] | OutputRendererDecorator[IT, OT]: + if params is None: + params = self.params() + if not isinstance(params, TransformerParams): + raise TypeError( + f"Expected `params` to be of type `RendererParams` but received `{type(params)}`. Please use `.params()` to create a `RendererParams` object." + ) + return self._fn(value_fn, params) + + def __init__( + self, + fn: OutputRendererImplFn[IT, P, OT], + ) -> None: + self._fn = fn + self.ValueFn = ValueFn[IT] + self.OutputRenderer = OutputRenderer[OT] + self.OutputRendererDecorator = OutputRendererDecorator[IT, OT] + + +@add_example() +def output_transformer( + transform_fn: TransformFn[IT, P, OT], +) -> OutputTransformer[IT, OT, P]: + """ + Renderer components decorator + + This decorator method is a convenience method to generate the appropriate types and + internal implementation for an overloaded renderer method. This method will provide + you with all the necessary types to define two different overloads: one for when the + decorator is called without parentheses and another for when it is called with + parentheses where app authors can pass in parameters to the renderer. + + ## Handler function + + The renderer's asynchronous handler function (`transform_fn`) is the key building + block for `renderer_components`. + + The handler function is supplied meta renderer information, the (app-supplied) + render function, and any keyword arguments supplied to the renderer decorator: + * The first parameter to the handler function has the class + :class:`~shiny.render.RenderMeta` and is typically called (e.g. `_meta`). This + information gives context the to the handler while trying to resolve the + app-supplied render function (e.g. `_fn`). + * The second parameter is the app-defined render function (e.g. `_fn`). It's return + type (`IT`) determines what types can be returned by the app-supplied render + function. For example, if `_fn` has the type `RenderFnAsync[str | None]`, both the + `str` and `None` types are allowed to be returned from the app-supplied render + function. + * The remaining parameters are the keyword arguments (e.g. `alt:Optional[str] = + None` or `**kwargs: Any`) that app authors may supply to the renderer (when the + renderer decorator is called with parentheses). Variadic positional parameters + (e.g. `*args`) are not allowed. All keyword arguments should have a type and + default value (except for `**kwargs: Any`). + + The handler's return type (`OT`) determines the output type of the renderer. Note + that in many cases (but not all!) `IT` and `OT` will be the same. The `None` type + should typically be defined in both `IT` and `OT`. If `IT` allows for `None` values, + it (typically) signals that nothing should be rendered. If `OT` allows for `None` + and returns a `None` value, shiny will not render the output. + + + Notes + ----- + + When defining the renderer decorator overloads, if you have extra parameters of + `**kwargs: object`, you may get a type error about incompatible signatures. To fix + this, you can use `**kwargs: Any` instead or add `_fn: None = None` as the first + parameter in the overload containing the `**kwargs: object`. + + Parameters + ---------- + transform_fn + Asynchronous function used to determine the app-supplied value type (`IT`), the + rendered type (`OT`), and the parameters (`P`) app authors can supply to the + renderer. + + Returns + ------- + : + A :class:`~shiny.render.RendererComponents` object that can be used to define + two overloads for your renderer function. One overload is for when the renderer + is called without parentheses and the other is for when the renderer is called + with parentheses. + """ + _assert_transformer(transform_fn) + + def renderer_decorator( + value_fn: ValueFnSync[IT] | ValueFnAsync[IT] | None, + params: TransformerParams[P], + ) -> OutputRenderer[OT] | OutputRendererDecorator[IT, OT]: + def as_value_fn( + fn: ValueFnSync[IT] | ValueFnAsync[IT], + ) -> OutputRenderer[OT]: + if _utils.is_async_callable(fn): + return OutputRendererAsync(fn, transform_fn, params) + else: + fn = cast(ValueFnSync[IT], fn) + return OutputRendererSync(fn, transform_fn, params) + + if value_fn is None: + return as_value_fn + val = as_value_fn(value_fn) + return val + + return OutputTransformer(renderer_decorator) From 8bb6aa69308de0e2cb2931f6d854d760fe3db3e3 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 8 Aug 2023 16:08:26 -0400 Subject: [PATCH 49/64] Restore `shiny.render.__init__` import structure and import transformer module --- shiny/render/__init__.py | 40 +++++++++++++++++----------------------- 1 file changed, 17 insertions(+), 23 deletions(-) diff --git a/shiny/render/__init__.py b/shiny/render/__init__.py index 734625319..3444cd5f0 100644 --- a/shiny/render/__init__.py +++ b/shiny/render/__init__.py @@ -2,45 +2,39 @@ Tools for reactively rendering output for the user interface. """ -from ._render import ( # noqa: F401 - # Values declared in `__all__` will give autocomplete hints / resolve. - # E.g. `from shiny import render; render.text` but not `render.RenderMeta` - # These values do not need ` as FOO` as the variable is used in `__all__` + +from . import ( # noqa: F401 + transformer, # pyright: ignore[reportUnusedImport] +) + +from ._deprecated import ( # noqa: F401 + RenderFunction, # pyright: ignore[reportUnusedImport] + RenderFunctionAsync, # pyright: ignore[reportUnusedImport] +) + +from ._render import ( text, plot, image, table, ui, - # Renamed values (in addition to the __all__values) are exposed when importing - # directly from `render` module just like a regular variable. - # E.g `from shiny.render import RenderMeta, RenderFnAsync, renderer_components` - # These values need ` as FOO` as the variable is not used in `__all__` and causes an - # reportUnusedImport error from pylance. - # Using the same name is allowed. - TransformerMetadata as TransformerMetadata, - ValueFnAsync as ValueFnAsync, - TransformerParams as TransformerParams, - OutputTransformer as OutputTransformer, - output_transformer as output_transformer, - # Deprecated / legacy classes - RenderFunction as RenderFunction, - RenderFunctionAsync as RenderFunctionAsync, ) -from ._dataframe import ( # noqa: F401 - # Renamed values - DataGrid as DataGrid, - DataTable as DataTable, - # Values declared in `__all__` +from ._dataframe import ( + DataGrid, + DataTable, data_frame, ) __all__ = ( + # TODO-future: Document which variables are exposed via different import approaches "data_frame", "text", "plot", "image", "table", "ui", + "DataGrid", + "DataTable", ) From de0f5dea1f3f5e21ff605413964ea347386a755e Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 8 Aug 2023 16:28:15 -0400 Subject: [PATCH 50/64] Fix broken imports --- shiny/reactive/_reactives.py | 2 +- shiny/render/_deprecated.py | 2 +- shiny/render/transformer/__init__.py | 2 +- shiny/session/_session.py | 2 +- tests/test_renderer.py | 6 +++++- 5 files changed, 9 insertions(+), 5 deletions(-) diff --git a/shiny/reactive/_reactives.py b/shiny/reactive/_reactives.py index c314b64ed..988c28b2e 100644 --- a/shiny/reactive/_reactives.py +++ b/shiny/reactive/_reactives.py @@ -22,7 +22,7 @@ from .._docstring import add_example from .._utils import is_async_callable, run_coro_sync from .._validation import req -from ..render._render import OutputRenderer +from ..render.transformer import OutputRenderer from ..types import MISSING, MISSING_TYPE, ActionButtonValue, SilentException from ._core import Context, Dependents, ReactiveWarning, isolate diff --git a/shiny/render/_deprecated.py b/shiny/render/_deprecated.py index 3d37b7d15..f937663f8 100644 --- a/shiny/render/_deprecated.py +++ b/shiny/render/_deprecated.py @@ -5,7 +5,7 @@ from abc import ABC, abstractmethod from typing import Generic -from transformer._transformer import ( +from .transformer._transformer import ( IT, OT, OutputRendererAsync, diff --git a/shiny/render/transformer/__init__.py b/shiny/render/transformer/__init__.py index 844ddda38..a2fdb67a2 100644 --- a/shiny/render/transformer/__init__.py +++ b/shiny/render/transformer/__init__.py @@ -1,4 +1,4 @@ -from _transformer import ( +from ._transformer import ( TransformerMetadata, TransformerParams, OutputRenderer, diff --git a/shiny/session/_session.py b/shiny/session/_session.py index ab911bc6e..b0799d35b 100644 --- a/shiny/session/_session.py +++ b/shiny/session/_session.py @@ -47,7 +47,7 @@ from ..input_handler import input_handlers from ..reactive import Effect, Effect_, Value, flush, isolate from ..reactive._core import lock, on_flushed -from ..render._render import OutputRenderer +from ..render.transformer import OutputRenderer from ..types import SafeException, SilentCancelOutputException, SilentException from ._utils import RenderedDeps, read_thunk_opt, session_context diff --git a/tests/test_renderer.py b/tests/test_renderer.py index bfeb07818..641d7edde 100644 --- a/tests/test_renderer.py +++ b/tests/test_renderer.py @@ -4,7 +4,11 @@ import pytest from shiny._utils import is_async_callable -from shiny.render._render import TransformerMetadata, ValueFnAsync, output_transformer +from shiny.render.transformer import ( + TransformerMetadata, + ValueFnAsync, + output_transformer, +) def test_output_transformer_works(): From 228650bc8b0e6132e6628fa504f7ee70200e8c96 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 8 Aug 2023 16:28:37 -0400 Subject: [PATCH 51/64] Test truely async transformer functions --- tests/test_renderer.py | 205 ++++++++++++++++++++++++++++------------- 1 file changed, 143 insertions(+), 62 deletions(-) diff --git a/tests/test_renderer.py b/tests/test_renderer.py index 641d7edde..c869448bd 100644 --- a/tests/test_renderer.py +++ b/tests/test_renderer.py @@ -191,65 +191,146 @@ def render_fn_sync(*args: str): assert "Expected `params` to be of type `RendererParams`" in str(e) -# # "Currently, `ValueFnAsync` can not be truely async and " -# # "support sync render methods" -# @pytest.mark.asyncio -# async def test_renderer_handler_fn_can_be_async(): -# @output_transformer -# async def AsyncTransformer( -# _meta: TransformerMetadata, -# _afn: ValueFnAsync[str], -# ) -> str: -# # Actually sleep to test that the handler is truely async -# await asyncio.sleep(0.1) -# ret = await _afn() -# return ret - -# @overload -# def async_renderer() -> AsyncTransformer.OutputRendererDecorator: -# ... - -# @overload -# def async_renderer( -# _fn: AsyncTransformer.ValueFn, -# ) -> AsyncTransformer.OutputRenderer: -# ... - -# def async_renderer( -# _fn: AsyncTransformer.ValueFn | None = None, -# ) -> AsyncTransformer.OutputRenderer | AsyncTransformer.OutputRendererDecorator: -# return AsyncTransformer(_fn) - -# test_val = "Test: Hello World!" - -# def app_render_fn() -> str: -# return test_val - -# renderer_sync = async_renderer(app_render_fn) -# renderer_sync._set_metadata( -# None, # pyright: ignore[reportGeneralTypeIssues] -# "renderer_sync", -# ) -# if is_async_callable(renderer_sync): -# raise RuntimeError("Expected `renderer_sync` to be a sync function") - -# # !! This line is currently not possible !! -# ret = renderer_sync() -# assert ret == test_val - -# async_test_val = "Async: Hello World!" - -# async def async_app_render_fn() -> str: -# await asyncio.sleep(0.1) -# return async_test_val - -# renderer_async = async_renderer(async_app_render_fn) -# renderer_async._set_metadata( -# None, # pyright: ignore[reportGeneralTypeIssues] -# "renderer_async", -# ) -# if not is_async_callable(renderer_async): -# raise RuntimeError("Expected `renderer_async` to be a coro function") - -# ret = await renderer_async() -# assert ret == test_val +# "Currently, `ValueFnAsync` can not be truely async and " +# "support sync render methods" +@pytest.mark.asyncio +async def test_renderer_handler_fn_can_be_async(): + @output_transformer + async def AsyncTransformer( + _meta: TransformerMetadata, + _afn: ValueFnAsync[str], + ) -> str: + # Actually sleep to test that the handler is truely async + await asyncio.sleep(0.1) + ret = await _afn() + return ret + + # ## Setup overloads ============================================= + + @overload + def async_renderer() -> AsyncTransformer.OutputRendererDecorator: + ... + + @overload + def async_renderer( + _fn: AsyncTransformer.ValueFn, + ) -> AsyncTransformer.OutputRenderer: + ... + + def async_renderer( + _fn: AsyncTransformer.ValueFn | None = None, + ) -> AsyncTransformer.OutputRenderer | AsyncTransformer.OutputRendererDecorator: + return AsyncTransformer(_fn) + + test_val = "Test: Hello World!" + + def app_render_fn() -> str: + return test_val + + # ## Test Sync: X ============================================= + + renderer_sync = async_renderer(app_render_fn) + renderer_sync._set_metadata( + None, # pyright: ignore[reportGeneralTypeIssues] + "renderer_sync", + ) + if is_async_callable(renderer_sync): + raise RuntimeError("Expected `renderer_sync` to be a sync function") + + # !! This line is currently not possible !! + try: + ret = renderer_sync() + raise Exception("Expected an exception to occur while calling `renderer_sync`") + assert ret == test_val + except RuntimeError as e: + assert "async function yielded control" in str(e) + + # ## Test Async: √ ============================================= + + async_test_val = "Async: Hello World!" + + async def async_app_render_fn() -> str: + await asyncio.sleep(0.1) + return async_test_val + + renderer_async = async_renderer(async_app_render_fn) + renderer_async._set_metadata( + None, # pyright: ignore[reportGeneralTypeIssues] + "renderer_async", + ) + if not is_async_callable(renderer_async): + raise RuntimeError("Expected `renderer_async` to be a coro function") + + ret = await renderer_async() + assert ret == async_test_val + + +# "Currently, `ValueFnAsync` can not be truely async and " +# "support sync render methods" +@pytest.mark.asyncio +async def test_renderer_handler_fn_can_be_yield_while_async(): + @output_transformer + async def YieldTransformer( + _meta: TransformerMetadata, + _afn: ValueFnAsync[str], + ) -> str: + # Only yield if the handler is async + if _meta.is_async: + # Actually sleep to test that the handler is truely async + await asyncio.sleep(0.1) + ret = await _afn() + return ret + + # ## Setup overloads ============================================= + + @overload + def yield_renderer() -> YieldTransformer.OutputRendererDecorator: + ... + + @overload + def yield_renderer( + _fn: YieldTransformer.ValueFn, + ) -> YieldTransformer.OutputRenderer: + ... + + def yield_renderer( + _fn: YieldTransformer.ValueFn | None = None, + ) -> YieldTransformer.OutputRenderer | YieldTransformer.OutputRendererDecorator: + return YieldTransformer(_fn) + + test_val = "Test: Hello World!" + + def app_render_fn() -> str: + return test_val + + # ## Test Sync: √ ============================================= + + renderer_sync = yield_renderer(app_render_fn) + renderer_sync._set_metadata( + None, # pyright: ignore[reportGeneralTypeIssues] + "renderer_sync", + ) + if is_async_callable(renderer_sync): + raise RuntimeError("Expected `renderer_sync` to be a sync function") + + ret = renderer_sync() + assert ret == test_val + + # ## Test Async: √ ============================================= + + async_test_val = "Async: Hello World!" + + async def async_app_render_fn() -> str: + await asyncio.sleep(0.1) + return async_test_val + + renderer_async = yield_renderer(async_app_render_fn) + renderer_async._set_metadata( + None, # pyright: ignore[reportGeneralTypeIssues] + "renderer_async", + ) + if not is_async_callable(renderer_async): + raise RuntimeError("Expected `renderer_async` to be a coro function") + + ret = await renderer_async() + assert ret == async_test_val From d4015b6b292bf6ff9d44a2d75f912dd5bdc41ea8 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 9 Aug 2023 11:11:56 -0400 Subject: [PATCH 52/64] Documentation; Partial: Remove `is_async` from meta info. Supply uncasted value fn to renderers --- shiny/render/transformer/_transformer.py | 384 +++++++++++++---------- 1 file changed, 213 insertions(+), 171 deletions(-) diff --git a/shiny/render/transformer/_transformer.py b/shiny/render/transformer/_transformer.py index e1281c27a..774ec1ba5 100644 --- a/shiny/render/transformer/_transformer.py +++ b/shiny/render/transformer/_transformer.py @@ -7,8 +7,15 @@ "TransformerParams", "OutputRenderer", "OutputTransformer", - "ValueFnAsync", + "ValueFn", + # "ValueFnSync", + # "ValueFnAsync", + # "TransformFn", "output_transformer", + "is_async_callable", + # "IT", + # "OT", + # "P", ) import inspect @@ -31,6 +38,7 @@ from ... import _utils from ..._docstring import add_example from ..._typing_extensions import Concatenate, ParamSpec +from ..._utils import is_async_callable # Input type for the user-spplied function that is passed to a render.xx IT = TypeVar("IT") @@ -54,69 +62,72 @@ class TransformerMetadata(NamedTuple): Properties ---------- - is_async - If `TRUE`, the app-supplied render function is asynchronous. session - The :class:`~shiny.Session` object of the current render function. + The :class:`~shiny.Session` object of the current output value function. name The name of the output being rendered. """ - is_async: bool session: Session name: str +# Motivation for using this class: +# * https://peps.python.org/pep-0612/ does allow for prepending an arg (e.g. +# `value_fn`). +# * However, the overload is not happy when both a positional arg (e.g. `value_fn`) is +# dropped and the variadic positional args (`*args`) are kept. +# * The variadic positional args (`*args`) CAN NOT be dropped as PEP612 states that both +# components of the `ParamSpec` must be used in the same function signature. +# * By making assertions on `P.args` to only allow for `*`, we _can_ make overloads that +# use either the single positional arg (e.g. `value_fn`) or the `P.kwargs` (as +# `P.args` == `*`) class TransformerParams(Generic[P]): """ Parameters for a transformer function - This class is used to hold the parameters for a transformer function. It is used to - enforce that the parameters are used in the correct order. + This class is used to isolate the transformer function parameters away from + internal implementation parameters used by Shiny. Properties ---------- *args No positional arguments should be supplied. Only keyword arguments should be - supplied. + supplied. (`*args` is required when using :class:`~typing.ParamSpec` even if + transformer is only leveraging `**kwargs`.) **kwargs Keyword arguments for the corresponding transformer function. """ - # Motivation for using this class: - # * https://peps.python.org/pep-0612/ does allow for prepending an arg (e.g. - # `value_fn`). - # * However, the overload is not happy when both a positional arg (e.g. `value_fn`) - # is dropped and the variadic positional args (`*args`) are kept. - # * The variadic positional args (`*args`) CAN NOT be dropped as PEP612 states that - # both components of the `ParamSpec` must be used in the same function signature. - # * By making assertions on `P.args` to only allow for `*`, we _can_ make overloads - # that use either the single positional arg (e.g. `value_fn`) or the `P.kwargs` - # (as `P.args` == `*`) def __init__(self, *args: P.args, **kwargs: P.kwargs) -> None: """ Properties ---------- *args No positional arguments should be supplied. Only keyword arguments should be - supplied. + supplied. (`*args` is required when using :class:`~typing.ParamSpec` even if + transformer is only leveraging `**kwargs`.) **kwargs Keyword arguments for the corresponding renderer function. """ - # Make sure there no `args` when running! + # Make sure there no `args` at run time! # This check is related to `_assert_transform_fn` not accepting any `args` if len(args) > 0: raise RuntimeError("`args` should not be supplied") # `*args` must be defined with `**kwargs` (as per PEP612) - # (even when expanded later when calling the handler function) - # So we store them, even if we know they are empty + # (even when expanded later when calling the transform function) + # We need to store (and later retrieve) them, even if we know they are empty self.args = args self.kwargs = kwargs @staticmethod def empty_params() -> TransformerParams[P]: + """ + Return `TransformerParams` definition with no parameters. + """ + def inner(*args: P.args, **kwargs: P.kwargs) -> TransformerParams[P]: return TransformerParams[P](*args, **kwargs) @@ -130,54 +141,83 @@ def inner(*args: P.args, **kwargs: P.kwargs) -> TransformerParams[P]: # A `ValueFn` function is an app-supplied function which returns an IT. # It can be either synchronous or asynchronous ValueFnSync = Callable[[], IT] +""" +App-supplied output value function which returns type `IT`. This function is +synchronous. +""" ValueFnAsync = Callable[[], Awaitable[IT]] +""" +App-supplied output value function which returns type `IT`. This function is +asynchronous. +""" ValueFn = Union[ValueFnSync[IT], ValueFnAsync[IT]] +""" +App-supplied output value function which returns type `IT`. This function can be +synchronous or asynchronous. +""" -# `TransformFn` is a package author function that transforms an object of type `IT` into type `OT`. +# `TransformFn` is a package author function that transforms an object of type `IT` into +# type `OT`. TransformFn = Callable[ Concatenate[TransformerMetadata, ValueFnAsync[IT], P], Awaitable[OT] ] +""" +Package author function that transforms an object of type `IT` into type `OT`. It should +be defined as an asynchronous function but should only asynchronously yield when the +second parameter (of type `ValueFn[IT]`) is awaitable. If the second function argument +is not awaitable (a _synchronous_ function), then the execution of the transform +function should also be synchronous. +""" class OutputRenderer(Generic[OT], ABC): """ Output Renderer - Base class for classes :class:`~shiny.render.RendererSync` and - :class:`~shiny.render.RendererAsync`. + Transforms the output (of type `IT`) of an app-supplied output value function + (`value_fn`) into type (`OT`). This transformed value is then sent to be an + :class:`~shiny.Outputs` output value. - When the `.__call__` method is invoked, the handler function (`transform_fn`) - (typically defined by package authors) is asynchronously called. The handler - function is given `meta` information, the (app-supplied) render function, and any - keyword arguments supplied to the render decorator. For consistency, the first two - parameters have been (arbitrarily) implemented as `_meta` and `_fn`. + When the `.__call__` method is invoked, the transform function (`transform_fn`) + (typically defined by package authors) is invoked. The wrapping classes + (:class:`~shiny.render.transformer.OutputRendererSync` and + :class:`~shiny.render.transformer.OutputRendererAsync`) will enforce whether the + transform function is synchronous or asynchronous independent of the awaitable + syntax. - The (app-supplied) value function (`value_fn`) returns type `IT`. The handler - function (defined by package authors) defines the parameter specification of type - `P` and asynchronously returns an object of type `OT`. Note that in many cases but - not all, `IT` and `OT` will be the same. `None` values should always be defined in - `IT` and `OT`. + The transform function (`transform_fn`) is given `meta` information + (:class:`~shiny.render.tranformer.TranformerMetadata`), the (app-supplied) value + function (`ValueFn[IT]`), and any keyword arguments supplied to the render decorator + (`P`). For consistency, the first two parameters have been (arbitrarily) implemented + as `_meta` and `_fn`. + Typing + ----- + `IT` + The type returned by the app-supplied output value function (`value_fn`). This + value should contain a `None` value to conform to the convention of app authors + being able to return `None` to display nothing in the rendered output. Note that + in many cases but not all, `IT` and `OT` will be the same. + `OT` + The type of the object returned by the transform function (`transform_fn`). This + value should contain a `None` value to conform to display nothing in the + rendered output. + `P` + The parameter specification defined by the transform function (`transform_fn`). + It should **not** contain any `*args`. All keyword arguments should have a type + and default value. - Methods - ------- - _is_async - If `TRUE`, the app-supplied render function is asynchronous. Must be implemented - in subclasses. - _meta - A named tuple of values: `is_async`, `session` (the :class:`~shiny.Session` - object), and `name` (the name of the output being rendered) See Also -------- - * :class:`~shiny.render.RendererSync` - * :class:`~shiny.render.RendererAsync` + * :class:`~shiny.render.transformer.OutputRendererSync` + * :class:`~shiny.render.transformer.OutputRendererAsync` """ @abstractmethod def __call__(self) -> OT: """ - Executes the renderer as a function. Must be implemented by subclasses. + Executes the output renderer as a function. Must be implemented by subclasses. """ ... @@ -189,29 +229,33 @@ def __init__( params: TransformerParams[P], ) -> None: """ - Renderer init method - - TODO-barret: docs Parameters ---------- - name - Name of original output function. Ex: `my_txt` - doc - Documentation of the output function. Ex: `"My text output will be displayed - verbatim". + value_fn + App-provided output value function. It should return an object of type `IT`. + transform_fn + Package author function that transforms an object of type `IT` into type + `OT`. The `params` will used as variadic keyword arguments. This method + should only use `await` syntax when the value function (`ValueFn[IT]`) is + awaitable. If the value function is not awaitable (a _synchronous_ + function), then the function should execute synchronously. + params + App-provided parameters for the transform function (`transform_fn`). + """ + # Copy over function name as it is consistent with how Session and Output # retrieve function names self.__name__ = value_fn.__name__ - if not _utils.is_async_callable(transform_fn): + if not is_async_callable(transform_fn): raise TypeError( - self.__class__.__name__ + " requires an async handler function" + self.__class__.__name__ + + " requires an async tranformer function (`transform_fn`)" ) - # `value_fn` is not required to be async. For consistency, we wrapped in an - # async function so that when it's passed in to `transform_fn`, `value_fn` is - # **always** an async function. + self._is_async = is_async_callable(value_fn) + # TODO-barret; Do not wrap!! self._value_fn = _utils.wrap_async(value_fn) self._transformer = transform_fn self._params = params @@ -225,35 +269,33 @@ def _set_metadata(self, session: Session, name: str) -> None: self._name: str = name def _meta(self) -> TransformerMetadata: + """ + Returns a named tuple of values: `session` (the :class:`~shiny.Session` object), + and `name` (the name of the output being rendered) + """ return TransformerMetadata( - is_async=self._is_async(), session=self._session, name=self._name, ) - @abstractmethod - def _is_async(self) -> bool: - ... - async def _run(self) -> OT: """ - Executes the (async) handler function - - The handler function will receive the following arguments: meta information of - type :class:`~shiny.render.RenderMeta`, an app-defined render function of type - :class:`~shiny.render.RenderFnAsync`, and `*args` and `**kwargs` of type `P`. - - Notes: - * The app-defined render function will always be upgraded to be an async - function. - * `*args` will always be empty as it is an expansion of - :class:`~shiny.render.RendererParams` which does not allow positional - arguments. + Executes the (async) tranform function + + The transform function will receive the following arguments: meta information of + type :class:`~shiny.render.transformer.TransformerMetadata`, an app-defined + render function of type :class:`~shiny.render.RenderFnAsync`, and `*args` and + `**kwargs` of type `P`. + + Note: `*args` will always be empty as it is an expansion of + :class:`~shiny.render.tranformer.TransformerParams` which does not allow positional arguments. + `*args` is required to use with `**kwargs` when using + `typing.ParamSpec`. """ ret = await self._transformer( - # RenderMeta + # TransformerMetadata self._meta(), - # Callable[[], Awaitable[IT]] + # Callable[[], Awaitable[IT]] | Callable[[], IT] self._value_fn, # P *self._params.args, @@ -272,28 +314,17 @@ class OutputRendererSync(OutputRenderer[OT]): See Also -------- - * :class:`~shiny.render.Renderer` - * :class:`~shiny.render.RendererAsync` + * :class:`~shiny.render.tranformer.OutputRenderer` + * :class:`~shiny.render.tranformer.OutputRendererAsync` """ - def _is_async(self) -> bool: - """ - Meta information about the renderer being asynchronous or not. - - Returns - ------- - : - Returns `FALSE` as this is a synchronous renderer. - """ - return False - def __init__( self, value_fn: ValueFnSync[IT], transform_fn: TransformFn[IT, P, OT], params: TransformerParams[P], ) -> None: - if _utils.is_async_callable(value_fn): + if is_async_callable(value_fn): raise TypeError( self.__class__.__name__ + " requires a synchronous render function" ) @@ -320,28 +351,17 @@ class OutputRendererAsync(OutputRenderer[OT]): See Also -------- - * :class:`~shiny.render.Renderer` - * :class:`~shiny.render.RendererSync` + * :class:`~shiny.render.tranformer.OutputRenderer` + * :class:`~shiny.render.tranformer.OutputRendererSync` """ - def _is_async(self) -> bool: - """ - Meta information about the renderer being asynchronous or not. - - Returns - ------- - : - Returns `TRUE` as this is an asynchronous renderer. - """ - return True - def __init__( self, value_fn: ValueFnAsync[IT], transform_fn: TransformFn[IT, P, OT], params: TransformerParams[P], ) -> None: - if not _utils.is_async_callable(value_fn): + if not is_async_callable(value_fn): raise TypeError( self.__class__.__name__ + " requires an asynchronous render function" ) @@ -357,14 +377,14 @@ async def __call__(self) -> OT: # pyright: ignore[reportIncompatibleMethodOverr # ====================================================================================== -# Restrict the value function +# Restrict the transformer function # ====================================================================================== # assert: No variable length positional values; # * We need a way to distinguish between a plain function and args supplied to the next # function. This is done by not allowing `*args`. -# assert: All kwargs of transformer should have a default value +# assert: All `**kwargs` of transformer should have a default value # * This makes calling the method with both `()` and without `()` possible / consistent. def _assert_transformer(transform_fn: TransformFn[IT, P, OT]) -> None: params = inspect.Signature.from_callable(transform_fn).parameters @@ -372,7 +392,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 " - "`RenderMeta` and `RenderFnAsync` respectively" + "`TransformerMetadata` and `RenderFnAsync` respectively" ) for i, param in zip(range(len(params)), params.values()): @@ -380,7 +400,7 @@ def _assert_transformer(transform_fn: TransformFn[IT, P, OT]) -> None: # # have been renamed. We need to do an `isinstance` check but do not have # # access to the objects # if i == 0: - # assert param.annotation == "RenderMeta" + # assert param.annotation == "TransformerMetadata" # if i == 1: # assert (param.annotation or "").startswith("RenderFnAsync") if i < 2 and not ( @@ -389,7 +409,7 @@ def _assert_transformer(transform_fn: TransformFn[IT, P, OT]) -> None: ): raise TypeError( "`transformer=` must have 2 positional parameters which have type " - "`RenderMeta` and `RenderFnAsync` respectively" + "`TransformerMetadata` and `RenderFnAsync` respectively" ) # Make sure there are no more than 2 positional args @@ -414,12 +434,17 @@ def _assert_transformer(transform_fn: TransformFn[IT, P, OT]) -> None: # ====================================================================================== -# Renderer decorator +# OutputTransformer # ====================================================================================== # Signature of a renderer decorator function OutputRendererDecorator = Callable[[ValueFn[IT]], OutputRenderer[OT]] +""" +Decorator function that takes the output value function (then calls it and transforms +the value) and returns an :class:`~shiny.render.transformer.OutputRenderer`. +""" + # Signature of a decorator that can be called with and without parentheses # With parens returns a `Renderer[OT]` # Without parens returns a `RendererDeco[IT, OT]` @@ -430,42 +455,46 @@ def _assert_transformer(transform_fn: TransformFn[IT, P, OT]) -> None: ], Union[OutputRenderer[OT], OutputRendererDecorator[IT, OT]], ] +""" +Generic overload definition for a decorator that can be called with and without +parentheses. If called with parentheses, it returns an decorator which returns an +:class:`~shiny.render.transformer.OutputRenderer`. If called without parentheses, it +returns an :class:`~shiny.render.transformer.OutputRenderer`. +""" class OutputTransformer(Generic[IT, OT, P]): """ Output Transformer class - A Transfomer takes the value returned from the user's render function, passes it - through the component author's transformer function, and returns the result. TODO-barret: cleanup docs + This class creates helper types and methods for defining an overloaded renderer + decorator. By manually defining the overloads locally, the function signatures are + as clean as possible (and therefore easier to read and understand). + + When called, an `OutputTransfomer` takes the value returned from the app-supplied + output value function and any app-supplied paramters and passes them through the + component author's transformer function, and returns the transformed result. Properties ---------- - type_decorator - The return type for the renderer decorator wrapper function. This should be used - when the app-defined render function is `None` and extra parameters are being - supplied. - type_renderer_fn - The (non-`None`) type for the renderer function's first argument that accepts an - app-defined render function. This type should be paired with the return type: - `type_renderer`. - type_renderer - The type for the return value of the renderer decorator function. This should be - used when the app-defined render function is not `None`. - type_impl_fn - The type for the implementation function's first argument. This value handles - both app-defined render functions and `None` and returns values appropriate for - both cases. `type_impl_fn` should be paired with `type_impl`. - type_impl - The type for the return value of the implementation function. This value handles - both app-defined render functions and `None` and returns values appropriate for - both cases. + ValueFn + The function type for the app-supplied output value function. This function may + be both synchronous or asynchronous. + OutputRenderer + The return type for the overload that accepts the app-supplied output value + function and returns an object of + :class:`~shiny.render.transformer.OutputRenderer`. + OutputRendererDecorator + The return type for the overload that accepts app-supplied parameters for the + transform function. The return value is a decorator that accepts the + app-supplied output value function and returns an object of + :class:`~shiny.render.transformer.OutputRenderer`. See Also -------- - * :func:`~shiny.render.renderer_components` - * :class:`~shiny.render.RendererParams` - * :class:`~shiny.render.Renderer` + * :func:`~shiny.render.transformer.output_transformer` + * :class:`~shiny.render.tranformer.TransformerParams` + * :class:`~shiny.render.tranformer.OutputRenderer` """ def params( @@ -484,7 +513,9 @@ def __call__( params = self.params() if not isinstance(params, TransformerParams): raise TypeError( - f"Expected `params` to be of type `RendererParams` but received `{type(params)}`. Please use `.params()` to create a `RendererParams` object." + "Expected `params` to be of type `TransformerParams` but received " + f"`{type(params)}`. Please use `.params()` to create a " + "`TransformerParams` object." ) return self._fn(value_fn, params) @@ -503,7 +534,7 @@ def output_transformer( transform_fn: TransformFn[IT, P, OT], ) -> OutputTransformer[IT, OT, P]: """ - Renderer components decorator + Output transformer decorator This decorator method is a convenience method to generate the appropriate types and internal implementation for an overloaded renderer method. This method will provide @@ -511,57 +542,67 @@ def output_transformer( decorator is called without parentheses and another for when it is called with parentheses where app authors can pass in parameters to the renderer. - ## Handler function + ## Transform function - The renderer's asynchronous handler function (`transform_fn`) is the key building - block for `renderer_components`. + The output renderer's transform function (`transform_fn`) is the key building block + for `output_transformer`. It is a package author function that calls the app-defined + output value function (`value_fn`) transforms the result of type `IT` into type + `OT`. - The handler function is supplied meta renderer information, the (app-supplied) - render function, and any keyword arguments supplied to the renderer decorator: + The transform function is supplied meta output information, the (app-supplied) value + function, and any keyword arguments supplied to the output tranformer decorator: * The first parameter to the handler function has the class - :class:`~shiny.render.RenderMeta` and is typically called (e.g. `_meta`). This - information gives context the to the handler while trying to resolve the - app-supplied render function (e.g. `_fn`). - * The second parameter is the app-defined render function (e.g. `_fn`). It's return - type (`IT`) determines what types can be returned by the app-supplied render - function. For example, if `_fn` has the type `RenderFnAsync[str | None]`, both the - `str` and `None` types are allowed to be returned from the app-supplied render - function. + :class:`~shiny.render.tranformer.TransformerMetadata` and is typically called + `_meta`. This information gives context the to the handler while trying to + resolve the app-supplied value function (typically called `_fn`). + * The second parameter is the app-defined output value function (e.g. `_fn`). It's + return type (`IT`) determines what types can be returned by the app-supplied + output value function. For example, if `_fn` has the type `ValueFn[str | None]`, + both the `str` and `None` types are allowed to be returned from the app-supplied + output value function. * The remaining parameters are the keyword arguments (e.g. `alt:Optional[str] = - None` or `**kwargs: Any`) that app authors may supply to the renderer (when the + None` or `**kwargs: object`) that app authors may supply to the renderer (when the renderer decorator is called with parentheses). Variadic positional parameters (e.g. `*args`) are not allowed. All keyword arguments should have a type and - default value (except for `**kwargs: Any`). - - The handler's return type (`OT`) determines the output type of the renderer. Note - that in many cases (but not all!) `IT` and `OT` will be the same. The `None` type - should typically be defined in both `IT` and `OT`. If `IT` allows for `None` values, - it (typically) signals that nothing should be rendered. If `OT` allows for `None` - and returns a `None` value, shiny will not render the output. + default value. No default value is needed for keyword arguments that are passed + through (e.g. `**kwargs: Any`). + The tranform function's return type (`OT`) determines the output type of the + :class:`~shiny.render.transformer.OutputRenderer`. Note that in many cases (but not + all!) `IT` and `OT` will be the same. The `None` type should typically be defined in + both `IT` and `OT`. If `IT` allows for `None` values, it (typically) signals that + nothing should be rendered. If `OT` allows for `None` and returns a `None` value, + shiny will not render the output. Notes ----- - When defining the renderer decorator overloads, if you have extra parameters of - `**kwargs: object`, you may get a type error about incompatible signatures. To fix - this, you can use `**kwargs: Any` instead or add `_fn: None = None` as the first - parameter in the overload containing the `**kwargs: object`. + * When defining the renderer decorator overloads, if you have extra parameters of + `**kwargs: object`, you may get a type error about incompatible signatures. To fix + this, you can use `**kwargs: Any` instead or add `_fn: None = None` as the first + parameter in the overload containing the `**kwargs: object`. + + * The `transform_fn` should be defined as an asynchronous function but should only + asynchronously yield (i.e. use `await` syntax) when the value function (the second + parameter of type `ValueFn[IT]`) is awaitable. If the value function is not + awaitable (i.e. it is a _synchronous_ function), then the execution of the + transform function should also be synchronous. + Parameters ---------- transform_fn - Asynchronous function used to determine the app-supplied value type (`IT`), the - rendered type (`OT`), and the parameters (`P`) app authors can supply to the - renderer. + Asynchronous function used to determine the app-supplied output value function + return type (`IT`), the transformed type (`OT`), and the keyword arguments (`P`) + app authors can supply to the renderer decorator. Returns ------- : - A :class:`~shiny.render.RendererComponents` object that can be used to define - two overloads for your renderer function. One overload is for when the renderer - is called without parentheses and the other is for when the renderer is called - with parentheses. + A :class:`~shiny.render.tranformer.OutputTransformer` object that can be used to + define two overloads for your renderer function. One overload is for when the + renderer is called without parentheses and the other is for when the renderer is + called with parentheses. """ _assert_transformer(transform_fn) @@ -572,9 +613,10 @@ def renderer_decorator( def as_value_fn( fn: ValueFnSync[IT] | ValueFnAsync[IT], ) -> OutputRenderer[OT]: - if _utils.is_async_callable(fn): + if is_async_callable(fn): return OutputRendererAsync(fn, transform_fn, params) else: + # To avoid duplicate work just for a typeguard, we cast the function fn = cast(ValueFnSync[IT], fn) return OutputRendererSync(fn, transform_fn, params) From 39afa5c424fa81b65581e731972db74e79554b6c Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 9 Aug 2023 12:03:00 -0400 Subject: [PATCH 53/64] Mark random port test as flaky --- tests/test_utils.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_utils.py b/tests/test_utils.py index 66e638a78..8f7d4b8dc 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -138,6 +138,7 @@ async def mutate_registrations(): # Timeout within 2 seconds @pytest.mark.timeout(2) +@pytest.mark.flaky(reruns=3, reruns_delay=1) def test_random_port(): assert random_port(9000, 9000) == 9000 From caa861e5c10a9677f7e4256d9f5aefcab119079b Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 9 Aug 2023 12:52:53 -0400 Subject: [PATCH 54/64] Cleanup files. Implement `_fn` as `ValueFn[IT]` (not `ValueFnAsync[IT]`); Expose `render_value_fn(value_fn)` --- .../{renderer => output_transformer}/app.py | 14 ++-- .../test_output_transformer.py} | 0 shiny/_utils.py | 16 ++++- .../app.py | 49 ++++++++----- shiny/render/_dataframe.py | 12 ++-- shiny/render/_deprecated.py | 5 +- shiny/render/_render.py | 31 ++++---- shiny/render/transformer/__init__.py | 6 +- shiny/render/transformer/_transformer.py | 70 +++++++++++++++---- ...renderer.py => test_output_transformer.py} | 37 +++++----- 10 files changed, 164 insertions(+), 76 deletions(-) rename e2e/server/{renderer => output_transformer}/app.py (88%) rename e2e/server/{renderer/test_renderer.py => output_transformer/test_output_transformer.py} (100%) rename shiny/api-examples/{renderer_components => output_transformer}/app.py (66%) rename tests/{test_renderer.py => test_output_transformer.py} (92%) diff --git a/e2e/server/renderer/app.py b/e2e/server/output_transformer/app.py similarity index 88% rename from e2e/server/renderer/app.py rename to e2e/server/output_transformer/app.py index 4d2282a77..12b7baad8 100644 --- a/e2e/server/renderer/app.py +++ b/e2e/server/output_transformer/app.py @@ -5,20 +5,26 @@ from typing import Optional, overload from shiny import App, Inputs, Outputs, Session, ui -from shiny.render._render import TransformerMetadata, ValueFnAsync, output_transformer +from shiny.render._render import ( + TransformerMetadata, + ValueFn, + is_async_callable, + output_transformer, + resolve_value_fn, +) @output_transformer async def TestTextTransformer( _meta: TransformerMetadata, - _afn: ValueFnAsync[str | None], + _fn: ValueFn[str | None], *, extra_txt: Optional[str] = None, ) -> str | None: - value = await _afn() + value = await resolve_value_fn(_fn) value = str(value) value += "; " - value += "async" if _meta.is_async else "sync" + value += "async" if is_async_callable(_fn) else "sync" if extra_txt: value = value + "; " + str(extra_txt) return value diff --git a/e2e/server/renderer/test_renderer.py b/e2e/server/output_transformer/test_output_transformer.py similarity index 100% rename from e2e/server/renderer/test_renderer.py rename to e2e/server/output_transformer/test_output_transformer.py diff --git a/shiny/_utils.py b/shiny/_utils.py index e8d1adb7e..2ec1ebc16 100644 --- a/shiny/_utils.py +++ b/shiny/_utils.py @@ -245,13 +245,23 @@ async def fn_async(*args: P.args, **kwargs: P.kwargs) -> T: return fn_async +# This function should generally be used in this code base instead of +# `iscoroutinefunction()`. def is_async_callable( obj: Callable[P, T] | Callable[P, Awaitable[T]] ) -> TypeGuard[Callable[P, Awaitable[T]]]: """ - Returns True if `obj` is an `async def` function, or if it's an object with a - `__call__` method which is an `async def` function. This function should generally - be used in this code base instead of iscoroutinefunction(). + Determine if an object is an async function. + + This is a more general version of `inspect.iscoroutinefunction()`, which only works + on functions. This function works on any object that has a `__call__` method, such + as a class instance. + + Returns + ------- + : + Returns True if `obj` is an `async def` function, or if it's an object with a + `__call__` method which is an `async def` function. """ if inspect.iscoroutinefunction(obj): return True diff --git a/shiny/api-examples/renderer_components/app.py b/shiny/api-examples/output_transformer/app.py similarity index 66% rename from shiny/api-examples/renderer_components/app.py rename to shiny/api-examples/output_transformer/app.py index 584f605e2..1e6dd2a0f 100644 --- a/shiny/api-examples/renderer_components/app.py +++ b/shiny/api-examples/output_transformer/app.py @@ -5,31 +5,43 @@ from typing import Literal, overload from shiny import App, Inputs, Outputs, Session, ui -from shiny.render import TransformerMetadata, ValueFnAsync, output_transformer +from shiny.render.transformer import ( + TransformerMetadata, + ValueFn, + output_transformer, + resolve_value_fn, +) ####### -# Package authors can create their own renderer methods by leveraging -# `renderer_components` helper method +# Package authors can create their own output transformer methods by leveraging +# `output_transformer` decorator. # -# This example is kept simple for demonstration purposes, but the handler function supplied to -# `renderer_components` can be much more complex (e.g. shiny.render.plotly) +# The transformer is kept simple for demonstration purposes, but it can be much more +# complex (e.g. shiny.render.plotly) ####### # Create renderer components from the async handler function: `capitalize_components()` @output_transformer async def CapitalizeTransformer( - # Contains information about the render call: `name`, `session`, `is_async` + # Contains information about the render call: `name` and `session` _meta: TransformerMetadata, - # An async form of the app-supplied render function - _afn: ValueFnAsync[str | None], + # The app-supplied output value function + _fn: ValueFn[str | None], *, - # Extra parameters that app authors can supply (e.g. `render_capitalize(to="upper")`) + # 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 _afn() - # Quit early if value is `None` + 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 @@ -48,7 +60,7 @@ async def CapitalizeTransformer( # def value(): # return input.caption() # ``` -# Note: Return type is `type_decorator` +# Note: Return type is `OutputRendererDecorator` @overload def render_capitalize( *, @@ -66,8 +78,8 @@ def render_capitalize( # def value(): # return input.caption() # ``` -# Note: `_fn` type is `type_renderer_fn` -# Note: Return type is `type_renderer` +# Note: `_fn` type is the transformer's `ValueFn` +# Note: Return type is the transformer's `OutputRenderer` @overload def render_capitalize( _fn: CapitalizeTransformer.ValueFn, @@ -76,8 +88,8 @@ def render_capitalize( # Lastly, implement the renderer. -# Note: `_fn` type is `type_impl_fn` -# Note: Return type is `type_impl` +# Note: `_fn` type is the transformer's `ValueFn` or `None` +# Note: Return type is the transformer's `OutputRenderer | OutputRendererDecorator` def render_capitalize( _fn: CapitalizeTransformer.ValueFn | None = None, *, @@ -109,18 +121,21 @@ def render_capitalize( def server(input: Inputs, output: Outputs, session: Session): @output + # Without parentheses @render_capitalize def no_parens(): return input.caption() @output + # With parentheses (same as `@render_capitalize()`) @render_capitalize(to="upper") def to_upper(): return input.caption() @output @render_capitalize(to="lower") - def to_lower(): + # Works with async functions too! + async def to_lower(): return input.caption() diff --git a/shiny/render/_dataframe.py b/shiny/render/_dataframe.py index cf5e6d964..46a4a063d 100644 --- a/shiny/render/_dataframe.py +++ b/shiny/render/_dataframe.py @@ -15,7 +15,12 @@ from .._docstring import add_example from ._dataframe_unsafe import serialize_numpy_dtypes -from .transformer import TransformerMetadata, ValueFnAsync, output_transformer +from .transformer import ( + TransformerMetadata, + ValueFn, + output_transformer, + resolve_value_fn, +) if TYPE_CHECKING: import pandas as pd @@ -215,13 +220,12 @@ def serialize_pandas_df(df: "pd.DataFrame") -> dict[str, Any]: DataFrameResult = Union[None, "pd.DataFrame", DataGrid, DataTable] -# TODO-barret; Port `__name__` and `__docs__` of `value_fn` @output_transformer async def DataFrameTransformer( _meta: TransformerMetadata, - _afn: ValueFnAsync[DataFrameResult | None], + _fn: ValueFn[DataFrameResult | None], ) -> object | None: - x = await _afn() + x = await resolve_value_fn(_fn) if x is None: return None diff --git a/shiny/render/_deprecated.py b/shiny/render/_deprecated.py index f937663f8..935130319 100644 --- a/shiny/render/_deprecated.py +++ b/shiny/render/_deprecated.py @@ -12,6 +12,7 @@ OutputRendererSync, TransformerMetadata, TransformerParams, + ValueFn, ValueFnAsync, ValueFnSync, ) @@ -39,7 +40,7 @@ async def run(self) -> OT: ... def __init__(self, fn: ValueFnSync[IT]) -> None: - async def transformer(_meta: TransformerMetadata, _fn: ValueFnAsync[IT]) -> OT: + async def transformer(_meta: TransformerMetadata, _fn: ValueFn[IT]) -> OT: ret = await self.run() return ret @@ -68,7 +69,7 @@ async def run(self) -> OT: ... def __init__(self, fn: ValueFnAsync[IT]) -> None: - async def transformer(_meta: TransformerMetadata, _fn: ValueFnAsync[IT]) -> OT: + async def transformer(_meta: TransformerMetadata, _fn: ValueFn[IT]) -> OT: ret = await self.run() return ret diff --git a/shiny/render/_render.py b/shiny/render/_render.py index 289247da0..d3f0c634d 100644 --- a/shiny/render/_render.py +++ b/shiny/render/_render.py @@ -35,7 +35,13 @@ from .._namespaces import ResolvedId from ..types import ImgData from ._try_render_plot import try_render_matplotlib, try_render_pil, try_render_plotnine -from .transformer import TransformerMetadata, ValueFnAsync, output_transformer +from .transformer import ( + TransformerMetadata, + ValueFn, + is_async_callable, + output_transformer, + resolve_value_fn, +) # ====================================================================================== # RenderText @@ -45,9 +51,9 @@ @output_transformer async def TextTransformer( _meta: TransformerMetadata, - _afn: ValueFnAsync[str | None], + _fn: ValueFn[str | None], ) -> str | None: - value = await _afn() + value = await resolve_value_fn(_fn) if value is None: return None return str(value) @@ -98,12 +104,12 @@ def text( @output_transformer async def PlotTransformer( _meta: TransformerMetadata, - _afn: ValueFnAsync[ImgData | None], + _fn: ValueFn[ImgData | None], *, alt: Optional[str] = None, **kwargs: object, ) -> ImgData | None: - is_userfn_async = _meta.is_async + is_userfn_async = is_async_callable(_fn) name = _meta.name session = _meta.session @@ -122,7 +128,8 @@ async def PlotTransformer( float, inputs[ResolvedId(f".clientdata_output_{name}_height")]() ) - x = await _afn() + # 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 @@ -265,11 +272,11 @@ def plot( @output_transformer async def ImageTransformer( _meta: TransformerMetadata, - _afn: ValueFnAsync[ImgData | None], + _fn: ValueFn[ImgData | None], *, delete_file: bool = False, ) -> ImgData | None: - res = await _afn() + res = await resolve_value_fn(_fn) if res is None: return None @@ -351,14 +358,14 @@ def to_pandas(self) -> "pd.DataFrame": @output_transformer async def TableTransformer( _meta: TransformerMetadata, - _afn: ValueFnAsync[TableResult | None], + _fn: ValueFn[TableResult | None], *, index: bool = False, classes: str = "table shiny-table w-auto", border: int = 0, **kwargs: object, ) -> RenderedDeps | None: - x = await _afn() + x = await resolve_value_fn(_fn) if x is None: return None @@ -481,9 +488,9 @@ def table( @output_transformer async def UiTransformer( _meta: TransformerMetadata, - _afn: ValueFnAsync[TagChild], + _fn: ValueFn[TagChild], ) -> RenderedDeps | None: - ui = await _afn() + ui = await resolve_value_fn(_fn) if ui is None: return None diff --git a/shiny/render/transformer/__init__.py b/shiny/render/transformer/__init__.py index a2fdb67a2..3728af1d8 100644 --- a/shiny/render/transformer/__init__.py +++ b/shiny/render/transformer/__init__.py @@ -4,8 +4,9 @@ OutputRenderer, OutputTransformer, ValueFn, - ValueFnAsync, output_transformer, + is_async_callable, + resolve_value_fn, ) __all__ = ( @@ -14,6 +15,7 @@ "OutputRenderer", "OutputTransformer", "ValueFn", - "ValueFnAsync", "output_transformer", + "is_async_callable", + "resolve_value_fn", ) diff --git a/shiny/render/transformer/_transformer.py b/shiny/render/transformer/_transformer.py index 774ec1ba5..9497eea9d 100644 --- a/shiny/render/transformer/_transformer.py +++ b/shiny/render/transformer/_transformer.py @@ -1,5 +1,3 @@ -# Needed for types imported only during TYPE_CHECKING with Python 3.7 - 3.9 -# See https://www.python.org/dev/peps/pep-0655/#usage-in-python-3-11 from __future__ import annotations __all__ = ( @@ -158,9 +156,7 @@ def inner(*args: P.args, **kwargs: P.kwargs) -> TransformerParams[P]: # `TransformFn` is a package author function that transforms an object of type `IT` into # type `OT`. -TransformFn = Callable[ - Concatenate[TransformerMetadata, ValueFnAsync[IT], P], Awaitable[OT] -] +TransformFn = Callable[Concatenate[TransformerMetadata, ValueFn[IT], P], Awaitable[OT]] """ Package author function that transforms an object of type `IT` into type `OT`. It should be defined as an asynchronous function but should only asynchronously yield when the @@ -255,8 +251,7 @@ def __init__( ) self._is_async = is_async_callable(value_fn) - # TODO-barret; Do not wrap!! - self._value_fn = _utils.wrap_async(value_fn) + self._value_fn = value_fn self._transformer = transform_fn self._params = params @@ -446,9 +441,9 @@ def _assert_transformer(transform_fn: TransformFn[IT, P, OT]) -> None: """ # Signature of a decorator that can be called with and without parentheses -# With parens returns a `Renderer[OT]` -# Without parens returns a `RendererDeco[IT, OT]` -OutputRendererImplFn = Callable[ +# With parens returns a `OutputRenderer[OT]` +# Without parens returns a `OutputRendererDeco[IT, OT]` +OutputTransformerFn = Callable[ [ Optional[ValueFn[IT]], TransformerParams[P], @@ -521,7 +516,7 @@ def __call__( def __init__( self, - fn: OutputRendererImplFn[IT, P, OT], + fn: OutputTransformerFn[IT, P, OT], ) -> None: self._fn = fn self.ValueFn = ValueFn[IT] @@ -607,11 +602,11 @@ def output_transformer( _assert_transformer(transform_fn) def renderer_decorator( - value_fn: ValueFnSync[IT] | ValueFnAsync[IT] | None, + value_fn: ValueFn[IT] | None, params: TransformerParams[P], ) -> OutputRenderer[OT] | OutputRendererDecorator[IT, OT]: def as_value_fn( - fn: ValueFnSync[IT] | ValueFnAsync[IT], + fn: ValueFn[IT], ) -> OutputRenderer[OT]: if is_async_callable(fn): return OutputRendererAsync(fn, transform_fn, params) @@ -626,3 +621,52 @@ def as_value_fn( return val return OutputTransformer(renderer_decorator) + + +async def resolve_value_fn(value_fn: ValueFn[IT]) -> IT: + """ + Resolve the value function + + This function is used to resolve the value function (`value_fn`) to an object of + type `IT`. If the value function is asynchronous, it will be awaited. If the value + function is synchronous, it will be called. + + While always using an async method within an output transform function is not + appropriate, this method may be used, avoiding the boilerplate of "if is async, then + await value_fn() else cast as synchronous and return value_fn()". + + Replace this: + ```python + if is_async_callable(_fn): + x = await _fn() + else: + x = _fn() + ``` + + With this: + ```python + x = await resolve_value_fn(_fn) + ``` + + This substitution is safe as the implementation does not actually asynchronously + yield to another process if the `value_fn` is synchronous. The `__call__` method of + the :class:`~shiny.render.transformer.OutputRendererSync` is built to execute + asynchronously defined methods that execute synchronously. + + Parameters + ---------- + value_fn + App-supplied output value function which returns type `IT`. This function can be + synchronous or asynchronous. + + Returns + ------- + : + The resolved value from `value_fn`. + """ + if is_async_callable(value_fn): + return await value_fn() + else: + # To avoid duplicate work just for a typeguard, we cast the function + value_fn = cast(ValueFnSync[IT], value_fn) + return value_fn() diff --git a/tests/test_renderer.py b/tests/test_output_transformer.py similarity index 92% rename from tests/test_renderer.py rename to tests/test_output_transformer.py index c869448bd..7a08dac62 100644 --- a/tests/test_renderer.py +++ b/tests/test_output_transformer.py @@ -6,8 +6,9 @@ from shiny._utils import is_async_callable from shiny.render.transformer import ( TransformerMetadata, - ValueFnAsync, + ValueFn, output_transformer, + resolve_value_fn, ) @@ -16,7 +17,7 @@ def test_output_transformer_works(): @output_transformer async def TestTransformer( _meta: TransformerMetadata, - _afn: ValueFnAsync[str], + _fn: ValueFn[str], ): ... @@ -41,7 +42,7 @@ def test_output_transformer_kwargs_are_allowed(): @output_transformer async def TestTransformer( _meta: TransformerMetadata, - _afn: ValueFnAsync[str], + _fn: ValueFn[str], *, y: str = "42", ): @@ -73,7 +74,7 @@ def test_output_transformer_with_pass_through_kwargs(): @output_transformer async def TestTransformer( _meta: TransformerMetadata, - _afn: ValueFnAsync[str], + _fn: ValueFn[str], *, y: str = "42", **kwargs: float, @@ -124,7 +125,7 @@ def test_output_transformer_limits_positional_arg_count(): @output_transformer async def TestTransformer( _meta: TransformerMetadata, - _afn: ValueFnAsync[str], + _fn: ValueFn[str], y: str, ): ... @@ -140,7 +141,7 @@ def test_output_transformer_does_not_allow_args(): @output_transformer async def TestTransformer( _meta: TransformerMetadata, - _afn: ValueFnAsync[str], + _fn: ValueFn[str], *args: str, ): ... @@ -157,7 +158,7 @@ def test_output_transformer_kwargs_have_defaults(): @output_transformer async def TestTransformer( _meta: TransformerMetadata, - _afn: ValueFnAsync[str], + _fn: ValueFn[str], *, y: str, ): @@ -173,7 +174,7 @@ def test_output_transformer_result_does_not_allow_args(): @output_transformer async def TestTransformer( _meta: TransformerMetadata, - _afn: ValueFnAsync[str], + _fn: ValueFn[str], ): ... @@ -188,21 +189,20 @@ def render_fn_sync(*args: str): ) raise RuntimeError() except TypeError as e: - assert "Expected `params` to be of type `RendererParams`" in str(e) + assert "Expected `params` to be of type `TransformerParams`" in str(e) -# "Currently, `ValueFnAsync` can not be truely async and " -# "support sync render methods" +# "Currently, `ValueFn` can not be truely async and "support sync render methods" @pytest.mark.asyncio async def test_renderer_handler_fn_can_be_async(): @output_transformer async def AsyncTransformer( _meta: TransformerMetadata, - _afn: ValueFnAsync[str], + _fn: ValueFn[str], ) -> str: # Actually sleep to test that the handler is truely async await asyncio.sleep(0.1) - ret = await _afn() + ret = await resolve_value_fn(_fn) return ret # ## Setup overloads ============================================= @@ -265,20 +265,19 @@ async def async_app_render_fn() -> str: assert ret == async_test_val -# "Currently, `ValueFnAsync` can not be truely async and " -# "support sync render methods" +# "Currently, `ValueFnA` can not be truely async and "support sync render methods". +# Test that conditionally calling async works. @pytest.mark.asyncio async def test_renderer_handler_fn_can_be_yield_while_async(): @output_transformer async def YieldTransformer( _meta: TransformerMetadata, - _afn: ValueFnAsync[str], + _fn: ValueFn[str], ) -> str: - # Only yield if the handler is async - if _meta.is_async: + if is_async_callable(_fn): # Actually sleep to test that the handler is truely async await asyncio.sleep(0.1) - ret = await _afn() + ret = await resolve_value_fn(_fn) return ret # ## Setup overloads ============================================= From 296e8ff9cb674465c9e2d6d8114eefe3cb40a726 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 9 Aug 2023 13:42:44 -0400 Subject: [PATCH 55/64] Add output transformer docs --- docs/_quartodoc.yml | 16 ++++++++++++++++ shiny/render/transformer/__init__.py | 10 +++++++--- shiny/render/transformer/_transformer.py | 24 +++++++++++++----------- 3 files changed, 36 insertions(+), 14 deletions(-) diff --git a/docs/_quartodoc.yml b/docs/_quartodoc.yml index d1387b9db..f56ed0e87 100644 --- a/docs/_quartodoc.yml +++ b/docs/_quartodoc.yml @@ -125,6 +125,22 @@ quartodoc: - render.data_frame - render.DataGrid - render.DataTable + - kind: page + path: OutputRender + flatten: true + summary: + name: "Create output render methods" + desc: "" + contents: + - render.transformer.output_transformer + - render.transformer.OutputTransformer + - render.transformer.TransformerMetadata + - render.transformer.TransformerParams + - render.transformer.OutputRenderer + - render.transformer.is_async_callable + - render.transformer.resolve_value_fn + - render.transformer.ValueFn + - render.transformer.TransformFn - title: Reactive programming desc: "" contents: diff --git a/shiny/render/transformer/__init__.py b/shiny/render/transformer/__init__.py index 3728af1d8..df5c643bf 100644 --- a/shiny/render/transformer/__init__.py +++ b/shiny/render/transformer/__init__.py @@ -1,12 +1,17 @@ -from ._transformer import ( +from ._transformer import ( # noqa: F401 TransformerMetadata, TransformerParams, OutputRenderer, OutputTransformer, - ValueFn, output_transformer, is_async_callable, resolve_value_fn, + ValueFn, # pyright: ignore[reportUnusedImport] + ValueFnSync, # pyright: ignore[reportUnusedImport] + ValueFnAsync, # pyright: ignore[reportUnusedImport] + TransformFn, # pyright: ignore[reportUnusedImport] + OutputRendererSync, # pyright: ignore[reportUnusedImport] + OutputRendererAsync, # pyright: ignore[reportUnusedImport] ) __all__ = ( @@ -14,7 +19,6 @@ "TransformerParams", "OutputRenderer", "OutputTransformer", - "ValueFn", "output_transformer", "is_async_callable", "resolve_value_fn", diff --git a/shiny/render/transformer/_transformer.py b/shiny/render/transformer/_transformer.py index 9497eea9d..6bb166091 100644 --- a/shiny/render/transformer/_transformer.py +++ b/shiny/render/transformer/_transformer.py @@ -182,7 +182,7 @@ class OutputRenderer(Generic[OT], ABC): syntax. The transform function (`transform_fn`) is given `meta` information - (:class:`~shiny.render.tranformer.TranformerMetadata`), the (app-supplied) value + (:class:`~shiny.render.transformer.TranformerMetadata`), the (app-supplied) value function (`ValueFn[IT]`), and any keyword arguments supplied to the render decorator (`P`). For consistency, the first two parameters have been (arbitrarily) implemented as `_meta` and `_fn`. @@ -283,7 +283,7 @@ async def _run(self) -> OT: `**kwargs` of type `P`. Note: `*args` will always be empty as it is an expansion of - :class:`~shiny.render.tranformer.TransformerParams` which does not allow positional arguments. + :class:`~shiny.render.transformer.TransformerParams` which does not allow positional arguments. `*args` is required to use with `**kwargs` when using `typing.ParamSpec`. """ @@ -309,8 +309,8 @@ class OutputRendererSync(OutputRenderer[OT]): See Also -------- - * :class:`~shiny.render.tranformer.OutputRenderer` - * :class:`~shiny.render.tranformer.OutputRendererAsync` + * :class:`~shiny.render.transformer.OutputRenderer` + * :class:`~shiny.render.transformer.OutputRendererAsync` """ def __init__( @@ -346,8 +346,8 @@ class OutputRendererAsync(OutputRenderer[OT]): See Also -------- - * :class:`~shiny.render.tranformer.OutputRenderer` - * :class:`~shiny.render.tranformer.OutputRendererSync` + * :class:`~shiny.render.transformer.OutputRenderer` + * :class:`~shiny.render.transformer.OutputRendererSync` """ def __init__( @@ -488,8 +488,8 @@ class OutputTransformer(Generic[IT, OT, P]): See Also -------- * :func:`~shiny.render.transformer.output_transformer` - * :class:`~shiny.render.tranformer.TransformerParams` - * :class:`~shiny.render.tranformer.OutputRenderer` + * :class:`~shiny.render.transformer.TransformerParams` + * :class:`~shiny.render.transformer.OutputRenderer` """ def params( @@ -537,7 +537,8 @@ def output_transformer( decorator is called without parentheses and another for when it is called with parentheses where app authors can pass in parameters to the renderer. - ## Transform function + Transform function + ------------------ The output renderer's transform function (`transform_fn`) is the key building block for `output_transformer`. It is a package author function that calls the app-defined @@ -546,8 +547,9 @@ def output_transformer( The transform function is supplied meta output information, the (app-supplied) value function, and any keyword arguments supplied to the output tranformer decorator: + * The first parameter to the handler function has the class - :class:`~shiny.render.tranformer.TransformerMetadata` and is typically called + :class:`~shiny.render.transformer.TransformerMetadata` and is typically called `_meta`. This information gives context the to the handler while trying to resolve the app-supplied value function (typically called `_fn`). * The second parameter is the app-defined output value function (e.g. `_fn`). It's @@ -594,7 +596,7 @@ def output_transformer( Returns ------- : - A :class:`~shiny.render.tranformer.OutputTransformer` object that can be used to + An :class:`~shiny.render.transformer.OutputTransformer` object that can be used to define two overloads for your renderer function. One overload is for when the renderer is called without parentheses and the other is for when the renderer is called with parentheses. From 0213e98c127f022396f7e94b8cb4fad4fa17ed68 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 9 Aug 2023 16:01:03 -0400 Subject: [PATCH 56/64] More documentation. More to go --- docs/_quartodoc.yml | 10 +++--- shiny/render/_dataframe.py | 6 ---- shiny/render/transformer/_transformer.py | 44 ++++++++++-------------- 3 files changed, 25 insertions(+), 35 deletions(-) diff --git a/docs/_quartodoc.yml b/docs/_quartodoc.yml index ce4269e2e..aaa17d8e1 100644 --- a/docs/_quartodoc.yml +++ b/docs/_quartodoc.yml @@ -129,7 +129,7 @@ quartodoc: path: OutputRender flatten: true summary: - name: "Create output render methods" + name: "Create rendering outputs" desc: "" contents: - render.transformer.output_transformer @@ -137,6 +137,8 @@ quartodoc: - 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 @@ -194,7 +196,7 @@ quartodoc: desc: "" contents: - kind: page - path: MiscTypes.html + path: MiscTypes flatten: true summary: name: "Miscellaneous types" @@ -208,7 +210,7 @@ quartodoc: - ui._input_slider.SliderValueArg - ui._input_slider.SliderStepArg - kind: page - path: TagTypes.html + path: TagTypes summary: name: "Tag types" desc: "" @@ -221,7 +223,7 @@ quartodoc: - htmltools.TagChild - htmltools.TagList - kind: page - path: ExceptionTypes.html + path: ExceptionTypes summary: name: "Exception types" desc: "" diff --git a/shiny/render/_dataframe.py b/shiny/render/_dataframe.py index 46a4a063d..171a35e6c 100644 --- a/shiny/render/_dataframe.py +++ b/shiny/render/_dataframe.py @@ -257,12 +257,6 @@ def data_frame( """ Reactively render a Pandas data frame object (or similar) as a basic HTML table. - Parameters - ---------- - index - Whether to print index (row) labels. - selection - Returns ------- diff --git a/shiny/render/transformer/_transformer.py b/shiny/render/transformer/_transformer.py index 6bb166091..7c11bc704 100644 --- a/shiny/render/transformer/_transformer.py +++ b/shiny/render/transformer/_transformer.py @@ -1,5 +1,7 @@ from __future__ import annotations +# TODO-barret; missing first paragraph from some classes: Example: TransformerMetadata. No init method for TransformerParams + __all__ = ( "TransformerMetadata", "TransformerParams", @@ -58,7 +60,7 @@ class TransformerMetadata(NamedTuple): This class is used to hold meta information for a transformer function. - Properties + Attributes ---------- session The :class:`~shiny.Session` object of the current output value function. @@ -87,19 +89,11 @@ class TransformerParams(Generic[P]): This class is used to isolate the transformer function parameters away from internal implementation parameters used by Shiny. - Properties - ---------- - *args - No positional arguments should be supplied. Only keyword arguments should be - supplied. (`*args` is required when using :class:`~typing.ParamSpec` even if - transformer is only leveraging `**kwargs`.) - **kwargs - Keyword arguments for the corresponding transformer function. """ def __init__(self, *args: P.args, **kwargs: P.kwargs) -> None: """ - Properties + Parameters ---------- *args No positional arguments should be supplied. Only keyword arguments should be @@ -188,20 +182,20 @@ class OutputRenderer(Generic[OT], ABC): as `_meta` and `_fn`. Typing - ----- - `IT` - The type returned by the app-supplied output value function (`value_fn`). This - value should contain a `None` value to conform to the convention of app authors - being able to return `None` to display nothing in the rendered output. Note that - in many cases but not all, `IT` and `OT` will be the same. - `OT` - The type of the object returned by the transform function (`transform_fn`). This - value should contain a `None` value to conform to display nothing in the - rendered output. - `P` - The parameter specification defined by the transform function (`transform_fn`). - It should **not** contain any `*args`. All keyword arguments should have a type - and default value. + ------ + * `IT` + * The type returned by the app-supplied output value function (`value_fn`). This + value should contain a `None` value to conform to the convention of app authors + being able to return `None` to display nothing in the rendered output. Note that + in many cases but not all, `IT` and `OT` will be the same. + * `OT` + * The type of the object returned by the transform function (`transform_fn`). This + value should contain a `None` value to conform to display nothing in the + rendered output. + * `P` + * The parameter specification defined by the transform function (`transform_fn`). + It should **not** contain any `*args`. All keyword arguments should have a type + and default value. See Also @@ -470,7 +464,7 @@ class OutputTransformer(Generic[IT, OT, P]): output value function and any app-supplied paramters and passes them through the component author's transformer function, and returns the transformed result. - Properties + Attributes ---------- ValueFn The function type for the app-supplied output value function. This function may From 2e7fcb827b45f09433de94bd05e4685e40c9b360 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Thu, 10 Aug 2023 11:21:38 -0400 Subject: [PATCH 57/64] doc tweaks --- shiny/api-examples/output_transformer/app.py | 6 ++--- shiny/render/transformer/_transformer.py | 24 +++++++++++++------- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/shiny/api-examples/output_transformer/app.py b/shiny/api-examples/output_transformer/app.py index 1e6dd2a0f..756bf3512 100644 --- a/shiny/api-examples/output_transformer/app.py +++ b/shiny/api-examples/output_transformer/app.py @@ -89,7 +89,7 @@ def render_capitalize( # Lastly, implement the renderer. # Note: `_fn` type is the transformer's `ValueFn` or `None` -# Note: Return type is the transformer's `OutputRenderer | OutputRendererDecorator` +# Note: Return type is the transformer's `OutputRenderer` or `OutputRendererDecorator` def render_capitalize( _fn: CapitalizeTransformer.ValueFn | None = None, *, @@ -127,14 +127,14 @@ def no_parens(): return input.caption() @output - # With parentheses (same as `@render_capitalize()`) + # With parentheses. Equivalent to `@render_capitalize()` @render_capitalize(to="upper") def to_upper(): return input.caption() @output @render_capitalize(to="lower") - # Works with async functions too! + # Works with async output value functions async def to_lower(): return input.caption() diff --git a/shiny/render/transformer/_transformer.py b/shiny/render/transformer/_transformer.py index 7c11bc704..1a1703c97 100644 --- a/shiny/render/transformer/_transformer.py +++ b/shiny/render/transformer/_transformer.py @@ -1,6 +1,9 @@ from __future__ import annotations -# TODO-barret; missing first paragraph from some classes: Example: TransformerMetadata. No init method for TransformerParams +# TODO-future: docs; missing first paragraph from some classes: Example: TransformerMetadata. +# No init method for TransformerParams. This is because the `DocClass` object does not +# display methods that start with `_`. THerefore no `__init__` or `__call__` methods are +# displayed. Even if they have docs. __all__ = ( "TransformerMetadata", @@ -33,7 +36,7 @@ ) if TYPE_CHECKING: - from ...session import Session + from ... import Session from ... import _utils from ..._docstring import add_example @@ -325,6 +328,9 @@ def __init__( ) def __call__(self) -> OT: + """ + Synchronously executes the output renderer as a function. + """ return _utils.run_coro_sync(self._run()) @@ -362,6 +368,9 @@ def __init__( ) async def __call__(self) -> OT: # pyright: ignore[reportIncompatibleMethodOverride] + """ + Asynchronously executes the output renderer as a function. + """ return await self._run() @@ -628,8 +637,7 @@ async def resolve_value_fn(value_fn: ValueFn[IT]) -> IT: function is synchronous, it will be called. While always using an async method within an output transform function is not - appropriate, this method may be used, avoiding the boilerplate of "if is async, then - await value_fn() else cast as synchronous and return value_fn()". + appropriate, this method may be safely used to avoid boilerplate. Replace this: ```python @@ -644,10 +652,10 @@ async def resolve_value_fn(value_fn: ValueFn[IT]) -> IT: x = await resolve_value_fn(_fn) ``` - This 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. + 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 ---------- From 9c532d69bc124e25842d4bc5754d3d2c3cfa7d00 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Thu, 10 Aug 2023 12:17:35 -0400 Subject: [PATCH 58/64] Update app.py --- e2e/server/output_transformer/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/server/output_transformer/app.py b/e2e/server/output_transformer/app.py index 12b7baad8..639ea335d 100644 --- a/e2e/server/output_transformer/app.py +++ b/e2e/server/output_transformer/app.py @@ -5,7 +5,7 @@ from typing import Optional, overload from shiny import App, Inputs, Outputs, Session, ui -from shiny.render._render import ( +from shiny.render.transformer import ( TransformerMetadata, ValueFn, is_async_callable, From eb1d454a7f6ba94a7f78d5829f541200ee18912d Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Thu, 10 Aug 2023 12:18:50 -0400 Subject: [PATCH 59/64] Update test_output_transformer.py --- tests/test_output_transformer.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_output_transformer.py b/tests/test_output_transformer.py index 7a08dac62..786c08755 100644 --- a/tests/test_output_transformer.py +++ b/tests/test_output_transformer.py @@ -1,3 +1,7 @@ +# Needed for types imported only during TYPE_CHECKING with Python 3.7 - 3.9 +# See https://www.python.org/dev/peps/pep-0655/#usage-in-python-3-11 +from __future__ import annotations + import asyncio from typing import Any, overload From cd939a0eed1ee3808108f13b60e381f5fa0077b1 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Thu, 10 Aug 2023 12:36:57 -0400 Subject: [PATCH 60/64] Expose `ValueFn` --- shiny/render/transformer/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/shiny/render/transformer/__init__.py b/shiny/render/transformer/__init__.py index df5c643bf..f28d91f1a 100644 --- a/shiny/render/transformer/__init__.py +++ b/shiny/render/transformer/__init__.py @@ -2,14 +2,14 @@ TransformerMetadata, TransformerParams, OutputRenderer, - OutputTransformer, output_transformer, is_async_callable, resolve_value_fn, - ValueFn, # pyright: ignore[reportUnusedImport] + ValueFn, ValueFnSync, # pyright: ignore[reportUnusedImport] ValueFnAsync, # pyright: ignore[reportUnusedImport] TransformFn, # pyright: ignore[reportUnusedImport] + OutputTransformer, # pyright: ignore[reportUnusedImport] OutputRendererSync, # pyright: ignore[reportUnusedImport] OutputRendererAsync, # pyright: ignore[reportUnusedImport] ) @@ -18,7 +18,7 @@ "TransformerMetadata", "TransformerParams", "OutputRenderer", - "OutputTransformer", + "ValueFn", "output_transformer", "is_async_callable", "resolve_value_fn", From 57afd2667ffa64d510e7c7bda5bc8017c6236335 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Thu, 10 Aug 2023 14:59:09 -0400 Subject: [PATCH 61/64] Remove comment about `from __future__ import annotations` --- e2e/server/output_transformer/app.py | 2 -- shiny/api-examples/output_transformer/app.py | 2 -- shiny/render/_coordmap.py | 3 --- shiny/render/_deprecated.py | 2 -- shiny/render/_render.py | 2 -- shiny/render/_try_render_plot.py | 2 -- tests/test_output_transformer.py | 2 -- 7 files changed, 15 deletions(-) diff --git a/e2e/server/output_transformer/app.py b/e2e/server/output_transformer/app.py index 639ea335d..0c39cb48b 100644 --- a/e2e/server/output_transformer/app.py +++ b/e2e/server/output_transformer/app.py @@ -1,5 +1,3 @@ -# Needed for types imported only during TYPE_CHECKING with Python 3.7 - 3.9 -# See https://www.python.org/dev/peps/pep-0655/#usage-in-python-3-11 from __future__ import annotations from typing import Optional, overload diff --git a/shiny/api-examples/output_transformer/app.py b/shiny/api-examples/output_transformer/app.py index 756bf3512..1c92b0830 100644 --- a/shiny/api-examples/output_transformer/app.py +++ b/shiny/api-examples/output_transformer/app.py @@ -1,5 +1,3 @@ -# Needed for types imported only during TYPE_CHECKING with Python 3.7 - 3.9 -# See https://www.python.org/dev/peps/pep-0655/#usage-in-python-3-11 from __future__ import annotations from typing import Literal, overload diff --git a/shiny/render/_coordmap.py b/shiny/render/_coordmap.py index 2369012c5..05b74400b 100644 --- a/shiny/render/_coordmap.py +++ b/shiny/render/_coordmap.py @@ -1,7 +1,4 @@ # pyright: reportUnknownMemberType=false - -# Needed for types imported only during TYPE_CHECKING with Python 3.7 - 3.9 -# See https://www.python.org/dev/peps/pep-0655/#usage-in-python-3-11 from __future__ import annotations import re diff --git a/shiny/render/_deprecated.py b/shiny/render/_deprecated.py index 935130319..093596bca 100644 --- a/shiny/render/_deprecated.py +++ b/shiny/render/_deprecated.py @@ -1,5 +1,3 @@ -# Needed for types imported only during TYPE_CHECKING with Python 3.7 - 3.9 -# See https://www.python.org/dev/peps/pep-0655/#usage-in-python-3-11 from __future__ import annotations from abc import ABC, abstractmethod diff --git a/shiny/render/_render.py b/shiny/render/_render.py index d3f0c634d..1d3b4ec3b 100644 --- a/shiny/render/_render.py +++ b/shiny/render/_render.py @@ -1,5 +1,3 @@ -# Needed for types imported only during TYPE_CHECKING with Python 3.7 - 3.9 -# See https://www.python.org/dev/peps/pep-0655/#usage-in-python-3-11 from __future__ import annotations __all__ = ( diff --git a/shiny/render/_try_render_plot.py b/shiny/render/_try_render_plot.py index 3c9b6723e..ea214cdeb 100644 --- a/shiny/render/_try_render_plot.py +++ b/shiny/render/_try_render_plot.py @@ -1,5 +1,3 @@ -# Needed for types imported only during TYPE_CHECKING with Python 3.7 - 3.9 -# See https://www.python.org/dev/peps/pep-0655/#usage-in-python-3-11 from __future__ import annotations import base64 diff --git a/tests/test_output_transformer.py b/tests/test_output_transformer.py index 786c08755..9f68988e0 100644 --- a/tests/test_output_transformer.py +++ b/tests/test_output_transformer.py @@ -1,5 +1,3 @@ -# Needed for types imported only during TYPE_CHECKING with Python 3.7 - 3.9 -# See https://www.python.org/dev/peps/pep-0655/#usage-in-python-3-11 from __future__ import annotations import asyncio From 4f4ee56ca249a36a4a06f3da39133c062ff4f00f Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Thu, 10 Aug 2023 15:03:16 -0400 Subject: [PATCH 62/64] spelling --- tests/test_output_transformer.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_output_transformer.py b/tests/test_output_transformer.py index 9f68988e0..e009b9f0d 100644 --- a/tests/test_output_transformer.py +++ b/tests/test_output_transformer.py @@ -194,7 +194,7 @@ def render_fn_sync(*args: str): assert "Expected `params` to be of type `TransformerParams`" in str(e) -# "Currently, `ValueFn` can not be truely async and "support sync render methods" +# "Currently, `ValueFn` can not be truly async and "support sync render methods" @pytest.mark.asyncio async def test_renderer_handler_fn_can_be_async(): @output_transformer @@ -202,7 +202,7 @@ async def AsyncTransformer( _meta: TransformerMetadata, _fn: ValueFn[str], ) -> str: - # Actually sleep to test that the handler is truely async + # Actually sleep to test that the handler is truly async await asyncio.sleep(0.1) ret = await resolve_value_fn(_fn) return ret @@ -267,7 +267,7 @@ async def async_app_render_fn() -> str: assert ret == async_test_val -# "Currently, `ValueFnA` can not be truely async and "support sync render methods". +# "Currently, `ValueFnA` can not be truly async and "support sync render methods". # Test that conditionally calling async works. @pytest.mark.asyncio async def test_renderer_handler_fn_can_be_yield_while_async(): @@ -277,7 +277,7 @@ async def YieldTransformer( _fn: ValueFn[str], ) -> str: if is_async_callable(_fn): - # Actually sleep to test that the handler is truely async + # Actually sleep to test that the handler is truly async await asyncio.sleep(0.1) ret = await resolve_value_fn(_fn) return ret From 88a75c0481b30d6880ba02e2a0d6f9a92e274770 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Thu, 10 Aug 2023 15:04:47 -0400 Subject: [PATCH 63/64] Speed up tests --- tests/test_output_transformer.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_output_transformer.py b/tests/test_output_transformer.py index e009b9f0d..04506b8e8 100644 --- a/tests/test_output_transformer.py +++ b/tests/test_output_transformer.py @@ -203,7 +203,7 @@ async def AsyncTransformer( _fn: ValueFn[str], ) -> str: # Actually sleep to test that the handler is truly async - await asyncio.sleep(0.1) + await asyncio.sleep(0) ret = await resolve_value_fn(_fn) return ret @@ -252,7 +252,7 @@ def app_render_fn() -> str: async_test_val = "Async: Hello World!" async def async_app_render_fn() -> str: - await asyncio.sleep(0.1) + await asyncio.sleep(0) return async_test_val renderer_async = async_renderer(async_app_render_fn) @@ -278,7 +278,7 @@ async def YieldTransformer( ) -> str: if is_async_callable(_fn): # Actually sleep to test that the handler is truly async - await asyncio.sleep(0.1) + await asyncio.sleep(0) ret = await resolve_value_fn(_fn) return ret @@ -322,7 +322,7 @@ def app_render_fn() -> str: async_test_val = "Async: Hello World!" async def async_app_render_fn() -> str: - await asyncio.sleep(0.1) + await asyncio.sleep(0) return async_test_val renderer_async = yield_renderer(async_app_render_fn) From f9486c57cc72fc49fe356d68b2cecee63ad3f9a3 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Thu, 10 Aug 2023 15:05:19 -0400 Subject: [PATCH 64/64] Apply suggestions from code review Co-authored-by: Winston Chang --- shiny/render/transformer/_transformer.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/shiny/render/transformer/_transformer.py b/shiny/render/transformer/_transformer.py index 1a1703c97..f206a8221 100644 --- a/shiny/render/transformer/_transformer.py +++ b/shiny/render/transformer/_transformer.py @@ -247,7 +247,6 @@ def __init__( + " requires an async tranformer function (`transform_fn`)" ) - self._is_async = is_async_callable(value_fn) self._value_fn = value_fn self._transformer = transform_fn self._params = params @@ -644,7 +643,7 @@ async def resolve_value_fn(value_fn: ValueFn[IT]) -> IT: if is_async_callable(_fn): x = await _fn() else: - x = _fn() + x = cast(ValueFnSync[IT], _fn)() ``` With this: