Skip to content

Commit

Permalink
Add Session abstract base class (#1331)
Browse files Browse the repository at this point in the history
  • Loading branch information
wch authored Apr 26, 2024
1 parent 2c2b075 commit 3547e20
Show file tree
Hide file tree
Showing 18 changed files with 662 additions and 359 deletions.
18 changes: 9 additions & 9 deletions shiny/_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)

Expand All @@ -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
Expand Down
5 changes: 3 additions & 2 deletions shiny/_typing_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"Concatenate",
"ParamSpec",
"TypeGuard",
"Never",
"NotRequired",
"Self",
"TypedDict",
Expand All @@ -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
Expand Down
55 changes: 0 additions & 55 deletions shiny/express/_mock_session.py

This file was deleted.

18 changes: 9 additions & 9 deletions shiny/express/_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -231,17 +231,17 @@ 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(
"express.app_opts() can only be used in a standalone Shiny Express app."
)

# 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):
Expand All @@ -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:
Expand Down
160 changes: 160 additions & 0 deletions shiny/express/_stub_session.py
Original file line number Diff line number Diff line change
@@ -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.
"""
)
)
10 changes: 5 additions & 5 deletions shiny/reactive/_reactives.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
from ._core import Context, Dependents, ReactiveWarning, isolate

if TYPE_CHECKING:
from ..session import Session
from .. import Session

T = TypeVar("T")

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion shiny/render/_data_frame.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading

0 comments on commit 3547e20

Please sign in to comment.