Skip to content

Commit 2b66c89

Browse files
authored
Add support for Shiny Express in Quarto Dashboards, take 2 (#1217)
1 parent 518fc05 commit 2b66c89

File tree

5 files changed

+70
-6
lines changed

5 files changed

+70
-6
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1313

1414
* `ui.card()` and `ui.value_box()` now take an `id` argument that, when provided, is used to report the full screen state of the card or value box to the server. For example, when using `ui.card(id = "my_card", full_screen = TRUE)` you can determine if the card is currently in full screen mode by reading the boolean value of `input.my_card()["full_screen"]`. (#1215)
1515

16+
* Added support for using `shiny.express` in Quarto Dashboards. (#1217)
17+
1618
### Bug fixes
1719

1820
* On Windows, Shiny Express app files are now read in as UTF-8. (#1203)

shiny/express/_is_express.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@
55
from __future__ import annotations
66

77
import ast
8+
import re
9+
import sys
810
from pathlib import Path
11+
from typing import Literal, cast
912

1013
from .._docstring import no_example
1114

@@ -42,8 +45,16 @@ def is_express_app(app: str, app_dir: str | None) -> bool:
4245

4346
try:
4447
# Read the file, parse it, and look for any imports of shiny.express.
45-
with open(app_path) as f:
48+
with open(app_path, encoding="utf-8") as f:
4649
content = f.read()
50+
51+
# Check for magic comment in the first 1000 characters
52+
forced_mode = find_magic_comment_mode(content[:1000])
53+
if forced_mode == "express":
54+
return True
55+
elif forced_mode == "core":
56+
return False
57+
4758
tree = ast.parse(content, app_path)
4859
detector = DetectShinyExpressVisitor()
4960
detector.visit(tree)
@@ -78,3 +89,28 @@ def visit_Module(self, node: ast.Module) -> None:
7889
# Don't recurse into any nodes, so the we'll only ever look at top-level nodes.
7990
def generic_visit(self, node: ast.AST) -> None:
8091
pass
92+
93+
94+
def find_magic_comment_mode(content: str) -> Literal["core", "express"] | None:
95+
"""
96+
Look for a magic comment of the form "# shiny_mode: express" or "# shiny_mode:
97+
core".
98+
99+
If a line of the form "# shiny_mode: x" is found, where "x" is not "express" or
100+
"core", then a message will be printed to stderr.
101+
102+
Returns
103+
-------
104+
:
105+
`True` if Shiny Express comment is found, `False` if Shiny Core comment is
106+
found, and `None` if no magic comment is found.
107+
"""
108+
m = re.search(r"^#[ \t]*shiny_mode:[ \t]*(\S*)[ \t]*$", content, re.MULTILINE)
109+
if m is not None:
110+
shiny_mode = cast(str, m.group(1))
111+
if shiny_mode in ("express", "core"):
112+
return shiny_mode
113+
else:
114+
print(f'Invalid shiny_mode: "{shiny_mode}"', file=sys.stderr)
115+
116+
return None

shiny/express/_run.py

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from .._utils import import_module_from_path
1414
from ..session import Inputs, Outputs, Session, get_current_session, session_context
1515
from ..types import MISSING, MISSING_TYPE
16+
from ._is_express import find_magic_comment_mode
1617
from ._mock_session import ExpressMockSession
1718
from ._recall_context import RecallContextManager
1819
from .expressify_decorator._func_displayhook import _expressify_decorator_function_def
@@ -144,8 +145,14 @@ def set_result(x: object):
144145
get_top_level_recall_context_manager().__exit__(None, None, None)
145146

146147
# If we're running as an Express app but there's also a top-level item named app
147-
# which is a shiny.App object, the user probably made a mistake.
148-
if "app" in var_context and isinstance(var_context["app"], App):
148+
# which is a shiny.App object, the user probably made a mistake. (But if there's
149+
# a magic comment to force it into Express mode, don't raise, because that means
150+
# the user should know what they're doing.)
151+
if (
152+
"app" in var_context
153+
and isinstance(var_context["app"], App)
154+
and find_magic_comment_mode(content[:1000]) is None
155+
):
149156
raise RuntimeError(
150157
"This looks like a Shiny Express app because it imports shiny.express, "
151158
"but it also looks like a Shiny Core app because it has a variable named "
@@ -165,7 +172,7 @@ def set_result(x: object):
165172
sys.displayhook = prev_displayhook
166173

167174

168-
_top_level_recall_context_manager: RecallContextManager[Tag]
175+
_top_level_recall_context_manager: RecallContextManager[Tag] | None = None
169176

170177

171178
def reset_top_level_recall_context_manager() -> None:
@@ -176,6 +183,9 @@ def reset_top_level_recall_context_manager() -> None:
176183

177184

178185
def get_top_level_recall_context_manager() -> RecallContextManager[Tag]:
186+
if _top_level_recall_context_manager is None:
187+
raise RuntimeError("No top-level recall context manager has been set.")
188+
179189
return _top_level_recall_context_manager
180190

181191

@@ -221,8 +231,16 @@ def app_opts(
221231
Whether to enable debug mode.
222232
"""
223233

224-
# Store these options only if we're in the UI-rendering phase of Shiny Express.
225234
mock_session = get_current_session()
235+
236+
if mock_session is None:
237+
# We can get here if a Shiny Core app, or if we're in the UI rendering phase of
238+
# a Quarto-Shiny dashboard.
239+
raise RuntimeError(
240+
"express.app_opts() can only be used in a standalone Shiny Express app."
241+
)
242+
243+
# Store these options only if we're in the UI-rendering phase of Shiny Express.
226244
if not isinstance(mock_session, ExpressMockSession):
227245
return
228246

shiny/express/ui/_page.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,14 @@ def page_opts(
5858
one based on the arguments provided. If not ``None``, this will override all
5959
heuristics for choosing page functions.
6060
"""
61-
cm = get_top_level_recall_context_manager()
61+
try:
62+
cm = get_top_level_recall_context_manager()
63+
except RuntimeError:
64+
# We can get here if a Shiny Core app, or if we're in the UI rendering phase of
65+
# a Quarto-Shiny dashboard.
66+
raise RuntimeError(
67+
"express.ui.page_opts() can only be used inside of a standalone Shiny Express app."
68+
)
6269

6370
if not isinstance(title, MISSING_TYPE):
6471
cm.kwargs["title"] = title

shiny/quarto.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ def convert_code_cells_to_app_py(json_file: str | Path, app_file: str | Path) ->
6868
)
6969

7070
app_content = f"""# This file generated by Quarto; do not edit by hand.
71+
# shiny_mode: core
7172
7273
from __future__ import annotations
7374

0 commit comments

Comments
 (0)