diff --git a/shiny/express/__init__.py b/shiny/express/__init__.py index 4896159a2..9e6b7cf9f 100644 --- a/shiny/express/__init__.py +++ b/shiny/express/__init__.py @@ -2,8 +2,12 @@ # 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, + Session as _Session, + get_current_session as _get_current_session, +) from .. import render from . import ui from ._is_express import is_express_app @@ -39,42 +43,10 @@ # 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_current_session().input # pyright: ignore elif name == "output": - return _get_current_session_or_mock().output + return _get_current_session().output # pyright: ignore elif name == "session": - return _get_current_session_or_mock() + return _get_current_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 diff --git a/shiny/express/_mock_session.py b/shiny/express/_mock_session.py new file mode 100644 index 000000000..6eab21e9e --- /dev/null +++ b/shiny/express/_mock_session.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import textwrap +from typing import Awaitable, Callable, cast + +from .._namespaces import Root +from ..session import Inputs, Outputs, Session + +all = ("MockSession",) + + +# A very bare-bones mock session class that is used only in shiny.express's UI rendering +# phase. +class MockSession: + def __init__(self): + self.ns = Root + self.input = Inputs({}) + self.output = Outputs(cast(Session, self), self.ns, {}, {}) + + # 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 __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 3261cc1e3..68fb8b035 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 ._mock_session import MockSession from ._recall_context import RecallContextManager from .expressify_decorator._func_displayhook import _expressify_decorator_function_def from .expressify_decorator._node_transformers import ( @@ -33,13 +34,15 @@ def wrap_express_app(file: Path) -> App: A `shiny.App` object. """ try: - # 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 - # 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())): + # 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 + # 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() + except AttributeError as e: raise RuntimeError(e) from e diff --git a/shiny/reactive/_reactives.py b/shiny/reactive/_reactives.py index bf5dd3644..8f3f49de7 100644 --- a/shiny/reactive/_reactives.py +++ b/shiny/reactive/_reactives.py @@ -480,6 +480,7 @@ def __init__( self.__name__ = fn.__name__ self.__doc__ = fn.__doc__ + from ..express._mock_session import MockSession from ..render.renderer import Renderer if isinstance(fn, Renderer): @@ -514,6 +515,12 @@ def __init__( # If no session is provided, autodetect the current session (this # could be None if outside of a session). session = get_current_session() + + if isinstance(session, MockSession): + # If we're in a MockSession, then don't actually set up this Effect -- we + # don't want it to try to run later. + return + self._session = session if self._session is not None: