diff --git a/shiny/_app.py b/shiny/_app.py index fb8dd2f50..3c960aca0 100644 --- a/shiny/_app.py +++ b/shiny/_app.py @@ -32,7 +32,7 @@ from ._utils import guess_mime_type, is_async_callable, sort_keys_length from .html_dependencies import jquery_deps, require_deps, shiny_deps from .http_staticfiles import FileResponse, StaticFiles -from .session._session import Inputs, Outputs, Session, session_context +from .session._session import AppSession, Inputs, Outputs, Session, session_context T = TypeVar("T") @@ -165,9 +165,9 @@ def __init__( static_assets_map = sort_keys_length(static_assets_map, descending=True) self._static_assets: dict[str, Path] = static_assets_map - self._sessions: dict[str, Session] = {} + self._sessions: dict[str, AppSession] = {} - self._sessions_needing_flush: dict[int, Session] = {} + self._sessions_needing_flush: dict[int, AppSession] = {} self._registered_dependencies: dict[str, HTMLDependency] = {} self._dependency_handler = starlette.routing.Router() @@ -243,14 +243,14 @@ async def _lifespan(self, app: starlette.applications.Starlette): async with self._exit_stack: yield - def _create_session(self, conn: Connection) -> Session: + def _create_session(self, conn: Connection) -> AppSession: id = secrets.token_hex(32) - session = Session(self, id, conn, debug=self._debug) + session = AppSession(self, id, conn, debug=self._debug) self._sessions[id] = session return session - def _remove_session(self, session: Session | str) -> None: - if isinstance(session, Session): + def _remove_session(self, session: AppSession | str) -> None: + if isinstance(session, AppSession): session = session.id if self._debug: @@ -379,7 +379,7 @@ async def _on_session_request_cb(self, request: Request) -> ASGIApp: subpath: str = request.path_params["subpath"] # type: ignore if session_id in self._sessions: - session: Session = self._sessions[session_id] + session: AppSession = self._sessions[session_id] with session_context(session): return await session._handle_request(request, action, subpath) @@ -388,7 +388,7 @@ async def _on_session_request_cb(self, request: Request) -> ASGIApp: # ========================================================================== # Flush # ========================================================================== - def _request_flush(self, session: Session) -> None: + def _request_flush(self, session: AppSession) -> None: # TODO: Until we have reactive domains, because we can't yet keep track # of which sessions need a flush. pass diff --git a/shiny/_typing_extensions.py b/shiny/_typing_extensions.py index 579835605..f5c7742a7 100644 --- a/shiny/_typing_extensions.py +++ b/shiny/_typing_extensions.py @@ -7,6 +7,7 @@ "Concatenate", "ParamSpec", "TypeGuard", + "Never", "NotRequired", "Self", "TypedDict", @@ -30,9 +31,9 @@ # they should both come from the same typing module. # https://peps.python.org/pep-0655/#usage-in-python-3-11 if sys.version_info >= (3, 11): - from typing import NotRequired, Self, TypedDict, assert_type + from typing import Never, NotRequired, Self, TypedDict, assert_type else: - from typing_extensions import NotRequired, Self, TypedDict, assert_type + from typing_extensions import Never, NotRequired, Self, TypedDict, assert_type # The only purpose of the following line is so that pyright will put all of the diff --git a/shiny/express/_mock_session.py b/shiny/express/_mock_session.py deleted file mode 100644 index 4167ea78f..000000000 --- a/shiny/express/_mock_session.py +++ /dev/null @@ -1,55 +0,0 @@ -from __future__ import annotations - -import textwrap -from typing import TYPE_CHECKING, Awaitable, Callable, cast - -from .._namespaces import Id, ResolvedId, Root -from ..session import Inputs, Outputs, Session - -if TYPE_CHECKING: - from ._run import AppOpts - -all = ("ExpressMockSession",) - - -class ExpressMockSession: - """ - A very bare-bones mock session class that is used only in shiny.express's UI - rendering phase. - - Note that this class is also used to hold application-level options that are set via - the `app_opts()` function. - """ - - def __init__(self, ns: ResolvedId = Root): - self.ns = ns - self.input = Inputs({}) - self.output = Outputs(cast(Session, self), self.ns, outputs={}) - - # Application-level (not session-level) options that may be set via app_opts(). - self.app_opts: AppOpts = {} - - # This is needed so that Outputs don't throw an error. - def _is_hidden(self, name: str) -> bool: - return False - - def on_ended( - self, - fn: Callable[[], None] | Callable[[], Awaitable[None]], - ) -> Callable[[], None]: - return lambda: None - - def make_scope(self, id: Id) -> Session: - ns = self.ns(id) - return cast(Session, ExpressMockSession(ns)) - - def __getattr__(self, name: str): - raise AttributeError( - textwrap.dedent( - f""" - The session attribute `{name}` is not yet available for use. Since this code - will run again when the session is initialized, you can use `if session:` to - only run this code when the session is established. - """ - ) - ) diff --git a/shiny/express/_run.py b/shiny/express/_run.py index aae70a47d..f351533a3 100644 --- a/shiny/express/_run.py +++ b/shiny/express/_run.py @@ -14,8 +14,8 @@ from ..session import Inputs, Outputs, Session, get_current_session, session_context from ..types import MISSING, MISSING_TYPE from ._is_express import find_magic_comment_mode -from ._mock_session import ExpressMockSession from ._recall_context import RecallContextManager +from ._stub_session import ExpressStubSession from .expressify_decorator._func_displayhook import _expressify_decorator_function_def from .expressify_decorator._node_transformers import ( DisplayFuncsTransformer, @@ -49,8 +49,8 @@ def wrap_express_app(file: Path) -> App: with session_context(None): import_module_from_path("globals", globals_file) - mock_session = ExpressMockSession() - with session_context(cast(Session, mock_session)): + stub_session = ExpressStubSession() + with session_context(stub_session): # We tagify here, instead of waiting for the App object to do it when it wraps # the UI in a HTMLDocument and calls render() on it. This is because # AttributeErrors can be thrown during the tagification process, and we need to @@ -79,7 +79,7 @@ def express_server(input: Inputs, output: Outputs, session: Session): if www_dir.is_dir(): app_opts["static_assets"] = {"/": www_dir} - app_opts = _merge_app_opts(app_opts, mock_session.app_opts) + app_opts = _merge_app_opts(app_opts, stub_session.app_opts) app_opts = _normalize_app_opts(app_opts, file.parent) app = App( @@ -231,9 +231,9 @@ def app_opts( Whether to enable debug mode. """ - mock_session = get_current_session() + stub_session = get_current_session() - if mock_session is None: + if stub_session is None: # We can get here if a Shiny Core app, or if we're in the UI rendering phase of # a Quarto-Shiny dashboard. raise RuntimeError( @@ -241,7 +241,7 @@ def app_opts( ) # Store these options only if we're in the UI-rendering phase of Shiny Express. - if not isinstance(mock_session, ExpressMockSession): + if not isinstance(stub_session, ExpressStubSession): return if not isinstance(static_assets, MISSING_TYPE): @@ -251,10 +251,10 @@ def app_opts( # Convert string values to Paths. (Need new var name to help type checker.) static_assets_paths = {k: Path(v) for k, v in static_assets.items()} - mock_session.app_opts["static_assets"] = static_assets_paths + stub_session.app_opts["static_assets"] = static_assets_paths if not isinstance(debug, MISSING_TYPE): - mock_session.app_opts["debug"] = debug + stub_session.app_opts["debug"] = debug def _merge_app_opts(app_opts: AppOpts, app_opts_new: AppOpts) -> AppOpts: diff --git a/shiny/express/_stub_session.py b/shiny/express/_stub_session.py new file mode 100644 index 000000000..19460cc10 --- /dev/null +++ b/shiny/express/_stub_session.py @@ -0,0 +1,160 @@ +from __future__ import annotations + +import textwrap +from typing import TYPE_CHECKING, Awaitable, Callable, Literal, Optional + +from htmltools import TagChild + +from .._namespaces import Id, ResolvedId, Root +from ..session import Inputs, Outputs, Session +from ..session._session import SessionProxy + +if TYPE_CHECKING: + from .._app import App + from .._typing_extensions import Never + from ..session._session import DownloadHandler, DynamicRouteHandler, RenderedDeps + from ..types import Jsonifiable + from ._run import AppOpts + +all = ("ExpressStubSession",) + + +class ExpressStubSession(Session): + """ + A very bare-bones stub session class that is used only in shiny.express's UI + rendering phase. + + Note that this class is also used to hold application-level options that are set via + the `app_opts()` function. + """ + + def __init__(self, ns: ResolvedId = Root): + self.ns = ns + self.input = Inputs({}) + self.output = Outputs(self, self.ns, outputs={}) + + # Set these values to None just to satisfy the abstract base class to make this + # code run -- these things should not be used at run time, so None will work as + # a placeholder. But we also need to tell pyright to ignore that the Nones don't + # match the type declared in the Session abstract base class. + self._outbound_message_queues = None # pyright: ignore + self._downloads = None # pyright: ignore + + # Application-level (not session-level) options that may be set via app_opts(). + self.app_opts: AppOpts = {} + + def is_stub_session(self) -> Literal[True]: + return True + + @property + def id(self) -> str: + self._not_implemented("id") + + @id.setter + def id(self, value: str) -> None: # pyright: ignore + self._not_implemented("id") + + @property + def app(self) -> App: + self._not_implemented("app") + + @app.setter + def app(self, value: App) -> None: # pyright: ignore + self._not_implemented("app") + + async def close(self, code: int = 1001) -> None: + return + + # This is needed so that Outputs don't throw an error. + def _is_hidden(self, name: str) -> bool: + return False + + def on_ended( + self, + fn: Callable[[], None] | Callable[[], Awaitable[None]], + ) -> Callable[[], None]: + return lambda: None + + def make_scope(self, id: Id) -> Session: + ns = self.ns(id) + return SessionProxy(parent=self, ns=ns) + + def root_scope(self) -> ExpressStubSession: + return self + + def _process_ui(self, ui: TagChild) -> RenderedDeps: + return {"deps": [], "html": ""} + + def send_input_message(self, id: str, message: dict[str, object]) -> None: + return + + def _send_insert_ui( + self, selector: str, multiple: bool, where: str, content: RenderedDeps + ) -> None: + return + + def _send_remove_ui(self, selector: str, multiple: bool) -> None: + return + + def _send_progress(self, type: str, message: object) -> None: + return + + async def send_custom_message(self, type: str, message: dict[str, object]) -> None: + return + + def set_message_handler( + self, + name: str, + handler: ( + Callable[..., Jsonifiable] | Callable[..., Awaitable[Jsonifiable]] | None + ), + *, + _handler_session: Optional[Session] = None, + ) -> str: + return "" + + async def _send_message(self, message: dict[str, object]) -> None: + return + + def _send_message_sync(self, message: dict[str, object]) -> None: + return + + def on_flush( + self, + fn: Callable[[], None] | Callable[[], Awaitable[None]], + once: bool = True, + ) -> Callable[[], None]: + return lambda: None + + def on_flushed( + self, + fn: Callable[[], None] | Callable[[], Awaitable[None]], + once: bool = True, + ) -> Callable[[], None]: + return lambda: None + + def dynamic_route(self, name: str, handler: DynamicRouteHandler) -> str: + return "" + + async def _unhandled_error(self, e: Exception) -> None: + return + + def download( + self, + id: Optional[str] = None, + filename: Optional[str | Callable[[], str]] = None, + media_type: None | str | Callable[[], str] = None, + encoding: str = "utf-8", + ) -> Callable[[DownloadHandler], None]: + return lambda x: None + + def _not_implemented(self, name: str) -> Never: + raise NotImplementedError( + textwrap.dedent( + f""" + The session attribute `{name}` is not yet available for use. Since this code + will run again when the session is initialized, you can use `if not session.is_stub_session():` + to only run this code when the session is established. + """ + ) + ) diff --git a/shiny/reactive/_reactives.py b/shiny/reactive/_reactives.py index f82658d09..a9147d91f 100644 --- a/shiny/reactive/_reactives.py +++ b/shiny/reactive/_reactives.py @@ -37,7 +37,7 @@ from ._core import Context, Dependents, ReactiveWarning, isolate if TYPE_CHECKING: - from ..session import Session + from .. import Session T = TypeVar("T") @@ -477,8 +477,8 @@ def __init__( self.__name__ = fn.__name__ self.__doc__ = fn.__doc__ - from ..express._mock_session import ExpressMockSession from ..render.renderer import Renderer + from ..session import Session if isinstance(fn, Renderer): raise TypeError( @@ -513,9 +513,9 @@ def __init__( # could be None if outside of a session). session = get_current_session() - if isinstance(session, ExpressMockSession): - # If we're in an ExpressMockSession, then don't actually set up this effect - # -- we don't want it to try to run later. + if isinstance(session, Session) and session.is_stub_session(): + # If we're in an ExpressStubSession or a SessionProxy of one, then don't + # actually set up this effect -- we don't want it to try to run later. return self._session = session diff --git a/shiny/render/_data_frame.py b/shiny/render/_data_frame.py index d3d680b08..98d2f40d1 100644 --- a/shiny/render/_data_frame.py +++ b/shiny/render/_data_frame.py @@ -49,7 +49,7 @@ if TYPE_CHECKING: import pandas as pd - from ..session._utils import Session + from ..session import Session DataFrameT = TypeVar("DataFrameT", bound=pd.DataFrame) # TODO-barret-render.data_frame; Pandas, Polars, api compat, etc.; Today, we only support Pandas diff --git a/shiny/render/_data_frame_utils/_datagridtable.py b/shiny/render/_data_frame_utils/_datagridtable.py index 976ca762f..f829fb018 100644 --- a/shiny/render/_data_frame_utils/_datagridtable.py +++ b/shiny/render/_data_frame_utils/_datagridtable.py @@ -19,7 +19,6 @@ from ..._docstring import add_example, no_example from ..._typing_extensions import TypedDict -from ...session import Session from ...session._utils import RenderedDeps, require_active_session from ...types import Jsonifiable from ._selection import ( @@ -33,6 +32,8 @@ if TYPE_CHECKING: import pandas as pd + from ...session import Session + DataFrameT = TypeVar("DataFrameT", bound=pd.DataFrame) # TODO-future; Pandas, Polars, api compat, etc.; Today, we only support Pandas diff --git a/shiny/render/_render.py b/shiny/render/_render.py index af4c30c95..810d9ef21 100644 --- a/shiny/render/_render.py +++ b/shiny/render/_render.py @@ -31,7 +31,6 @@ from .._docstring import add_example, no_example from .._namespaces import ResolvedId from .._typing_extensions import Self -from ..express._mock_session import ExpressMockSession from ..session import get_current_session, require_active_session from ..session._session import DownloadHandler, DownloadInfo from ..types import MISSING, MISSING_TYPE, ImgData @@ -707,10 +706,11 @@ def url() -> str: super().__call__(url) # Register the download handler for the session. The reason we check for session - # not being None is because in Express, when the UI is rendered, this function - # `render.download()()` called once before any sessions have been started. + # not being None or a stub session is because in Express, when the UI is + # rendered, this function `render.download()()` called once before any sessions + # have been started. session = get_current_session() - if session is not None and not isinstance(session, ExpressMockSession): + if session is not None and not session.is_stub_session(): session._downloads[self.output_id] = DownloadInfo( filename=self.filename, content_type=self.media_type, diff --git a/shiny/session/_session.py b/shiny/session/_session.py index b262d3f43..f280ee7d6 100644 --- a/shiny/session/_session.py +++ b/shiny/session/_session.py @@ -16,6 +16,7 @@ import typing import urllib.parse import warnings +from abc import ABC, abstractmethod from pathlib import Path from typing import ( TYPE_CHECKING, @@ -135,6 +136,11 @@ def __init__(self): self.errors: dict[str, Any] = {} self.input_messages: list[dict[str, Any]] = [] + def reset(self) -> None: + self.values.clear() + self.errors.clear() + self.input_messages.clear() + def set_value(self, id: str, value: Any) -> None: self.values[id] = value # remove from self.errors @@ -151,35 +157,338 @@ def add_input_message(self, id: str, message: dict[str, Any]) -> None: self.input_messages.append({"id": id, "message": message}) -# Makes isinstance(x, Session) also return True when x is a SessionProxy (i.e., a module -# session) -class SessionMeta(type): - def __instancecheck__(self, __instance: Any) -> bool: - return isinstance(__instance, SessionProxy) - - -class Session(object, metaclass=SessionMeta): +# ====================================================================================== +# Session abstract base class +# ====================================================================================== +class Session(ABC): """ - A class representing a user session. + Interface definition for Session-like classes, like :class:`AppSession`, + :class:`SessionProxy`, and :class:`~shiny.express.ExpressStubSession`. """ - ns: ResolvedId = Root - - # These declarations are here only for pyright and stubgen to generate stub files. + ns: ResolvedId app: App id: str - http_conn: HTTPConnection input: Inputs output: Outputs user: str | None groups: list[str] | None + # TODO: not sure these should be directly exposed + _outbound_message_queues: OutBoundMessageQueues + _downloads: dict[str, DownloadInfo] + + @abstractmethod + def is_stub_session(self) -> bool: + """ + Returns whether this is a stub session. + + In the UI-rendering phase of Shiny Express apps, the session context has a stub + session. This stub session is not a real session; it is there only so that code + which expects a session can run without raising errors. + """ + + @add_example() + @abstractmethod + async def close(self, code: int = 1001) -> None: + """ + Close the session. + """ + + @abstractmethod + def _is_hidden(self, name: str) -> bool: ... + + @add_example() + @abstractmethod + def on_ended( + self, + fn: Callable[[], None] | Callable[[], Awaitable[None]], + ) -> Callable[[], None]: + """ + Registers a function to be called after the client has disconnected. + + Parameters + ---------- + fn + The function to call. + + Returns + ------- + : + A function that can be used to cancel the registration. + """ + + @abstractmethod + def make_scope(self, id: Id) -> Session: ... + + @abstractmethod + def root_scope(self) -> Session: ... + + @abstractmethod + def _process_ui(self, ui: TagChild) -> RenderedDeps: ... + + @abstractmethod + def send_input_message(self, id: str, message: dict[str, object]) -> None: + """ + Send an input message to the session. + + Sends a message to an input on the session's client web page; if the input is + present and bound on the page at the time the message is received, then the + input binding object's ``receiveMessage(el, message)`` method will be called. + This method should generally not be called directly from Shiny apps, but through + friendlier wrapper functions like ``ui.update_text()``. + + Parameters + ---------- + id + An id matching the id of an input to update. + message + The message to send. + """ + + @abstractmethod + def _send_insert_ui( + self, selector: str, multiple: bool, where: str, content: RenderedDeps + ) -> None: ... + + @abstractmethod + def _send_remove_ui(self, selector: str, multiple: bool) -> None: ... + + @overload + @abstractmethod + def _send_progress( + self, type: Literal["binding"], message: BindingProgressMessage + ) -> None: + pass + + @overload + @abstractmethod + def _send_progress( + self, type: Literal["open"], message: OpenProgressMessage + ) -> None: + pass + + @overload + @abstractmethod + def _send_progress( + self, type: Literal["close"], message: CloseProgressMessage + ) -> None: + pass + + @overload + @abstractmethod + def _send_progress( + self, type: Literal["update"], message: UpdateProgressMessage + ) -> None: + pass + + @abstractmethod + def _send_progress(self, type: str, message: object) -> None: ... + + @add_example() + @abstractmethod + async def send_custom_message(self, type: str, message: dict[str, object]) -> None: + """ + Send a message to the client. + + Parameters + ---------- + type + The type of message to send. + message + The message to send. + + Note + ---- + Sends messages to the client which can be handled in JavaScript with + ``Shiny.addCustomMessageHandler(type, function(message){...})``. Once the + message handler is added, it will be invoked each time ``send_custom_message()`` + is called on the server. + """ + + @abstractmethod + async def _send_message(self, message: dict[str, object]) -> None: ... + + @abstractmethod + def _send_message_sync(self, message: dict[str, object]) -> None: + """ + Same as _send_message, except that if the message isn't too large and the socket + isn't too backed up, then the message may be sent synchronously instead of + having to wait until the current task yields (and potentially much longer than + that, if there is a lot of contention for the main thread). + """ + + @add_example() + @abstractmethod + def on_flush( + self, + fn: Callable[[], None] | Callable[[], Awaitable[None]], + once: bool = True, + ) -> Callable[[], None]: + """ + Register a function to call before the next reactive flush. + + Parameters + ---------- + fn + The function to call. + once + Whether to call the function only once or on every flush. + + Returns + ------- + : + A function that can be used to cancel the registration. + """ + + @add_example() + @abstractmethod + def on_flushed( + self, + fn: Callable[[], None] | Callable[[], Awaitable[None]], + once: bool = True, + ) -> Callable[[], None]: + """ + Register a function to call after the next reactive flush. + + Parameters + ---------- + fn + The function to call. + once + Whether to call the function only once or on every flush. + + Returns + ------- + : + A function that can be used to cancel the registration. + """ + + @abstractmethod + async def _unhandled_error(self, e: Exception) -> None: ... + + @abstractmethod + def download( + self, + id: Optional[str] = None, + filename: Optional[str | Callable[[], str]] = None, + media_type: None | str | Callable[[], str] = None, + encoding: str = "utf-8", + ) -> Callable[[DownloadHandler], None]: + """ + Deprecated. Please use :class:`~shiny.render.download` instead. + + Parameters + ---------- + id + The name of the download. + filename + The filename of the download. + media_type + The media type of the download. + encoding + The encoding of the download. + + Returns + ------- + : + The decorated function. + """ + + @add_example() + @abstractmethod + def dynamic_route(self, name: str, handler: DynamicRouteHandler) -> str: + """ + Register a function to call when a dynamically generated, session-specific, + route is requested. + + Provides a convenient way to serve-up session-dependent values for other + clients/applications to consume. + + Parameters + ---------- + name + A name for the route (used to determine part of the URL path). + handler + The function to call when a request is made to the route. This function + should take a single argument (a :class:`starlette.requests.Request` object) + and return a :class:`starlette.types.ASGIApp` object. + + + Returns + ------- + : + The URL path for the route. + """ + + @abstractmethod + def set_message_handler( + self, + name: str, + handler: ( + Callable[..., Jsonifiable] | Callable[..., Awaitable[Jsonifiable]] | None + ), + *, + _handler_session: Optional[Session] = None, + ) -> str: + """ + Set a client message handler. + + Sets a method that can be called by the client via + `Shiny.shinyapp.makeRequest()`. `Shiny.shinyapp.makeRequest()` makes a request + to the server and waits for a response. By using `makeRequest()` (JS) and + `set_message_handler()` (python), you can have a much richer communication + interaction than just using Input values and re-rendering outputs. + + For example, `@render.data_frame` can have many cells edited. While it is + possible to set many input values, if `makeRequest()` did not exist, the data + frame would be updated on the first cell update. This would cause the data frame + to be re-rendered, cancelling any pending cell updates. `makeRequest()` allows + for individual cell updates to be sent to the server, processed, and handled by + the existing data frame output. + + When the message handler is executed, it will be executed within an isolated + reactive context and the session context that set the message handler. + + Parameters + ---------- + name + The name of the message handler. + handler + The handler function to be called when the client makes a message for the + given name. The handler function should take any number of arguments that + are provided by the client and return a JSON-serializable object. + + If the value is `None`, then the handler at `name` will be removed. + _handler_session + For internal use. This is the session which will be used as the session + context when calling the handler. + + Returns + ------- + : + The key under which the handler is stored (or removed). This value will be + namespaced when used with a session proxy. + """ + + +# ====================================================================================== +# AppSession +# ====================================================================================== + + +class AppSession(Session): + """ + A class representing a user session. + """ + # ========================================================================== # Initialization # ========================================================================== def __init__( self, app: App, id: str, conn: Connection, debug: bool = False ) -> None: + self.ns: ResolvedId = Root self.app: App = app self.id: str = id self._conn: Connection = conn @@ -254,11 +563,10 @@ async def _run_session_end_tasks(self) -> None: finally: self.app._remove_session(self) - @add_example() + def is_stub_session(self) -> Literal[False]: + return False + async def close(self, code: int = 1001) -> None: - """ - Close the session. - """ await self._conn.close(code, None) await self._run_session_end_tasks() @@ -596,26 +904,10 @@ async def wrap_content_sync() -> AsyncIterable[bytes]: return await handler(request) else: return handler(request) - - return HTMLResponse("

Not Found

", 404) - - def send_input_message(self, id: str, message: dict[str, object]) -> None: - """ - Send an input message to the session. - - Sends a message to an input on the session's client web page; if the input is - present and bound on the page at the time the message is received, then the - input binding object's ``receiveMessage(el, message)`` method will be called. - This method should generally not be called directly from Shiny apps, but through - friendlier wrapper functions like ``ui.update_text()``. - - Parameters - ---------- - id - An id matching the id of an input to update. - message - The message to send. - """ + + return HTMLResponse("

Not Found

", 404) + + def send_input_message(self, id: str, message: dict[str, object]) -> None: self._outbound_message_queues.add_input_message(id, message) self._request_flush() @@ -634,53 +926,11 @@ def _send_remove_ui(self, selector: str, multiple: bool) -> None: msg = {"selector": selector, "multiple": multiple} self._send_message_sync({"shiny-remove-ui": msg}) - @overload - def _send_progress( - self, type: Literal["binding"], message: BindingProgressMessage - ) -> None: - pass - - @overload - def _send_progress( - self, type: Literal["open"], message: OpenProgressMessage - ) -> None: - pass - - @overload - def _send_progress( - self, type: Literal["close"], message: CloseProgressMessage - ) -> None: - pass - - @overload - def _send_progress( - self, type: Literal["update"], message: UpdateProgressMessage - ) -> None: - pass - def _send_progress(self, type: str, message: object) -> None: msg: dict[str, object] = {"progress": {"type": type, "message": message}} self._send_message_sync(msg) - @add_example() async def send_custom_message(self, type: str, message: dict[str, object]) -> None: - """ - Send a message to the client. - - Parameters - ---------- - type - The type of message to send. - message - The message to send. - - Note - ---- - Sends messages to the client which can be handled in JavaScript with - ``Shiny.addCustomMessageHandler(type, function(message){...})``. Once the - message handler is added, it will be invoked each time ``send_custom_message()`` - is called on the server. - """ await self._send_message({"custom": {type: message}}) async def _send_message(self, message: dict[str, object]) -> None: @@ -695,12 +945,6 @@ async def _send_message(self, message: dict[str, object]) -> None: await self._conn.send(json.dumps(message)) def _send_message_sync(self, message: dict[str, object]) -> None: - """ - Same as _send_message, except that if the message isn't too large and the socket - isn't too backed up, then the message may be sent synchronously instead of - having to wait until the current task yields (and potentially much longer than - that, if there is a lot of contention for the main thread). - """ _utils.run_coro_hybrid(self._send_message(message)) def _print_error_message(self, message: str | Exception) -> None: @@ -720,50 +964,18 @@ async def _send_error_response( # ========================================================================== # Flush # ========================================================================== - @add_example() def on_flush( self, fn: Callable[[], None] | Callable[[], Awaitable[None]], once: bool = True, ) -> Callable[[], None]: - """ - Register a function to call before the next reactive flush. - - Parameters - ---------- - fn - The function to call. - once - Whether to call the function only once or on every flush. - - Returns - ------- - : - A function that can be used to cancel the registration. - """ return self._flush_callbacks.register(wrap_async(fn), once) - @add_example() def on_flushed( self, fn: Callable[[], None] | Callable[[], Awaitable[None]], once: bool = True, ) -> Callable[[], None]: - """ - Register a function to call after the next reactive flush. - - Parameters - ---------- - fn - The function to call. - once - Whether to call the function only once or on every flush. - - Returns - ------- - : - A function that can be used to cancel the registration. - """ return self._flushed_callbacks.register(wrap_async(fn), once) def _request_flush(self) -> None: @@ -785,7 +997,7 @@ async def _flush(self) -> None: try: await self._send_message(message) finally: - self._outbound_message_queues = OutBoundMessageQueues() + self._outbound_message_queues.reset() finally: with session_context(self): await self._flushed_callbacks.invoke() @@ -793,24 +1005,10 @@ async def _flush(self) -> None: # ========================================================================== # On session ended # ========================================================================== - @add_example() def on_ended( self, fn: Callable[[], None] | Callable[[], Awaitable[None]], ) -> Callable[[], None]: - """ - Registers a function to be called after the client has disconnected. - - Parameters - ---------- - fn - The function to call. - - Returns - ------- - : - A function that can be used to cancel the registration. - """ return self._on_ended_callbacks.register(wrap_async(fn)) # ========================================================================== @@ -820,7 +1018,6 @@ async def _unhandled_error(self, e: Exception) -> None: print("Unhandled error: " + str(e), file=sys.stderr) await self.close() - @add_example() def download( self, id: Optional[str] = None, @@ -828,26 +1025,6 @@ def download( media_type: None | str | Callable[[], str] = None, encoding: str = "utf-8", ) -> Callable[[DownloadHandler], None]: - """ - Deprecated. Please use :class:`~shiny.render.download` instead. - - Parameters - ---------- - id - The name of the download. - filename - The filename of the download. - media_type - The media type of the download. - encoding - The encoding of the download. - - Returns - ------- - : - The decorated function. - """ - warn_deprecated( "session.download() is deprecated. Please use render.download() instead." ) @@ -871,30 +1048,7 @@ def _(): return wrapper - @add_example() def dynamic_route(self, name: str, handler: DynamicRouteHandler) -> str: - """ - Register a function to call when a dynamically generated, session-specific, - route is requested. - - Provides a convenient way to serve-up session-dependent values for other - clients/applications to consume. - - Parameters - ---------- - name - A name for the route (used to determine part of the URL path). - handler - The function to call when a request is made to the route. This function - should take a single argument (a :class:`starlette.requests.Request` object) - and return a :class:`starlette.types.ASGIApp` object. - - - Returns - ------- - : - The URL path for the route. - """ self._dynamic_routes.update({name: handler}) nonce = _utils.rand_hex(8) @@ -906,69 +1060,21 @@ def set_message_handler( handler: ( Callable[..., Jsonifiable] | Callable[..., Awaitable[Jsonifiable]] | None ), - ) -> str: - """ - Set a client message handler. - - Sets a method that can be called by the client via - `Shiny.shinyapp.makeRequest()`. `Shiny.shinyapp.makeRequest()` makes a request - to the server and waits for a response. By using `makeRequest()` (JS) and - `set_message_handler()` (python), you can have a much richer communication - interaction than just using Input values and re-rendering outputs. - - For example, `@render.data_frame` can have many cells edited. While it is - possible to set many input values, if `makeRequest()` did not exist, the data - frame would be updated on the first cell update. This would cause the data frame - to be re-rendered, cancelling any pending cell updates. `makeRequest()` allows - for individual cell updates to be sent to the server, processed, and handled by - the existing data frame output. - - When the message handler is executed, it will be executed within an isolated reactive - context and the session context that set the message handler. - - Parameters - ---------- - name - The name of the message handler. - handler - The handler function to be called when the client makes a message for the - given name. The handler function should take any number of arguments that - are provided by the client and return a JSON-serializable object. - - If the value is `None`, then the handler at `name` will be removed. - - Returns - ------- - : - The key under which the handler is stored (or removed). This value will be - namespaced when used with a session proxy. - """ - # Use `_impl` method to allow for SessionProxy to set the `handler_session` - return self._set_message_handler_impl( - name, - handler, - # The handler will be executed within the root session - handler_session=self, - ) - - def _set_message_handler_impl( - self, - name: str, - handler: ( - Callable[..., Jsonifiable] | Callable[..., Awaitable[Jsonifiable]] | None - ), *, - # Allow the handler to be executed within a Session or SessionProxy - handler_session: Session | SessionProxy, + _handler_session: Optional[Session] = None, ) -> str: # Verify that the name is a string assert isinstance(name, str) + + if _handler_session is None: + _handler_session = self + if handler is None: if name in self._message_handlers: del self._message_handlers[name] else: assert callable(handler) - self._message_handlers[name] = (wrap_async(handler), self) + self._message_handlers[name] = (wrap_async(handler), _handler_session) return name def _process_ui(self, ui: TagChild) -> RenderedDeps: @@ -983,9 +1089,9 @@ def _process_ui(self, ui: TagChild) -> RenderedDeps: def make_scope(self, id: Id) -> Session: ns = self.ns(id) - return SessionProxy(parent=self, ns=ns) # type: ignore + return SessionProxy(parent=self, ns=ns) - def root_scope(self) -> Session: + def root_scope(self) -> AppSession: return self @@ -1012,23 +1118,40 @@ class UpdateProgressMessage(TypedDict): style: str -class SessionProxy: - ns: ResolvedId - input: Inputs - output: Outputs +# ====================================================================================== +# SessionProxy +# ====================================================================================== + +class SessionProxy(Session): def __init__(self, parent: Session, ns: ResolvedId) -> None: self._parent = parent + self.app = parent.app + self.id = parent.id self.ns = ns self.input = Inputs(values=parent.input._map, ns=ns) self.output = Outputs( - cast(Session, self), + self, ns=ns, - outputs=self.output._outputs, + outputs=parent.output._outputs, ) + self._outbound_message_queues = parent._outbound_message_queues + self._downloads = parent._downloads + + def _is_hidden(self, name: str) -> bool: + return self._parent._is_hidden(name) + + def on_ended( + self, + fn: Callable[[], None] | Callable[[], Awaitable[None]], + ) -> Callable[[], None]: + return self._parent.on_ended(fn) + + def is_stub_session(self) -> bool: + return self._parent.is_stub_session() - def __getattr__(self, attr: str) -> Any: - return getattr(self._parent, attr) + async def close(self, code: int = 1001) -> None: + await self._parent.close(code) def make_scope(self, id: str) -> Session: return self._parent.make_scope(self.ns(id)) @@ -1039,11 +1162,25 @@ def root_scope(self) -> Session: res = res._parent return res + def _process_ui(self, ui: TagChild) -> RenderedDeps: + return self._parent._process_ui(ui) + def send_input_message(self, id: str, message: dict[str, object]) -> None: - return self._parent.send_input_message(self.ns(id), message) + self._parent.send_input_message(self.ns(id), message) - def dynamic_route(self, name: str, handler: DynamicRouteHandler) -> str: - return self._parent.dynamic_route(self.ns(name), handler) + def _send_insert_ui( + self, selector: str, multiple: bool, where: str, content: RenderedDeps + ) -> None: + self._parent._send_insert_ui(selector, multiple, where, content) + + def _send_remove_ui(self, selector: str, multiple: bool) -> None: + self._parent._send_remove_ui(selector, multiple) + + def _send_progress(self, type: str, message: object) -> None: + self._parent._send_progress(type, message) # pyright: ignore + + async def send_custom_message(self, type: str, message: dict[str, object]) -> None: + await self._parent.send_custom_message(type, message) def set_message_handler( self, @@ -1051,23 +1188,61 @@ def set_message_handler( handler: ( Callable[..., Jsonifiable] | Callable[..., Awaitable[Jsonifiable]] | None ), + *, + _handler_session: Optional[Session] = None, ) -> str: # Verify that the name is a string assert isinstance(name, str) - return self._parent._set_message_handler_impl( + + if _handler_session is None: + _handler_session = self + + return self._parent.set_message_handler( self.ns(name), handler, - # Allow the handler to be executed within this session proxy - handler_session=self, + _handler_session=_handler_session, ) + def on_flush( + self, + fn: Callable[[], None] | Callable[[], Awaitable[None]], + once: bool = True, + ) -> Callable[[], None]: + return self._parent.on_flush(fn, once) + + async def _send_message(self, message: dict[str, object]) -> None: + await self._parent._send_message(message) + + def _send_message_sync(self, message: dict[str, object]) -> None: + self._parent._send_message_sync(message) + + def on_flushed( + self, + fn: Callable[[], None] | Callable[[], Awaitable[None]], + once: bool = True, + ) -> Callable[[], None]: + return self._parent.on_flushed(fn, once) + + def dynamic_route(self, name: str, handler: DynamicRouteHandler) -> str: + return self._parent.dynamic_route(self.ns(name), handler) + + async def _unhandled_error(self, e: Exception) -> None: + await self._parent._unhandled_error(e) + def download( - self, id: Optional[str] = None, **kwargs: object + self, + id: Optional[str] = None, + filename: Optional[str | Callable[[], str]] = None, + media_type: None | str | Callable[[], str] = None, + encoding: str = "utf-8", ) -> Callable[[DownloadHandler], None]: def wrapper(fn: DownloadHandler): id_ = self.ns(id or fn.__name__) return self._parent.download( - id=id_, **kwargs # pyright: ignore[reportArgumentType] + id=id_, + filename=filename, + media_type=media_type, + encoding=encoding, )(fn) return wrapper @@ -1189,6 +1364,15 @@ def __call__( suspend_when_hidden: bool = True, priority: int = 0, ) -> RendererT | Callable[[RendererT], RendererT]: + + def require_real_session() -> Session: + if self._session.is_stub_session(): + raise RuntimeError( + "`output` must be used with a real session (as opposed to a stub session)." + ) + + return self._session + def set_renderer(renderer: RendererT) -> RendererT: if not isinstance(renderer, Renderer): raise TypeError( @@ -1212,30 +1396,35 @@ def set_renderer(renderer: RendererT) -> RendererT: priority=priority, ) async def output_obs(): - await self._session._send_message( + if self._session.is_stub_session(): + raise RuntimeError( + "`output` must be used with a real session (as opposed to a stub session)." + ) + + session = require_real_session() + + await session._send_message( {"recalculating": {"name": output_name, "status": "recalculating"}} ) try: value = await renderer.render() - self._session._outbound_message_queues.set_value(output_name, value) + session._outbound_message_queues.set_value(output_name, value) except SilentOperationInProgressException: - self._session._send_progress( + session._send_progress( "binding", {"id": output_name, "persistent": True} ) return except SilentCancelOutputException: return except SilentException: - self._session._outbound_message_queues.set_value(output_name, None) + session._outbound_message_queues.set_value(output_name, None) except Exception as e: # Print traceback to the console traceback.print_exc() # Possibly sanitize error for the user - if self._session.app.sanitize_errors and not isinstance( - e, SafeException - ): - err_msg = self._session.app.sanitize_error_msg + if session.app.sanitize_errors and not isinstance(e, SafeException): + err_msg = session.app.sanitize_error_msg else: err_msg = str(e) # Register the outbound error message @@ -1246,12 +1435,10 @@ async def output_obs(): # TODO: I don't think we actually use this for anything client-side "type": None, } - self._session._outbound_message_queues.set_error( - output_name, err_message - ) + session._outbound_message_queues.set_error(output_name, err_message) return finally: - await self._session._send_message( + await session._send_message( { "recalculating": { "name": output_name, @@ -1261,7 +1448,9 @@ async def output_obs(): ) output_obs.on_invalidate( - lambda: self._session._send_progress("binding", {"id": output_name}) + lambda: require_real_session()._send_progress( + "binding", {"id": output_name} + ) ) # Store the renderer and effect info diff --git a/shiny/ui/_accordion.py b/shiny/ui/_accordion.py index 454ebefd6..e82c6376b 100644 --- a/shiny/ui/_accordion.py +++ b/shiny/ui/_accordion.py @@ -14,7 +14,7 @@ from .css._css_unit import CssUnit, as_css_unit if TYPE_CHECKING: - from .. import Session + from ..session import Session __all__ = ( "accordion", diff --git a/shiny/ui/_input_update.py b/shiny/ui/_input_update.py index e7c8a7760..c01cf7ef4 100644 --- a/shiny/ui/_input_update.py +++ b/shiny/ui/_input_update.py @@ -41,7 +41,7 @@ from ._utils import JSEval, _session_on_flush_send_msg, extract_js_keys if TYPE_CHECKING: - from .. import Session + from ..session import Session _note = """ diff --git a/shiny/ui/_modal.py b/shiny/ui/_modal.py index c24d164ab..0f2eb9168 100644 --- a/shiny/ui/_modal.py +++ b/shiny/ui/_modal.py @@ -7,15 +7,17 @@ "modal_remove", ) -from typing import Literal, Optional +from typing import TYPE_CHECKING, Literal, Optional from htmltools import HTML, Tag, TagAttrs, TagAttrValue, TagChild, div, tags from .._docstring import add_example from ..session import require_active_session -from ..session._session import Session from ..types import MISSING, MISSING_TYPE +if TYPE_CHECKING: + from ..session import Session + @add_example(ex_dir="../api-examples/modal") def modal_button(label: TagChild, icon: TagChild = None, **kwargs: TagAttrValue) -> Tag: diff --git a/shiny/ui/_notification.py b/shiny/ui/_notification.py index 33593dce2..29d0fd642 100644 --- a/shiny/ui/_notification.py +++ b/shiny/ui/_notification.py @@ -11,7 +11,7 @@ from ..session import require_active_session if TYPE_CHECKING: - from .. import Session + from ..session import Session @add_example() diff --git a/shiny/ui/_progress.py b/shiny/ui/_progress.py index 5f5ad764c..a302d8c6d 100644 --- a/shiny/ui/_progress.py +++ b/shiny/ui/_progress.py @@ -12,7 +12,7 @@ from ..session._session import UpdateProgressMessage if TYPE_CHECKING: - from .. import Session + from ..session import Session @add_example() diff --git a/shiny/ui/_sidebar.py b/shiny/ui/_sidebar.py index e35250af0..2ddd91085 100644 --- a/shiny/ui/_sidebar.py +++ b/shiny/ui/_sidebar.py @@ -31,7 +31,7 @@ from .fill import as_fill_item, as_fillable_container if TYPE_CHECKING: - from .. import Session + from ..session import Session __all__ = ( "Sidebar", diff --git a/tests/playwright/shiny/plot-sizing/bike.jpg b/tests/playwright/shiny/plot-sizing/bike.jpg index 72444080b..9d1eaa190 100644 Binary files a/tests/playwright/shiny/plot-sizing/bike.jpg and b/tests/playwright/shiny/plot-sizing/bike.jpg differ diff --git a/tests/pytest/test_modules.py b/tests/pytest/test_modules.py index 1b91fc7d7..2b8c7d3ed 100644 --- a/tests/pytest/test_modules.py +++ b/tests/pytest/test_modules.py @@ -1,7 +1,9 @@ """Tests for `Module`.""" +from __future__ import annotations + import asyncio -from typing import Dict, Union, cast +from typing import cast import pytest from htmltools import Tag, TagList @@ -10,6 +12,7 @@ from shiny._connection import MockConnection from shiny._namespaces import resolve_id from shiny.session import get_current_session +from shiny.session._session import AppSession, SessionProxy @module.ui @@ -41,7 +44,7 @@ def test_module_ui(): @pytest.mark.asyncio async def test_session_scoping(): - sessions: Dict[str, Union[Session, None, str]] = {} + sessions: dict[str, Session | str | None] = {} @module.server def inner_server(input: Inputs, output: Outputs, session: Session): @@ -98,18 +101,20 @@ async def mock_client(): assert sessions["inner"] is sessions["inner_current"] assert sessions["inner_current"] is sessions["inner_calc_current"] - assert isinstance(sessions["inner_current"], Session) + assert isinstance(sessions["inner_current"], SessionProxy) + assert sessions["inner_current"].root_scope() is sessions["top"] assert sessions["inner_id"] == "mod_outer-mod_inner-foo" assert sessions["inner_ui_id"] == "mod_outer-mod_inner-outer-inner-button" assert sessions["outer"] is sessions["outer_current"] assert sessions["outer_current"] is sessions["outer_calc_current"] - assert isinstance(sessions["outer_current"], Session) + assert isinstance(sessions["outer_current"], SessionProxy) + assert sessions["outer_current"].root_scope() is sessions["top"] assert sessions["outer_id"] == "mod_outer-foo" assert sessions["outer_ui_id"] == "mod_outer-outer-inner-button" assert sessions["top"] is sessions["top_current"] assert sessions["top_current"] is sessions["top_calc_current"] - assert isinstance(sessions["top_current"], Session) + assert isinstance(sessions["top_current"], AppSession) assert sessions["top_id"] == "foo" assert sessions["top_ui_id"] == "outer-inner-button"