Skip to content

Commit 9454d83

Browse files
cpsievertwch
andcommitted
express.ui.page_opts(title = ...) now always generates a header (#1016)
Co-authored-by: Winston Chang <[email protected]>
1 parent 1c360a9 commit 9454d83

File tree

6 files changed

+155
-49
lines changed

6 files changed

+155
-49
lines changed

shiny/express/__init__.py

Lines changed: 12 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
from __future__ import annotations
22

3+
from typing import cast
4+
35
# Import these with underscore names so they won't show in autocomplete from the Python
46
# console.
5-
from ..session import Inputs as _Inputs, Outputs as _Outputs, Session as _Session
6-
from ..session import _utils as _session_utils
7+
from ..session import Inputs as _Inputs, Outputs as _Outputs
8+
from ..session._session import ExpressSession as _ExpressSession
9+
from ..session._utils import get_current_session
710
from .. import render
811
from . import ui
912
from ._is_express import is_express_app
@@ -30,7 +33,7 @@
3033
# Add types to help type checkers
3134
input: _Inputs
3235
output: _Outputs
33-
session: _Session
36+
session: _ExpressSession
3437

3538

3639
# Note that users should use `from shiny.express import input` instead of `from shiny
@@ -40,42 +43,15 @@
4043
# cases, but when it fails, it will be very confusing.
4144
def __getattr__(name: str) -> object:
4245
if name == "input":
43-
return _get_current_session_or_mock().input
46+
return get_express_session().input
4447
elif name == "output":
45-
return _get_current_session_or_mock().output
48+
return get_express_session().output
4649
elif name == "session":
47-
return _get_current_session_or_mock()
50+
return get_express_session()
4851

4952
raise AttributeError(f"Module 'shiny.express' has no attribute '{name}'")
5053

5154

52-
# A very bare-bones mock session class that is used only in shiny.express.
53-
class _MockSession:
54-
def __init__(self):
55-
from typing import cast
56-
57-
from .._namespaces import Root
58-
59-
self.input = _Inputs({})
60-
self.output = _Outputs(cast(_Session, self), Root, {}, {})
61-
62-
# This is needed so that Outputs don't throw an error.
63-
def _is_hidden(self, name: str) -> bool:
64-
return False
65-
66-
67-
_current_mock_session: _MockSession | None = None
68-
69-
70-
def _get_current_session_or_mock() -> _Session:
71-
from typing import cast
72-
73-
session = _session_utils.get_current_session()
74-
if session is None:
75-
global _current_mock_session
76-
if _current_mock_session is None:
77-
_current_mock_session = _MockSession()
78-
return cast(_Session, _current_mock_session)
79-
80-
else:
81-
return session
55+
# Express code gets executed twice: once with a MockSession and once with a real session.
56+
def get_express_session() -> _ExpressSession:
57+
return cast(_ExpressSession, get_current_session())

shiny/express/_run.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
from htmltools import Tag, TagList
99

1010
from .._app import App
11-
from ..session import Inputs, Outputs, Session
11+
from ..session import Inputs, Outputs, Session, session_context
12+
from ..session._session import MockSession
1213
from ._recall_context import RecallContextManager
1314
from .display_decorator._func_displayhook import _display_decorator_function_def
1415
from .display_decorator._node_transformers import (
@@ -39,7 +40,8 @@ def wrap_express_app(file: Path) -> App:
3940
# catch them here and convert them to a different type of error, because uvicorn
4041
# specifically catches AttributeErrors and prints an error message that is
4142
# misleading for Shiny Express. https://github.com/posit-dev/py-shiny/issues/937
42-
app_ui = run_express(file).tagify()
43+
with session_context(cast(Session, MockSession())):
44+
app_ui = run_express(file).tagify()
4345
except AttributeError as e:
4446
raise RuntimeError(e) from e
4547

shiny/express/ui/_page.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ def page_auto_cm() -> RecallContextManager[Tag]:
1919
def page_opts(
2020
*,
2121
title: str | MISSING_TYPE = MISSING,
22+
window_title: str | MISSING_TYPE = MISSING,
2223
lang: str | MISSING_TYPE = MISSING,
2324
page_fn: Callable[..., Tag] | None | MISSING_TYPE = MISSING,
2425
fillable: bool | MISSING_TYPE = MISSING,
@@ -32,7 +33,10 @@ def page_opts(
3233
Parameters
3334
----------
3435
title
35-
The browser window title (defaults to the host URL of the page).
36+
A title shown on the page.
37+
window_title
38+
The browser window title. If no value is provided, this will use the value of
39+
``title``.
3640
lang
3741
ISO 639-1 language code for the HTML page, such as ``"en"`` or ``"ko"``. This
3842
will be used as the lang in the ``<html>`` tag, as in ``<html lang="en">``. The
@@ -56,6 +60,8 @@ def page_opts(
5660

5761
if not isinstance(title, MISSING_TYPE):
5862
cm.kwargs["title"] = title
63+
if not isinstance(window_title, MISSING_TYPE):
64+
cm.kwargs["window_title"] = window_title
5965
if not isinstance(lang, MISSING_TYPE):
6066
cm.kwargs["lang"] = lang
6167
if not isinstance(page_fn, MISSING_TYPE):

shiny/session/_session.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import json
1212
import os
1313
import re
14+
import textwrap
1415
import traceback
1516
import typing
1617
import urllib.parse
@@ -1089,3 +1090,41 @@ def _manage_hidden(self) -> None:
10891090

10901091
def _should_suspend(self, name: str) -> bool:
10911092
return self._suspend_when_hidden[name] and self._session._is_hidden(name)
1093+
1094+
1095+
# A bare-bones mock session class that is used only in shiny.express.
1096+
class MockSession:
1097+
ns: ResolvedId = Root
1098+
1099+
def __init__(self):
1100+
from typing import cast
1101+
1102+
self.input = Inputs({})
1103+
self.output = Outputs(cast(Session, self), Root, {}, {})
1104+
1105+
# Needed so that Outputs don't throw an error.
1106+
def _is_hidden(self, name: str) -> bool:
1107+
return False
1108+
1109+
# Needed so that observers don't throw an error.
1110+
def on_ended(self, *args: object, **kwargs: object) -> None:
1111+
pass
1112+
1113+
def __bool__(self) -> bool:
1114+
return False
1115+
1116+
def __getattr__(self, name: str):
1117+
raise AttributeError(
1118+
textwrap.dedent(
1119+
f"""
1120+
The session attribute `{name}` is not yet available for use.
1121+
Since this code will run again when the session is initialized,
1122+
you can use `if session:` to only run this code when the session is
1123+
established.
1124+
"""
1125+
)
1126+
)
1127+
1128+
1129+
# Express code gets evaluated twice: once with a MockSession, and once with a real one
1130+
ExpressSession = MockSession | Session

shiny/session/_utils.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717

1818
from .._namespaces import namespace_context
1919
from .._typing_extensions import TypedDict
20+
from .._validation import req
21+
from ..reactive import get_current_context
2022

2123

2224
class RenderedDeps(TypedDict):
@@ -30,7 +32,6 @@ class RenderedDeps(TypedDict):
3032
_current_session: ContextVar[Optional[Session]] = ContextVar(
3133
"current_session", default=None
3234
)
33-
_default_session: Optional[Session] = None
3435

3536

3637
def get_current_session() -> Optional[Session]:
@@ -52,7 +53,7 @@ def get_current_session() -> Optional[Session]:
5253
-------
5354
~require_active_session
5455
"""
55-
return _current_session.get() or _default_session
56+
return _current_session.get()
5657

5758

5859
@contextmanager
@@ -127,6 +128,12 @@ def require_active_session(session: Optional[Session]) -> Session:
127128
raise RuntimeError(
128129
f"{calling_fn_name}() must be called from within an active Shiny session."
129130
)
131+
132+
# If session is falsy (i.e., it's a MockSession) and there's a context,
133+
# throw a silent exception since this code will run again with an actual session.
134+
if not session and has_current_context():
135+
req(False)
136+
130137
return session
131138

132139

@@ -150,3 +157,11 @@ def read_thunk_opt(thunk: Optional[Callable[[], T] | T]) -> Optional[T]:
150157
return thunk()
151158
else:
152159
return thunk
160+
161+
162+
def has_current_context() -> bool:
163+
try:
164+
get_current_context()
165+
return True
166+
except RuntimeError:
167+
return False

shiny/ui/_page.py

Lines changed: 76 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
"page_output",
1212
)
1313

14-
from typing import Callable, Literal, Optional, Sequence, cast
14+
from typing import Any, Callable, Literal, Optional, Sequence, cast
1515

1616
from htmltools import (
1717
MetadataNode,
@@ -29,6 +29,7 @@
2929
from .._docstring import add_example
3030
from .._namespaces import resolve_id_or_none
3131
from ..types import MISSING, MISSING_TYPE, NavSetArg
32+
from ._bootstrap import panel_title
3233
from ._html_deps_external import bootstrap_deps
3334
from ._html_deps_py_shiny import page_output_dependency
3435
from ._html_deps_shinyverse import components_dependency
@@ -447,6 +448,7 @@ def page_bootstrap(
447448
def page_auto(
448449
*args: TagChild | TagAttrs,
449450
title: str | MISSING_TYPE = MISSING,
451+
window_title: str | MISSING_TYPE = MISSING,
450452
lang: str | MISSING_TYPE = MISSING,
451453
fillable: bool | MISSING_TYPE = MISSING,
452454
full_width: bool = False,
@@ -468,7 +470,10 @@ def page_auto(
468470
UI elements. These are used to determine which page function to use, and they
469471
are also passed along to that page function.
470472
title
471-
The browser window title (defaults to the host URL of the page).
473+
A title shown on the page.
474+
window_title
475+
The browser window title. If no value is provided, this will use the value of
476+
``title``.
472477
lang
473478
ISO 639-1 language code for the HTML page, such as ``"en"`` or ``"ko"``. This
474479
will be used as the lang in the ``<html>`` tag, as in ``<html lang="en">``. The
@@ -497,6 +502,8 @@ def page_auto(
497502
"""
498503
if not isinstance(title, MISSING_TYPE):
499504
kwargs["title"] = title
505+
if not isinstance(window_title, MISSING_TYPE):
506+
kwargs["window_title"] = window_title
500507
if not isinstance(lang, MISSING_TYPE):
501508
kwargs["lang"] = lang
502509

@@ -513,11 +520,11 @@ def page_auto(
513520
fillable = False
514521

515522
if fillable:
516-
page_fn = page_fillable # pyright: ignore[reportGeneralTypeIssues]
523+
page_fn = _page_auto_fillable
517524
elif full_width:
518-
page_fn = page_fluid # pyright: ignore[reportGeneralTypeIssues]
525+
page_fn = _page_auto_fluid
519526
else:
520-
page_fn = page_fixed # pyright: ignore[reportGeneralTypeIssues]
527+
page_fn = _page_auto_fixed
521528

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

532539
else:
@@ -541,12 +548,12 @@ def page_auto(
541548

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

546553
elif nSidebars == 1:
547554
# TODO: change page_navbar() to remove `sidebar` and accept a sidebar as a
548555
# *arg.
549-
page_fn = page_navbar # pyright: ignore[reportGeneralTypeIssues]
556+
page_fn = page_navbar
550557
args = tuple([x for x in args if x not in sidebars])
551558
kwargs["sidebar"] = sidebars[0]
552559

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

562569

570+
# For `page_fillable`, `page_fluid`, and `page_fixed`, the `title` arg sets the window
571+
# title, but doesn't add anything visible on the page.
572+
#
573+
# In contrast, for `page_auto`, the `title` arg adds a title panel to the page, and the
574+
# `window_title` arg sets the window title.
575+
#
576+
# The wrapper functions below provide the `page_auto` interface, where `title` to add a
577+
# title panel to the page, and `window_title` to set the title of the window. If `title`
578+
# is provided but `window_title` is not, then `window_title` is set to the value of
579+
# `title`.
580+
def _page_auto_fillable(
581+
*args: TagChild | TagAttrs,
582+
title: str | None = None,
583+
window_title: str | None = None,
584+
**kwargs: Any,
585+
) -> Tag:
586+
if window_title is None and title is not None:
587+
window_title = title
588+
589+
return page_fillable(
590+
None if title is None else panel_title(title),
591+
*args,
592+
title=window_title,
593+
**kwargs,
594+
)
595+
596+
597+
def _page_auto_fluid(
598+
*args: TagChild | TagAttrs,
599+
title: str | None = None,
600+
window_title: str | None = None,
601+
**kwargs: str,
602+
) -> Tag:
603+
if window_title is None and title is not None:
604+
window_title = title
605+
606+
return page_fluid(
607+
None if title is None else panel_title(title),
608+
*args,
609+
title=window_title,
610+
**kwargs,
611+
)
612+
613+
614+
def _page_auto_fixed(
615+
*args: TagChild | TagAttrs,
616+
title: str | None = None,
617+
window_title: str | None = None,
618+
**kwargs: Any,
619+
) -> Tag:
620+
if window_title is None and title is not None:
621+
window_title = title
622+
623+
return page_fixed(
624+
None if title is None else panel_title(title),
625+
*args,
626+
title=window_title,
627+
**kwargs,
628+
)
629+
630+
563631
def page_output(id: str) -> Tag:
564632
"""
565633
Create a page container where the entire body is a UI output.

0 commit comments

Comments
 (0)