Skip to content

Commit 81d7d08

Browse files
committed
Close #1034. Set a MockSession context for the initial execution of express code
1 parent de2b063 commit 81d7d08

File tree

4 files changed

+72
-40
lines changed

4 files changed

+72
-40
lines changed

shiny/express/__init__.py

Lines changed: 12 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
from __future__ import annotations
22

3+
from typing import cast
4+
35
# Import these with underscore names so they won't show in autocomplete from the Python
46
# console.
5-
from ..session import Inputs as _Inputs, Outputs as _Outputs, Session as _Session
6-
from ..session import _utils as _session_utils
7+
from ..session import Inputs as _Inputs, Outputs as _Outputs
8+
from ..session._session import ExpressSession as _ExpressSession
9+
from ..session._utils import get_current_session
710
from .. import render
811
from . import ui
912
from ._is_express import is_express_app
@@ -30,7 +33,7 @@
3033
# Add types to help type checkers
3134
input: _Inputs
3235
output: _Outputs
33-
session: _Session
36+
session: _ExpressSession
3437

3538

3639
# Note that users should use `from shiny.express import input` instead of `from shiny
@@ -40,42 +43,15 @@
4043
# cases, but when it fails, it will be very confusing.
4144
def __getattr__(name: str) -> object:
4245
if name == "input":
43-
return _get_current_session_or_mock().input
46+
return get_express_session().input
4447
elif name == "output":
45-
return _get_current_session_or_mock().output
48+
return get_express_session().output
4649
elif name == "session":
47-
return _get_current_session_or_mock()
50+
return get_express_session()
4851

4952
raise AttributeError(f"Module 'shiny.express' has no attribute '{name}'")
5053

5154

52-
# A very bare-bones mock session class that is used only in shiny.express.
53-
class _MockSession:
54-
def __init__(self):
55-
from typing import cast
56-
57-
from .._namespaces import Root
58-
59-
self.input = _Inputs({})
60-
self.output = _Outputs(cast(_Session, self), Root, {}, {})
61-
62-
# This is needed so that Outputs don't throw an error.
63-
def _is_hidden(self, name: str) -> bool:
64-
return False
65-
66-
67-
_current_mock_session: _MockSession | None = None
68-
69-
70-
def _get_current_session_or_mock() -> _Session:
71-
from typing import cast
72-
73-
session = _session_utils.get_current_session()
74-
if session is None:
75-
global _current_mock_session
76-
if _current_mock_session is None:
77-
_current_mock_session = _MockSession()
78-
return cast(_Session, _current_mock_session)
79-
80-
else:
81-
return session
55+
# Express code gets executed twice: once with a MockSession and once with a real session.
56+
def get_express_session() -> _ExpressSession:
57+
return cast(_ExpressSession, get_current_session())

shiny/express/_run.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
from htmltools import Tag, TagList
99

1010
from .._app import App
11-
from ..session import Inputs, Outputs, Session
11+
from ..session import Inputs, Outputs, Session, session_context
12+
from ..session._session import MockSession
1213
from ._recall_context import RecallContextManager
1314
from .display_decorator._func_displayhook import _display_decorator_function_def
1415
from .display_decorator._node_transformers import (
@@ -39,7 +40,8 @@ def wrap_express_app(file: Path) -> App:
3940
# catch them here and convert them to a different type of error, because uvicorn
4041
# specifically catches AttributeErrors and prints an error message that is
4142
# misleading for Shiny Express. https://github.com/posit-dev/py-shiny/issues/937
42-
app_ui = run_express(file).tagify()
43+
with session_context(cast(Session, MockSession())):
44+
app_ui = run_express(file).tagify()
4345
except AttributeError as e:
4446
raise RuntimeError(e) from e
4547

shiny/session/_session.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import json
1212
import os
1313
import re
14+
import textwrap
1415
import traceback
1516
import typing
1617
import urllib.parse
@@ -1157,3 +1158,41 @@ def _manage_hidden(self) -> None:
11571158

11581159
def _should_suspend(self, name: str) -> bool:
11591160
return self._suspend_when_hidden[name] and self._session._is_hidden(name)
1161+
1162+
1163+
# A bare-bones mock session class that is used only in shiny.express.
1164+
class MockSession:
1165+
ns: ResolvedId = Root
1166+
1167+
def __init__(self):
1168+
from typing import cast
1169+
1170+
self.input = Inputs({})
1171+
self.output = Outputs(cast(Session, self), Root, {}, {})
1172+
1173+
# Needed so that Outputs don't throw an error.
1174+
def _is_hidden(self, name: str) -> bool:
1175+
return False
1176+
1177+
# Needed so that observers don't throw an error.
1178+
def on_ended(self, *args: object, **kwargs: object) -> None:
1179+
pass
1180+
1181+
def __bool__(self) -> bool:
1182+
return False
1183+
1184+
def __getattr__(self, name: str):
1185+
raise AttributeError(
1186+
textwrap.dedent(
1187+
f"""
1188+
The session attribute `{name}` is not yet available for use.
1189+
Since this code will run again when the session is initialized,
1190+
you can use `if session:` to only run this code when the session is
1191+
established.
1192+
"""
1193+
)
1194+
)
1195+
1196+
1197+
# Express code gets evaluated twice: once with a MockSession, and once with a real one
1198+
ExpressSession = MockSession | Session

shiny/session/_utils.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
from .._docstring import no_example
1919
from .._namespaces import namespace_context
2020
from .._typing_extensions import TypedDict
21+
from .._validation import req
22+
from ..reactive import get_current_context
2123

2224

2325
class RenderedDeps(TypedDict):
@@ -31,7 +33,6 @@ class RenderedDeps(TypedDict):
3133
_current_session: ContextVar[Optional[Session]] = ContextVar(
3234
"current_session", default=None
3335
)
34-
_default_session: Optional[Session] = None
3536

3637

3738
@no_example
@@ -54,7 +55,7 @@ def get_current_session() -> Optional[Session]:
5455
-------
5556
~require_active_session
5657
"""
57-
return _current_session.get() or _default_session
58+
return _current_session.get()
5859

5960

6061
@contextmanager
@@ -130,6 +131,12 @@ def require_active_session(session: Optional[Session]) -> Session:
130131
raise RuntimeError(
131132
f"{calling_fn_name}() must be called from within an active Shiny session."
132133
)
134+
135+
# If session is falsy (i.e., it's a MockSession) and there's a context,
136+
# throw a silent exception since this code will run again with an actual session.
137+
if not session and has_current_context():
138+
req(False)
139+
133140
return session
134141

135142

@@ -153,3 +160,11 @@ def read_thunk_opt(thunk: Optional[Callable[[], T] | T]) -> Optional[T]:
153160
return thunk()
154161
else:
155162
return thunk
163+
164+
165+
def has_current_context() -> bool:
166+
try:
167+
get_current_context()
168+
return True
169+
except RuntimeError:
170+
return False

0 commit comments

Comments
 (0)