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 1c360a9 commit b702ecf
Show file tree
Hide file tree
Showing 6 changed files with 155 additions and 49 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
8 changes: 7 additions & 1 deletion shiny/express/ui/_page.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ def page_auto_cm() -> RecallContextManager[Tag]:
def page_opts(
*,
title: str | MISSING_TYPE = MISSING,
window_title: str | MISSING_TYPE = MISSING,
lang: str | MISSING_TYPE = MISSING,
page_fn: Callable[..., Tag] | None | MISSING_TYPE = MISSING,
fillable: bool | MISSING_TYPE = MISSING,
Expand All @@ -32,7 +33,10 @@ def page_opts(
Parameters
----------
title
The browser window title (defaults to the host URL of the page).
A title shown on the page.
window_title
The browser window title. If no value is provided, this will use the value of
``title``.
lang
ISO 639-1 language code for the HTML page, such as ``"en"`` or ``"ko"``. This
will be used as the lang in the ``<html>`` tag, as in ``<html lang="en">``. The
Expand All @@ -56,6 +60,8 @@ def page_opts(

if not isinstance(title, MISSING_TYPE):
cm.kwargs["title"] = title
if not isinstance(window_title, MISSING_TYPE):
cm.kwargs["window_title"] = window_title
if not isinstance(lang, MISSING_TYPE):
cm.kwargs["lang"] = lang
if not isinstance(page_fn, MISSING_TYPE):
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 @@ -1089,3 +1090,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 @@ -17,6 +17,8 @@

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 @@ -30,7 +32,6 @@ class RenderedDeps(TypedDict):
_current_session: ContextVar[Optional[Session]] = ContextVar(
"current_session", default=None
)
_default_session: Optional[Session] = None


def get_current_session() -> Optional[Session]:
Expand All @@ -52,7 +53,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 @@ -127,6 +128,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 @@ -150,3 +157,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
84 changes: 76 additions & 8 deletions shiny/ui/_page.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
"page_output",
)

from typing import Callable, Literal, Optional, Sequence, cast
from typing import Any, Callable, Literal, Optional, Sequence, cast

from htmltools import (
MetadataNode,
Expand All @@ -29,6 +29,7 @@
from .._docstring import add_example
from .._namespaces import resolve_id_or_none
from ..types import MISSING, MISSING_TYPE, NavSetArg
from ._bootstrap import panel_title
from ._html_deps_external import bootstrap_deps
from ._html_deps_py_shiny import page_output_dependency
from ._html_deps_shinyverse import components_dependency
Expand Down Expand Up @@ -447,6 +448,7 @@ def page_bootstrap(
def page_auto(
*args: TagChild | TagAttrs,
title: str | MISSING_TYPE = MISSING,
window_title: str | MISSING_TYPE = MISSING,
lang: str | MISSING_TYPE = MISSING,
fillable: bool | MISSING_TYPE = MISSING,
full_width: bool = False,
Expand All @@ -468,7 +470,10 @@ def page_auto(
UI elements. These are used to determine which page function to use, and they
are also passed along to that page function.
title
The browser window title (defaults to the host URL of the page).
A title shown on the page.
window_title
The browser window title. If no value is provided, this will use the value of
``title``.
lang
ISO 639-1 language code for the HTML page, such as ``"en"`` or ``"ko"``. This
will be used as the lang in the ``<html>`` tag, as in ``<html lang="en">``. The
Expand Down Expand Up @@ -497,6 +502,8 @@ def page_auto(
"""
if not isinstance(title, MISSING_TYPE):
kwargs["title"] = title
if not isinstance(window_title, MISSING_TYPE):
kwargs["window_title"] = window_title
if not isinstance(lang, MISSING_TYPE):
kwargs["lang"] = lang

Expand All @@ -513,11 +520,11 @@ def page_auto(
fillable = False

if fillable:
page_fn = page_fillable # pyright: ignore[reportGeneralTypeIssues]
page_fn = _page_auto_fillable
elif full_width:
page_fn = page_fluid # pyright: ignore[reportGeneralTypeIssues]
page_fn = _page_auto_fluid
else:
page_fn = page_fixed # pyright: ignore[reportGeneralTypeIssues]
page_fn = _page_auto_fixed

elif nSidebars == 1:
if not isinstance(fillable, MISSING_TYPE):
Expand All @@ -526,7 +533,7 @@ def page_auto(
# page_sidebar() needs sidebar to be the first arg
# TODO: Change page_sidebar() to remove `sidebar` and accept a sidebar as a
# *arg.
page_fn = page_sidebar # pyright: ignore[reportGeneralTypeIssues]
page_fn = page_sidebar
args = tuple(sidebars + [x for x in args if x not in sidebars])

else:
Expand All @@ -541,12 +548,12 @@ def page_auto(

if nSidebars == 0:
# TODO: what do we do when nArgs != nNavs? Just let page_navbar handle it (i.e. error)?
page_fn = page_navbar # pyright: ignore[reportGeneralTypeIssues]
page_fn = page_navbar

elif nSidebars == 1:
# TODO: change page_navbar() to remove `sidebar` and accept a sidebar as a
# *arg.
page_fn = page_navbar # pyright: ignore[reportGeneralTypeIssues]
page_fn = page_navbar
args = tuple([x for x in args if x not in sidebars])
kwargs["sidebar"] = sidebars[0]

Expand All @@ -560,6 +567,67 @@ def page_auto(
return page_fn(*args, **kwargs)


# For `page_fillable`, `page_fluid`, and `page_fixed`, the `title` arg sets the window
# title, but doesn't add anything visible on the page.
#
# In contrast, for `page_auto`, the `title` arg adds a title panel to the page, and the
# `window_title` arg sets the window title.
#
# The wrapper functions below provide the `page_auto` interface, where `title` to add a
# title panel to the page, and `window_title` to set the title of the window. If `title`
# is provided but `window_title` is not, then `window_title` is set to the value of
# `title`.
def _page_auto_fillable(
*args: TagChild | TagAttrs,
title: str | None = None,
window_title: str | None = None,
**kwargs: Any,
) -> Tag:
if window_title is None and title is not None:
window_title = title

return page_fillable(
None if title is None else panel_title(title),
*args,
title=window_title,
**kwargs,
)


def _page_auto_fluid(
*args: TagChild | TagAttrs,
title: str | None = None,
window_title: str | None = None,
**kwargs: str,
) -> Tag:
if window_title is None and title is not None:
window_title = title

return page_fluid(
None if title is None else panel_title(title),
*args,
title=window_title,
**kwargs,
)


def _page_auto_fixed(
*args: TagChild | TagAttrs,
title: str | None = None,
window_title: str | None = None,
**kwargs: Any,
) -> Tag:
if window_title is None and title is not None:
window_title = title

return page_fixed(
None if title is None else panel_title(title),
*args,
title=window_title,
**kwargs,
)


def page_output(id: str) -> Tag:
"""
Create a page container where the entire body is a UI output.
Expand Down

0 comments on commit b702ecf

Please sign in to comment.