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("