Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Set a MockSession context for the initial execution of express code #1038

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 12 additions & 36 deletions shiny/express/__init__.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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())
6 changes: 4 additions & 2 deletions shiny/express/_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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

Expand Down
39 changes: 39 additions & 0 deletions shiny/session/_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import json
import os
import re
import textwrap
import traceback
import typing
import urllib.parse
Expand Down Expand Up @@ -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
19 changes: 17 additions & 2 deletions shiny/session/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -31,7 +33,6 @@ class RenderedDeps(TypedDict):
_current_session: ContextVar[Optional[Session]] = ContextVar(
"current_session", default=None
)
_default_session: Optional[Session] = None


@no_example
Expand All @@ -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
Expand Down Expand Up @@ -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


Expand All @@ -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
Loading