Skip to content

Commit 3547e20

Browse files
authored
Add Session abstract base class (#1331)
1 parent 2c2b075 commit 3547e20

File tree

18 files changed

+662
-359
lines changed

18 files changed

+662
-359
lines changed

shiny/_app.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
from ._utils import guess_mime_type, is_async_callable, sort_keys_length
3333
from .html_dependencies import jquery_deps, require_deps, shiny_deps
3434
from .http_staticfiles import FileResponse, StaticFiles
35-
from .session._session import Inputs, Outputs, Session, session_context
35+
from .session._session import AppSession, Inputs, Outputs, Session, session_context
3636

3737
T = TypeVar("T")
3838

@@ -165,9 +165,9 @@ def __init__(
165165
static_assets_map = sort_keys_length(static_assets_map, descending=True)
166166
self._static_assets: dict[str, Path] = static_assets_map
167167

168-
self._sessions: dict[str, Session] = {}
168+
self._sessions: dict[str, AppSession] = {}
169169

170-
self._sessions_needing_flush: dict[int, Session] = {}
170+
self._sessions_needing_flush: dict[int, AppSession] = {}
171171

172172
self._registered_dependencies: dict[str, HTMLDependency] = {}
173173
self._dependency_handler = starlette.routing.Router()
@@ -243,14 +243,14 @@ async def _lifespan(self, app: starlette.applications.Starlette):
243243
async with self._exit_stack:
244244
yield
245245

246-
def _create_session(self, conn: Connection) -> Session:
246+
def _create_session(self, conn: Connection) -> AppSession:
247247
id = secrets.token_hex(32)
248-
session = Session(self, id, conn, debug=self._debug)
248+
session = AppSession(self, id, conn, debug=self._debug)
249249
self._sessions[id] = session
250250
return session
251251

252-
def _remove_session(self, session: Session | str) -> None:
253-
if isinstance(session, Session):
252+
def _remove_session(self, session: AppSession | str) -> None:
253+
if isinstance(session, AppSession):
254254
session = session.id
255255

256256
if self._debug:
@@ -379,7 +379,7 @@ async def _on_session_request_cb(self, request: Request) -> ASGIApp:
379379
subpath: str = request.path_params["subpath"] # type: ignore
380380

381381
if session_id in self._sessions:
382-
session: Session = self._sessions[session_id]
382+
session: AppSession = self._sessions[session_id]
383383
with session_context(session):
384384
return await session._handle_request(request, action, subpath)
385385

@@ -388,7 +388,7 @@ async def _on_session_request_cb(self, request: Request) -> ASGIApp:
388388
# ==========================================================================
389389
# Flush
390390
# ==========================================================================
391-
def _request_flush(self, session: Session) -> None:
391+
def _request_flush(self, session: AppSession) -> None:
392392
# TODO: Until we have reactive domains, because we can't yet keep track
393393
# of which sessions need a flush.
394394
pass

shiny/_typing_extensions.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
"Concatenate",
88
"ParamSpec",
99
"TypeGuard",
10+
"Never",
1011
"NotRequired",
1112
"Self",
1213
"TypedDict",
@@ -30,9 +31,9 @@
3031
# they should both come from the same typing module.
3132
# https://peps.python.org/pep-0655/#usage-in-python-3-11
3233
if sys.version_info >= (3, 11):
33-
from typing import NotRequired, Self, TypedDict, assert_type
34+
from typing import Never, NotRequired, Self, TypedDict, assert_type
3435
else:
35-
from typing_extensions import NotRequired, Self, TypedDict, assert_type
36+
from typing_extensions import Never, NotRequired, Self, TypedDict, assert_type
3637

3738

3839
# The only purpose of the following line is so that pyright will put all of the

shiny/express/_mock_session.py

Lines changed: 0 additions & 55 deletions
This file was deleted.

shiny/express/_run.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@
1414
from ..session import Inputs, Outputs, Session, get_current_session, session_context
1515
from ..types import MISSING, MISSING_TYPE
1616
from ._is_express import find_magic_comment_mode
17-
from ._mock_session import ExpressMockSession
1817
from ._recall_context import RecallContextManager
18+
from ._stub_session import ExpressStubSession
1919
from .expressify_decorator._func_displayhook import _expressify_decorator_function_def
2020
from .expressify_decorator._node_transformers import (
2121
DisplayFuncsTransformer,
@@ -49,8 +49,8 @@ def wrap_express_app(file: Path) -> App:
4949
with session_context(None):
5050
import_module_from_path("globals", globals_file)
5151

52-
mock_session = ExpressMockSession()
53-
with session_context(cast(Session, mock_session)):
52+
stub_session = ExpressStubSession()
53+
with session_context(stub_session):
5454
# We tagify here, instead of waiting for the App object to do it when it wraps
5555
# the UI in a HTMLDocument and calls render() on it. This is because
5656
# 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):
7979
if www_dir.is_dir():
8080
app_opts["static_assets"] = {"/": www_dir}
8181

82-
app_opts = _merge_app_opts(app_opts, mock_session.app_opts)
82+
app_opts = _merge_app_opts(app_opts, stub_session.app_opts)
8383
app_opts = _normalize_app_opts(app_opts, file.parent)
8484

8585
app = App(
@@ -231,17 +231,17 @@ def app_opts(
231231
Whether to enable debug mode.
232232
"""
233233

234-
mock_session = get_current_session()
234+
stub_session = get_current_session()
235235

236-
if mock_session is None:
236+
if stub_session is None:
237237
# We can get here if a Shiny Core app, or if we're in the UI rendering phase of
238238
# a Quarto-Shiny dashboard.
239239
raise RuntimeError(
240240
"express.app_opts() can only be used in a standalone Shiny Express app."
241241
)
242242

243243
# Store these options only if we're in the UI-rendering phase of Shiny Express.
244-
if not isinstance(mock_session, ExpressMockSession):
244+
if not isinstance(stub_session, ExpressStubSession):
245245
return
246246

247247
if not isinstance(static_assets, MISSING_TYPE):
@@ -251,10 +251,10 @@ def app_opts(
251251
# Convert string values to Paths. (Need new var name to help type checker.)
252252
static_assets_paths = {k: Path(v) for k, v in static_assets.items()}
253253

254-
mock_session.app_opts["static_assets"] = static_assets_paths
254+
stub_session.app_opts["static_assets"] = static_assets_paths
255255

256256
if not isinstance(debug, MISSING_TYPE):
257-
mock_session.app_opts["debug"] = debug
257+
stub_session.app_opts["debug"] = debug
258258

259259

260260
def _merge_app_opts(app_opts: AppOpts, app_opts_new: AppOpts) -> AppOpts:

shiny/express/_stub_session.py

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
from __future__ import annotations
2+
3+
import textwrap
4+
from typing import TYPE_CHECKING, Awaitable, Callable, Literal, Optional
5+
6+
from htmltools import TagChild
7+
8+
from .._namespaces import Id, ResolvedId, Root
9+
from ..session import Inputs, Outputs, Session
10+
from ..session._session import SessionProxy
11+
12+
if TYPE_CHECKING:
13+
from .._app import App
14+
from .._typing_extensions import Never
15+
from ..session._session import DownloadHandler, DynamicRouteHandler, RenderedDeps
16+
from ..types import Jsonifiable
17+
from ._run import AppOpts
18+
19+
all = ("ExpressStubSession",)
20+
21+
22+
class ExpressStubSession(Session):
23+
"""
24+
A very bare-bones stub session class that is used only in shiny.express's UI
25+
rendering phase.
26+
27+
Note that this class is also used to hold application-level options that are set via
28+
the `app_opts()` function.
29+
"""
30+
31+
def __init__(self, ns: ResolvedId = Root):
32+
self.ns = ns
33+
self.input = Inputs({})
34+
self.output = Outputs(self, self.ns, outputs={})
35+
36+
# Set these values to None just to satisfy the abstract base class to make this
37+
# code run -- these things should not be used at run time, so None will work as
38+
# a placeholder. But we also need to tell pyright to ignore that the Nones don't
39+
# match the type declared in the Session abstract base class.
40+
self._outbound_message_queues = None # pyright: ignore
41+
self._downloads = None # pyright: ignore
42+
43+
# Application-level (not session-level) options that may be set via app_opts().
44+
self.app_opts: AppOpts = {}
45+
46+
def is_stub_session(self) -> Literal[True]:
47+
return True
48+
49+
@property
50+
def id(self) -> str:
51+
self._not_implemented("id")
52+
53+
@id.setter
54+
def id(self, value: str) -> None: # pyright: ignore
55+
self._not_implemented("id")
56+
57+
@property
58+
def app(self) -> App:
59+
self._not_implemented("app")
60+
61+
@app.setter
62+
def app(self, value: App) -> None: # pyright: ignore
63+
self._not_implemented("app")
64+
65+
async def close(self, code: int = 1001) -> None:
66+
return
67+
68+
# This is needed so that Outputs don't throw an error.
69+
def _is_hidden(self, name: str) -> bool:
70+
return False
71+
72+
def on_ended(
73+
self,
74+
fn: Callable[[], None] | Callable[[], Awaitable[None]],
75+
) -> Callable[[], None]:
76+
return lambda: None
77+
78+
def make_scope(self, id: Id) -> Session:
79+
ns = self.ns(id)
80+
return SessionProxy(parent=self, ns=ns)
81+
82+
def root_scope(self) -> ExpressStubSession:
83+
return self
84+
85+
def _process_ui(self, ui: TagChild) -> RenderedDeps:
86+
return {"deps": [], "html": ""}
87+
88+
def send_input_message(self, id: str, message: dict[str, object]) -> None:
89+
return
90+
91+
def _send_insert_ui(
92+
self, selector: str, multiple: bool, where: str, content: RenderedDeps
93+
) -> None:
94+
return
95+
96+
def _send_remove_ui(self, selector: str, multiple: bool) -> None:
97+
return
98+
99+
def _send_progress(self, type: str, message: object) -> None:
100+
return
101+
102+
async def send_custom_message(self, type: str, message: dict[str, object]) -> None:
103+
return
104+
105+
def set_message_handler(
106+
self,
107+
name: str,
108+
handler: (
109+
Callable[..., Jsonifiable] | Callable[..., Awaitable[Jsonifiable]] | None
110+
),
111+
*,
112+
_handler_session: Optional[Session] = None,
113+
) -> str:
114+
return ""
115+
116+
async def _send_message(self, message: dict[str, object]) -> None:
117+
return
118+
119+
def _send_message_sync(self, message: dict[str, object]) -> None:
120+
return
121+
122+
def on_flush(
123+
self,
124+
fn: Callable[[], None] | Callable[[], Awaitable[None]],
125+
once: bool = True,
126+
) -> Callable[[], None]:
127+
return lambda: None
128+
129+
def on_flushed(
130+
self,
131+
fn: Callable[[], None] | Callable[[], Awaitable[None]],
132+
once: bool = True,
133+
) -> Callable[[], None]:
134+
return lambda: None
135+
136+
def dynamic_route(self, name: str, handler: DynamicRouteHandler) -> str:
137+
return ""
138+
139+
async def _unhandled_error(self, e: Exception) -> None:
140+
return
141+
142+
def download(
143+
self,
144+
id: Optional[str] = None,
145+
filename: Optional[str | Callable[[], str]] = None,
146+
media_type: None | str | Callable[[], str] = None,
147+
encoding: str = "utf-8",
148+
) -> Callable[[DownloadHandler], None]:
149+
return lambda x: None
150+
151+
def _not_implemented(self, name: str) -> Never:
152+
raise NotImplementedError(
153+
textwrap.dedent(
154+
f"""
155+
The session attribute `{name}` is not yet available for use. Since this code
156+
will run again when the session is initialized, you can use `if not session.is_stub_session():`
157+
to only run this code when the session is established.
158+
"""
159+
)
160+
)

shiny/reactive/_reactives.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737
from ._core import Context, Dependents, ReactiveWarning, isolate
3838

3939
if TYPE_CHECKING:
40-
from ..session import Session
40+
from .. import Session
4141

4242
T = TypeVar("T")
4343

@@ -477,8 +477,8 @@ def __init__(
477477
self.__name__ = fn.__name__
478478
self.__doc__ = fn.__doc__
479479

480-
from ..express._mock_session import ExpressMockSession
481480
from ..render.renderer import Renderer
481+
from ..session import Session
482482

483483
if isinstance(fn, Renderer):
484484
raise TypeError(
@@ -513,9 +513,9 @@ def __init__(
513513
# could be None if outside of a session).
514514
session = get_current_session()
515515

516-
if isinstance(session, ExpressMockSession):
517-
# If we're in an ExpressMockSession, then don't actually set up this effect
518-
# -- we don't want it to try to run later.
516+
if isinstance(session, Session) and session.is_stub_session():
517+
# If we're in an ExpressStubSession or a SessionProxy of one, then don't
518+
# actually set up this effect -- we don't want it to try to run later.
519519
return
520520

521521
self._session = session

shiny/render/_data_frame.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@
4949
if TYPE_CHECKING:
5050
import pandas as pd
5151

52-
from ..session._utils import Session
52+
from ..session import Session
5353

5454
DataFrameT = TypeVar("DataFrameT", bound=pd.DataFrame)
5555
# TODO-barret-render.data_frame; Pandas, Polars, api compat, etc.; Today, we only support Pandas

0 commit comments

Comments
 (0)