Skip to content

Commit

Permalink
Close #1034. Set a MockSession context for the initial execution of e…
Browse files Browse the repository at this point in the history
…xpress code
  • Loading branch information
cpsievert committed Jan 19, 2024
1 parent de2b063 commit 81d7d08
Show file tree
Hide file tree
Showing 4 changed files with 72 additions and 40 deletions.
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 @@ -30,7 +33,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 @@ -40,42 +43,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 .display_decorator._func_displayhook import _display_decorator_function_def
from .display_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

0 comments on commit 81d7d08

Please sign in to comment.