diff --git a/shiny/express/__init__.py b/shiny/express/__init__.py index 8add0557ac..d13fc3ae80 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 @@ -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 @@ -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()) diff --git a/shiny/express/_run.py b/shiny/express/_run.py index d65190b1ef..5d6242c6c6 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 .display_decorator._func_displayhook import _display_decorator_function_def from .display_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/express/ui/_page.py b/shiny/express/ui/_page.py index 50a67d7bed..5ebe2f75c2 100644 --- a/shiny/express/ui/_page.py +++ b/shiny/express/ui/_page.py @@ -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, @@ -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 ```` tag, as in ````. The @@ -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): diff --git a/shiny/session/_session.py b/shiny/session/_session.py index c8791f1c27..d6dc503477 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 @@ -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 diff --git a/shiny/session/_utils.py b/shiny/session/_utils.py index dd1c255cda..09bcdfcce3 100644 --- a/shiny/session/_utils.py +++ b/shiny/session/_utils.py @@ -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): @@ -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]: @@ -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 @@ -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 @@ -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 diff --git a/shiny/ui/_page.py b/shiny/ui/_page.py index 7a56b7daad..1675f4b485 100644 --- a/shiny/ui/_page.py +++ b/shiny/ui/_page.py @@ -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, @@ -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 @@ -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, @@ -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 ```` tag, as in ````. The @@ -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 @@ -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): @@ -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: @@ -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] @@ -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.