diff --git a/docs/_quartodoc.yml b/docs/_quartodoc.yml index 921a453f9..99bd5290c 100644 --- a/docs/_quartodoc.yml +++ b/docs/_quartodoc.yml @@ -177,10 +177,10 @@ quartodoc: desc: "" contents: - render.renderer.Renderer - - render.renderer.RendererBase - render.renderer.Jsonifiable - render.renderer.ValueFn - render.renderer.AsyncValueFn + - render.renderer.RendererT - title: Reactive programming desc: "" contents: diff --git a/shiny/__init__.py b/shiny/__init__.py index 4b00b42f2..a0ed1ee9f 100644 --- a/shiny/__init__.py +++ b/shiny/__init__.py @@ -1,6 +1,6 @@ """A package for building reactive web applications.""" -__version__ = "0.6.1.9005" +__version__ = "0.6.1.9006" from ._shinyenv import is_pyodide as _is_pyodide diff --git a/shiny/express/_output.py b/shiny/express/_output.py index 7fce0d9f0..5f64f459f 100644 --- a/shiny/express/_output.py +++ b/shiny/express/_output.py @@ -3,23 +3,20 @@ import contextlib import sys from contextlib import AbstractContextManager -from typing import Callable, Generator, TypeVar, overload +from typing import Callable, Generator, TypeVar, cast, overload from .. import ui -from .._typing_extensions import ParamSpec -from ..render.renderer import RendererBase, RendererBaseT +from ..render.renderer import Renderer, RendererT __all__ = ("suspend_display",) -P = ParamSpec("P") -R = TypeVar("R") CallableT = TypeVar("CallableT", bound=Callable[..., object]) # TODO-barret-future; quartodoc entry? def output_args( **kwargs: object, -) -> Callable[[RendererBaseT], RendererBaseT]: +) -> Callable[[RendererT], RendererT]: """ Sets default UI arguments for a Shiny rendering function. @@ -41,7 +38,7 @@ def output_args( A decorator that sets the default UI arguments for a Shiny rendering function. """ - def wrapper(renderer: RendererBaseT) -> RendererBaseT: + def wrapper(renderer: RendererT) -> RendererT: renderer._auto_output_ui_kwargs = kwargs return renderer @@ -49,12 +46,12 @@ def wrapper(renderer: RendererBaseT) -> RendererBaseT: @overload -def suspend_display(fn: CallableT) -> CallableT: +def suspend_display(fn: RendererT) -> RendererT: ... @overload -def suspend_display(fn: RendererBaseT) -> RendererBaseT: +def suspend_display(fn: CallableT) -> CallableT: ... @@ -64,8 +61,8 @@ def suspend_display() -> AbstractContextManager[None]: def suspend_display( - fn: Callable[P, R] | RendererBaseT | None = None -) -> Callable[P, R] | RendererBaseT | AbstractContextManager[None]: + fn: RendererT | CallableT | None = None, +) -> RendererT | CallableT | AbstractContextManager[None]: """Suppresses the display of UI elements in various ways. If used as a context manager (`with suspend_display():`), it suppresses the display @@ -99,12 +96,12 @@ def suspend_display( if fn is None: return suspend_display_ctxmgr() - # Special case for RendererBase; when we decorate those, we just mean "don't + # Special case for Renderer; when we decorate those, we just mean "don't # display yourself" - if isinstance(fn, RendererBase): + if isinstance(fn, Renderer): # By setting the class value, the `self` arg will be auto added. fn.auto_output_ui = null_ui - return fn + return cast(RendererT, fn) return suspend_display_ctxmgr()(fn) diff --git a/shiny/reactive/_reactives.py b/shiny/reactive/_reactives.py index 59b9c7e51..bf5dd3644 100644 --- a/shiny/reactive/_reactives.py +++ b/shiny/reactive/_reactives.py @@ -480,9 +480,9 @@ def __init__( self.__name__ = fn.__name__ self.__doc__ = fn.__doc__ - from ..render.renderer import RendererBase + from ..render.renderer import Renderer - if isinstance(fn, RendererBase): + if isinstance(fn, Renderer): raise TypeError( "`@reactive.effect` can not be combined with `@render.xx`.\n" + "Please remove your call of `@reactive.effect`." @@ -819,9 +819,9 @@ def decorator(user_fn: Callable[[], T]) -> Callable[[], T]: # This is here instead of at the top of the .py file in order to avoid a # circular dependency. - from ..render.renderer import RendererBase + from ..render.renderer import Renderer - if isinstance(user_fn, RendererBase): + 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/_deprecated.py b/shiny/render/_deprecated.py index 01b957e49..3328be688 100644 --- a/shiny/render/_deprecated.py +++ b/shiny/render/_deprecated.py @@ -1,19 +1,9 @@ from __future__ import annotations -from abc import ABC, abstractmethod from typing import Generic -from .transformer._transformer import ( - IT, - OT, - OutputRendererAsync, - OutputRendererSync, - TransformerMetadata, - ValueFn, - ValueFnAsync, - ValueFnSync, - empty_params, -) +from .._deprecated import ShinyDeprecationWarning +from .transformer._transformer import IT, OT, ValueFnAsync, ValueFnSync # ====================================================================================== # Deprecated classes @@ -24,56 +14,32 @@ # 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): +class RenderFunction(Generic[IT, OT]): """ - Deprecated. Please use :func:`~shiny.render.renderer_components` instead. + Deprecated. Please use :class:`~shiny.render.renderer.Renderer` class instead. """ - @abstractmethod - def __call__(self) -> OT: - ... - - @abstractmethod - async def run(self) -> OT: - ... - def __init__(self, fn: ValueFnSync[IT]) -> None: - async def transformer(_meta: TransformerMetadata, _fn: ValueFn[IT]) -> OT: - ret = await self.run() - return ret - - super().__init__( - value_fn=fn, - transform_fn=transformer, - params=empty_params(), + raise ShinyDeprecationWarning( + "Class `" + + str(self.__class__.__name__) + + "` inherits from the deprecated class `shiny.render.RenderFunction`. " + "Please update your renderer to use `shiny.render.renderer.Renderer` instead." ) - 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): +class RenderFunctionAsync(Generic[IT, OT]): """ - Deprecated. Please use :func:`~shiny.render.renderer_components` instead. + Deprecated. Please use :class:`~shiny.render.renderer.Renderer` class instead. """ - @abstractmethod - async def __call__(self) -> OT: # pyright: ignore[reportIncompatibleMethodOverride] - ... - - @abstractmethod - async def run(self) -> OT: - ... - def __init__(self, fn: ValueFnAsync[IT]) -> None: - async def transformer(_meta: TransformerMetadata, _fn: ValueFn[IT]) -> OT: - ret = await self.run() - return ret - - super().__init__( - value_fn=fn, - transform_fn=transformer, - params=empty_params(), + raise ShinyDeprecationWarning( + "Class `" + + str(self.__class__.__name__) + + "` inherits from the deprecated class `shiny.render.RenderFunctionAsync`. " + "Please update your renderer to use `shiny.render.renderer.Renderer` instead." ) - self._fn = fn diff --git a/shiny/render/renderer/__init__.py b/shiny/render/renderer/__init__.py index f61367f39..057a08725 100644 --- a/shiny/render/renderer/__init__.py +++ b/shiny/render/renderer/__init__.py @@ -1,18 +1,16 @@ from ._renderer import ( # noqa: F401 - RendererBase, Renderer, ValueFn, Jsonifiable, - RendererBaseT, + RendererT, AsyncValueFn, # IT, # pyright: ignore[reportUnusedImport] ) __all__ = ( - "RendererBase", "Renderer", "ValueFn", "Jsonifiable", "AsyncValueFn", - "RendererBaseT", + "RendererT", ) diff --git a/shiny/render/renderer/_renderer.py b/shiny/render/renderer/_renderer.py index 2e3b93ec4..b311aa225 100644 --- a/shiny/render/renderer/_renderer.py +++ b/shiny/render/renderer/_renderer.py @@ -1,6 +1,5 @@ from __future__ import annotations -from abc import ABC, abstractmethod from typing import ( Any, Awaitable, @@ -31,18 +30,17 @@ __all__ = ( "Renderer", - "RendererBase", "Jsonifiable", "ValueFn", "AsyncValueFn", - "RendererBaseT", + "RendererT", ) -RendererBaseT = TypeVar("RendererBaseT", bound="RendererBase") +RendererT = TypeVar("RendererT", bound="Renderer[Any]") """ Generic class to pass the Renderer class through a decorator. -When accepting and returning a `RendererBase` class, utilize this TypeVar as to not reduce the variable type to `RendererBase` +When accepting and returning a `Renderer` class, utilize this TypeVar as to not reduce the variable type to `Renderer[Any]` """ # Input type for the user-spplied function that is passed to a render.xx @@ -96,9 +94,9 @@ """ -class RendererBase(ABC): +class Renderer(Generic[IT]): """ - Base class for all renderers. + Output renderer class TODO-barret-docs """ @@ -112,10 +110,9 @@ class RendererBase(ABC): """ Name of output function supplied. (The value will not contain any module prefix.) - Set within `Renderer.__call__()` method. + Set within `.__call__()` method. """ - # Meta output_id: str """ Output function name or ID (provided to `@output(id=)`). @@ -128,6 +125,41 @@ class RendererBase(ABC): output renderer is registered within the session. """ + fn: AsyncValueFn[IT] + """ + App-supplied output value function which returns type `IT`. This function is always + asyncronous as the original app-supplied function possibly wrapped to execute + asynchonously. + """ + + def __call__(self, _fn: ValueFn[IT]) -> Self: + """ + Renderer __call__ docs here; Sets app's value function + + TODO-barret-docs + """ + + if not callable(_fn): + raise TypeError("Value function must be callable") + + # Set value function with extra meta information + self.fn = AsyncValueFn(_fn) + + # Copy over function name as it is consistent with how Session and Output + # retrieve function names + self.__name__ = _fn.__name__ + + # Set the value of `output_id` to the function name. + # This helps with testing and other situations where no session is present + # for auto-registration to occur. + self.output_id = self.__name__ + + # Allow for App authors to not require `@output` + self._auto_register() + + # Return self for possible chaining of methods! + return self + def _set_output_metadata( self, *, @@ -151,14 +183,50 @@ def auto_output_ui( ) -> DefaultUIFnResultOrNone: return None - @abstractmethod - async def render(self) -> Jsonifiable: - ... - - def __init__(self) -> None: + def __init__( + self, + _fn: Optional[ValueFn[IT]] = None, + ) -> None: + # Do not display docs here. If docs are present, it could highjack the docs of + # the subclass's `__init__` method. + # """ + # Renderer - init docs here + # """ super().__init__() + self._auto_registered: bool = False + # Must be done last + if callable(_fn): + # Register the value function + self(_fn) + + async def transform(self, value: IT) -> Jsonifiable: + """ + Renderer - transform docs here + + TODO-barret-docs + """ + raise NotImplementedError( + "Please implement either the `transform(self, value: IT)` or `render(self)` method.\n" + "* `transform(self, value: IT)` should transform the `value` (of type `IT`) into Jsonifiable object. Ex: `dict`, `None`, `str`. (standard)\n" + "* `render(self)` method has full control of how an App author's value is retrieved (`self._fn()`) and utilized. (rare)\n" + "By default, the `render` retrieves the value and then calls `transform` method on non-`None` values." + ) + + async def render(self) -> Jsonifiable: + """ + Renderer - render docs here + + TODO-barret-docs + """ + value = await self.fn() + if value is None: + return None + + rendered = await self.transform(value) + return rendered + # ###### # Tagify-like methods # ###### @@ -284,86 +352,3 @@ def get_sync_fn(self) -> Callable[[], IT | None]: ) sync_fn = cast(Callable[[], IT], self._orig_fn) return sync_fn - - -class Renderer(RendererBase, Generic[IT]): - """ - Renderer cls docs here - - TODO-barret-docs - """ - - fn: AsyncValueFn[IT] - """ - App-supplied output value function which returns type `IT`. This function is always - asyncronous as the original app-supplied function possibly wrapped to execute - asynchonously. - """ - - def __call__(self, _fn: ValueFn[IT]) -> Self: - """ - Renderer __call__ docs here; Sets app's value function - - TODO-barret-docs - """ - - if not callable(_fn): - raise TypeError("Value function must be callable") - - # Set value function with extra meta information - self.fn = AsyncValueFn(_fn) - - # Copy over function name as it is consistent with how Session and Output - # retrieve function names - self.__name__ = _fn.__name__ - - # Set the value of `output_id` to the function name. - # This helps with testing and other situations where no session is present - # for auto-registration to occur. - self.output_id = self.__name__ - - # Allow for App authors to not require `@output` - self._auto_register() - - # Return self for possible chaining of methods! - return self - - def __init__( - self, - _fn: Optional[ValueFn[IT]] = None, - ): - # Do not display docs here. If docs are present, it could highjack the docs of - # the subclass's `__init__` method. - # """ - # Renderer - init docs here - # """ - super().__init__() - if callable(_fn): - # Register the value function - self(_fn) - - async def transform(self, value: IT) -> Jsonifiable: - """ - Renderer - transform docs here - - TODO-barret-docs - """ - raise NotImplementedError( - "Please implement either the `transform(self, value: IT)` or `render(self)` method.\n" - "* `transform(self, value: IT)` should transform the `value` (of type `IT`) into Jsonifiable object. Ex: `dict`, `None`, `str`. (standard)\n" - "* `render(self)` method has full control of how an App author's value is retrieved (`self._fn()`) and utilized. (rare)\n" - "By default, the `render` retrieves the value and then calls `transform` method on non-`None` values." - ) - - async def render(self) -> Jsonifiable: - """ - Renderer - render docs here - - TODO-barret-docs - """ - value = await self.fn() - if value is None: - return None - - rendered = await self.transform(value) - return rendered diff --git a/shiny/render/transformer/_transformer.py b/shiny/render/transformer/_transformer.py index 0f2d41364..5c95a74c7 100644 --- a/shiny/render/transformer/_transformer.py +++ b/shiny/render/transformer/_transformer.py @@ -17,9 +17,9 @@ ) import inspect -from abc import ABC, abstractmethod from typing import ( TYPE_CHECKING, + Any, Awaitable, Callable, Generic, @@ -32,7 +32,7 @@ overload, ) -from ..renderer import Jsonifiable, RendererBase +from ..renderer import Jsonifiable, Renderer from ..renderer._renderer import DefaultUIFn, DefaultUIFnResultOrNone if TYPE_CHECKING: @@ -41,12 +41,12 @@ from ..._deprecated import warn_deprecated from ..._docstring import add_example from ..._typing_extensions import Concatenate, ParamSpec -from ..._utils import is_async_callable, run_coro_sync +from ..._utils import is_async_callable from ...types import MISSING # Input type for the user-spplied function that is passed to a render.xx IT = TypeVar("IT") -# Output type after the Renderer.__call__ method is called on the IT object. +# Output type after the OutputRenderer._exec method has been called on the IT object. OT = TypeVar("OT") # Param specification for value_fn function P = ParamSpec("P") @@ -165,15 +165,15 @@ def inner(*args: P.args, **kwargs: P.kwargs) -> TransformerParams[P]: # type `OT`. TransformFn = Callable[Concatenate[TransformerMetadata, ValueFn[IT], P], Awaitable[OT]] """ -Package author function that transforms an object of type `IT` into type `OT`. It should -be defined as an asynchronous function but should only asynchronously yield when the -second parameter (of type `ValueFn[IT]`) is awaitable. If the second function argument -is not awaitable (a _synchronous_ function), then the execution of the transform -function should also be synchronous. +Package author function that transforms an object of type `IT` into type `OT`. """ -class OutputRenderer(RendererBase, ABC, Generic[OT]): +class OutputRenderer( + # Inherit from `[Any]` to not change the type signature of `OutputRenderer[OT]` to `OuputRenderer[IT, OT]`. + Renderer[Any], + Generic[OT], +): """ Output Renderer @@ -181,12 +181,8 @@ class OutputRenderer(RendererBase, ABC, Generic[OT]): (`value_fn`) into type (`OT`). This transformed value is then sent to be an :class:`~shiny.Outputs` output value. - When the `.__call__` method is invoked, the transform function (`transform_fn`) - (typically defined by package authors) is invoked. The wrapping classes - (:class:`~shiny.render.transformer.OutputRendererSync` and - :class:`~shiny.render.transformer.OutputRendererAsync`) will enforce whether the - transform function is synchronous or asynchronous independent of the awaitable - syntax. + When the `._run` method is invoked, the transform function (`transform_fn`) + (typically defined by package authors) is invoked. The transform function (`transform_fn`) is given `meta` information (:class:`~shiny.render.transformer.TranformerMetadata`), the (app-supplied) value @@ -216,13 +212,6 @@ class OutputRenderer(RendererBase, ABC, Generic[OT]): * :class:`~shiny.render.transformer.OutputRendererAsync` """ - @abstractmethod - def __call__(self) -> OT: - """ - Executes the output renderer (both the app's output value function and transformer). - """ - ... - def __init__( self, *, @@ -239,10 +228,7 @@ def __init__( App-provided output value function. It should return an object of type `IT`. transform_fn Package author function that transforms an object of type `IT` into type - `OT`. The `params` will used as variadic keyword arguments. This method - should only use `await` syntax when the value function (`ValueFn[IT]`) is - awaitable. If the value function is not awaitable (a _synchronous_ - function), then the function should execute synchronously. + `OT`. The `params` will used as variadic keyword arguments. params App-provided parameters for the transform function (`transform_fn`). default_ui @@ -264,14 +250,6 @@ def __init__( " Ex `async def my_transformer(....`" ) - # # Upgrade value function to be async; - # # Calling an async function has a ~35ns overhead (barret's machine) - # # Checking if a function is async has a 180+ns overhead (barret's machine) - # # -> It is faster to always call an async function than to always check if it is async - # # Always being async simplifies the execution - # # Not used - # self._fn = AsyncValueFn(value_fn) - self._value_fn = value_fn self._value_fn_is_async = is_async_callable(value_fn) # legacy key # Copy over function name as it is consistent with how Session and Output @@ -363,9 +341,6 @@ class OutputRendererSync(OutputRenderer[OT]): """ Output Renderer (Synchronous) - This class is used to define a synchronous renderer. The `.__call__` method is - implemented to call the `._run` method synchronously. - See Also -------- * :class:`~shiny.render.transformer.OutputRenderer` @@ -393,23 +368,14 @@ def __init__( default_ui_passthrough_args=default_ui_passthrough_args, ) - def __call__(self) -> OT: - """ - Synchronously executes the output renderer as a function. - """ - return run_coro_sync(self._run()) - -# The reason for having a separate RendererAsync class is because the __call__ +# The reason for having a separate RendererAsync class is because the _run # method is marked here as async; you can't have a single class where one method could # be either sync or async. class OutputRendererAsync(OutputRenderer[OT]): """ Output Renderer (Asynchronous) - This class is used to define an asynchronous renderer. The `.__call__` method is - implemented to call the `._run` method asynchronously. - See Also -------- * :class:`~shiny.render.transformer.OutputRenderer` @@ -438,12 +404,6 @@ def __init__( default_ui_passthrough_args=default_ui_passthrough_args, ) - async def __call__(self) -> OT: # pyright: ignore[reportIncompatibleMethodOverride] - """ - Asynchronously executes the output renderer as a function. - """ - return await self._run() - # ====================================================================================== # Restrict the transformer function @@ -776,11 +736,6 @@ async def resolve_value_fn(value_fn: ValueFn[IT]) -> IT: x = await resolve_value_fn(_fn) ``` - This code substitution is safe as the implementation does not _actually_ - asynchronously yield to another process if the `value_fn` is synchronous. The - `__call__` method of the :class:`~shiny.render.transformer.OutputRendererSync` is - built to execute asynchronously defined methods that execute synchronously. - Parameters ---------- value_fn diff --git a/shiny/session/_session.py b/shiny/session/_session.py index c8791f1c2..b2c8be525 100644 --- a/shiny/session/_session.py +++ b/shiny/session/_session.py @@ -49,7 +49,7 @@ from ..input_handler import input_handlers from ..reactive import Effect_, Value, effect, flush, isolate from ..reactive._core import lock, on_flushed -from ..render.renderer import Jsonifiable, RendererBase, RendererBaseT +from ..render.renderer import Jsonifiable, Renderer, RendererT from ..types import SafeException, SilentCancelOutputException, SilentException from ._utils import RenderedDeps, read_thunk_opt, session_context @@ -973,7 +973,7 @@ def __init__( self._suspend_when_hidden = suspend_when_hidden @overload - def __call__(self, renderer: RendererBaseT) -> RendererBaseT: + def __call__(self, renderer: RendererT) -> RendererT: ... @overload @@ -983,19 +983,19 @@ def __call__( id: Optional[str] = None, suspend_when_hidden: bool = True, priority: int = 0, - ) -> Callable[[RendererBaseT], RendererBaseT]: + ) -> Callable[[RendererT], RendererT]: ... def __call__( self, - renderer: Optional[RendererBaseT] = None, + renderer: Optional[RendererT] = None, *, id: Optional[str] = None, suspend_when_hidden: bool = True, priority: int = 0, - ) -> RendererBaseT | Callable[[RendererBaseT], RendererBaseT]: - def set_renderer(renderer: RendererBaseT) -> RendererBaseT: - if not isinstance(renderer, RendererBase): + ) -> RendererT | Callable[[RendererT], RendererT]: + def set_renderer(renderer: RendererT) -> RendererT: + 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`." diff --git a/tests/pytest/test_output_transformer.py b/tests/pytest/test_output_transformer.py index 40d75de79..c52b37b82 100644 --- a/tests/pytest/test_output_transformer.py +++ b/tests/pytest/test_output_transformer.py @@ -251,10 +251,10 @@ def renderer_sync() -> str: return test_val # All renderers are async in execution. - assert not is_async_callable(renderer_sync) + assert not is_async_callable(renderer_sync._value_fn) with session_context(test_session): - val = renderer_sync() + val = await renderer_sync._run() assert val == test_val # ## Test Async: √ ============================================= @@ -266,11 +266,11 @@ async def renderer_async() -> str: await asyncio.sleep(0) return async_test_val - if not is_async_callable(renderer_async): + if not is_async_callable(renderer_async._value_fn): raise RuntimeError("Expected `renderer_async` to be a coro function") with session_context(test_session): - ret = await renderer_async() + ret = await renderer_async._run() assert ret == async_test_val diff --git a/tests/pytest/test_utils.py b/tests/pytest/test_utils.py index 122b306aa..e4a69d03b 100644 --- a/tests/pytest/test_utils.py +++ b/tests/pytest/test_utils.py @@ -165,7 +165,7 @@ def test_random_port(): # Port `port + j` is busy, # Shift the test range and try again port += j + 1 - print(port) + print("Trying port: ", port) # If no port is available, throw an error # `attempts` should be << n if attempts == n: @@ -191,6 +191,19 @@ def test_random_port_unusable(): def test_random_port_starvation(): - with socketserver.TCPServer(("127.0.0.1", 9000), socketserver.BaseRequestHandler): - with pytest.raises(RuntimeError, match="Failed to find a usable random port"): - random_port(9000, 9000) + port = 9000 + for _ in range(100): + try: + with socketserver.TCPServer( + ("127.0.0.1", port), + socketserver.BaseRequestHandler, + ): + with pytest.raises( + RuntimeError, match="Failed to find a usable random port" + ): + random_port(port, port) + except OSError as e: + print(e) + # Port is busy, bump the port number + port += 1 + print("Trying port: ", port)