diff --git a/shiny/express/__init__.py b/shiny/express/__init__.py index 4896159a2..21be74f09 100644 --- a/shiny/express/__init__.py +++ b/shiny/express/__init__.py @@ -1,9 +1,12 @@ from __future__ import annotations +from typing import cast + # Import these with underscore names so they won't show in autocomplete from the Python # console. -from ..session import Inputs as _Inputs, Outputs as _Outputs, Session as _Session -from ..session import _utils as _session_utils +from ..session import Inputs as _Inputs, Outputs as _Outputs +from ..session._session import ExpressSession as _ExpressSession +from ..session._utils import get_current_session from .. import render from . import ui from ._is_express import is_express_app @@ -29,7 +32,7 @@ # Add types to help type checkers input: _Inputs output: _Outputs -session: _Session +session: _ExpressSession # Note that users should use `from shiny.express import input` instead of `from shiny @@ -39,42 +42,15 @@ # cases, but when it fails, it will be very confusing. def __getattr__(name: str) -> object: if name == "input": - return _get_current_session_or_mock().input + return get_express_session().input elif name == "output": - return _get_current_session_or_mock().output + return get_express_session().output elif name == "session": - return _get_current_session_or_mock() + return get_express_session() raise AttributeError(f"Module 'shiny.express' has no attribute '{name}'") -# A very bare-bones mock session class that is used only in shiny.express. -class _MockSession: - def __init__(self): - from typing import cast - - from .._namespaces import Root - - self.input = _Inputs({}) - self.output = _Outputs(cast(_Session, self), Root, {}, {}) - - # This is needed so that Outputs don't throw an error. - def _is_hidden(self, name: str) -> bool: - return False - - -_current_mock_session: _MockSession | None = None - - -def _get_current_session_or_mock() -> _Session: - from typing import cast - - session = _session_utils.get_current_session() - if session is None: - global _current_mock_session - if _current_mock_session is None: - _current_mock_session = _MockSession() - return cast(_Session, _current_mock_session) - - else: - return session +# Express code gets executed twice: once with a MockSession and once with a real session. +def get_express_session() -> _ExpressSession: + return cast(_ExpressSession, get_current_session()) diff --git a/shiny/express/_run.py b/shiny/express/_run.py index 3261cc1e3..00cde4854 100644 --- a/shiny/express/_run.py +++ b/shiny/express/_run.py @@ -8,7 +8,8 @@ from htmltools import Tag, TagList from .._app import App -from ..session import Inputs, Outputs, Session +from ..session import Inputs, Outputs, Session, session_context +from ..session._session import MockSession from ._recall_context import RecallContextManager from .expressify_decorator._func_displayhook import _expressify_decorator_function_def from .expressify_decorator._node_transformers import ( @@ -39,7 +40,8 @@ def wrap_express_app(file: Path) -> App: # catch them here and convert them to a different type of error, because uvicorn # specifically catches AttributeErrors and prints an error message that is # misleading for Shiny Express. https://github.com/posit-dev/py-shiny/issues/937 - app_ui = run_express(file).tagify() + with session_context(cast(Session, MockSession())): + app_ui = run_express(file).tagify() except AttributeError as e: raise RuntimeError(e) from e diff --git a/shiny/session/_session.py b/shiny/session/_session.py index aff11f25e..02378e6a4 100644 --- a/shiny/session/_session.py +++ b/shiny/session/_session.py @@ -11,6 +11,7 @@ import json import os import re +import textwrap import traceback import typing import urllib.parse @@ -1157,3 +1158,41 @@ def _manage_hidden(self) -> None: def _should_suspend(self, name: str) -> bool: return self._suspend_when_hidden[name] and self._session._is_hidden(name) + + +# A bare-bones mock session class that is used only in shiny.express. +class MockSession: + ns: ResolvedId = Root + + def __init__(self): + from typing import cast + + self.input = Inputs({}) + self.output = Outputs(cast(Session, self), Root, {}, {}) + + # Needed so that Outputs don't throw an error. + def _is_hidden(self, name: str) -> bool: + return False + + # Needed so that observers don't throw an error. + def on_ended(self, *args: object, **kwargs: object) -> None: + pass + + def __bool__(self) -> bool: + return False + + 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. + """ + ) + ) + + +# Express code gets evaluated twice: once with a MockSession, and once with a real one +ExpressSession = MockSession | Session diff --git a/shiny/session/_utils.py b/shiny/session/_utils.py index dba7c3ce7..f07ae891d 100644 --- a/shiny/session/_utils.py +++ b/shiny/session/_utils.py @@ -18,6 +18,8 @@ from .._docstring import no_example from .._namespaces import namespace_context from .._typing_extensions import TypedDict +from .._validation import req +from ..reactive import get_current_context class RenderedDeps(TypedDict): @@ -31,7 +33,6 @@ class RenderedDeps(TypedDict): _current_session: ContextVar[Optional[Session]] = ContextVar( "current_session", default=None ) -_default_session: Optional[Session] = None @no_example @@ -54,7 +55,7 @@ def get_current_session() -> Optional[Session]: ------- ~require_active_session """ - return _current_session.get() or _default_session + return _current_session.get() @contextmanager @@ -130,6 +131,12 @@ def require_active_session(session: Optional[Session]) -> Session: raise RuntimeError( f"{calling_fn_name}() must be called from within an active Shiny session." ) + + # If session is falsy (i.e., it's a MockSession) and there's a context, + # throw a silent exception since this code will run again with an actual session. + if not session and has_current_context(): + req(False) + return session @@ -153,3 +160,11 @@ def read_thunk_opt(thunk: Optional[Callable[[], T] | T]) -> Optional[T]: return thunk() else: return thunk + + +def has_current_context() -> bool: + try: + get_current_context() + return True + except RuntimeError: + return False