diff --git a/CHANGELOG.md b/CHANGELOG.md index 51cd06725..929c1b4c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * `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) +* Added support for using `shiny.express` in Quarto Dashboards. (#1217) + ### Bug fixes ### Other changes diff --git a/shiny/express/_is_express.py b/shiny/express/_is_express.py index f2f1b643d..a62f0b408 100644 --- a/shiny/express/_is_express.py +++ b/shiny/express/_is_express.py @@ -5,7 +5,10 @@ from __future__ import annotations import ast +import re +import sys from pathlib import Path +from typing import Literal, cast from .._docstring import no_example @@ -42,8 +45,16 @@ def is_express_app(app: str, app_dir: str | None) -> bool: try: # Read the file, parse it, and look for any imports of shiny.express. - with open(app_path) as f: + with open(app_path, encoding="utf-8") as f: content = f.read() + + # Check for magic comment in the first 1000 characters + forced_mode = find_magic_comment_mode(content[:1000]) + if forced_mode == "express": + return True + elif forced_mode == "core": + return False + tree = ast.parse(content, app_path) detector = DetectShinyExpressVisitor() detector.visit(tree) @@ -78,3 +89,28 @@ def visit_Module(self, node: ast.Module) -> None: # Don't recurse into any nodes, so the we'll only ever look at top-level nodes. def generic_visit(self, node: ast.AST) -> None: pass + + +def find_magic_comment_mode(content: str) -> Literal["core", "express"] | None: + """ + Look for a magic comment of the form "# shiny_mode: express" or "# shiny_mode: + core". + + If a line of the form "# shiny_mode: x" is found, where "x" is not "express" or + "core", then a message will be printed to stderr. + + Returns + ------- + : + `True` if Shiny Express comment is found, `False` if Shiny Core comment is + found, and `None` if no magic comment is found. + """ + m = re.search(r"^#[ \t]*shiny_mode:[ \t]*(\S*)[ \t]*$", content, re.MULTILINE) + if m is not None: + shiny_mode = cast(str, m.group(1)) + if shiny_mode in ("express", "core"): + return shiny_mode + else: + print(f'Invalid shiny_mode: "{shiny_mode}"', file=sys.stderr) + + return None diff --git a/shiny/express/_run.py b/shiny/express/_run.py index 53f112d25..7de634740 100644 --- a/shiny/express/_run.py +++ b/shiny/express/_run.py @@ -13,6 +13,7 @@ from .._utils import import_module_from_path from ..session import Inputs, Outputs, Session, get_current_session, session_context from ..types import MISSING, MISSING_TYPE +from ._is_express import find_magic_comment_mode from ._mock_session import ExpressMockSession from ._recall_context import RecallContextManager from .expressify_decorator._func_displayhook import _expressify_decorator_function_def @@ -144,8 +145,14 @@ def set_result(x: object): get_top_level_recall_context_manager().__exit__(None, None, None) # If we're running as an Express app but there's also a top-level item named app - # which is a shiny.App object, the user probably made a mistake. - if "app" in var_context and isinstance(var_context["app"], App): + # which is a shiny.App object, the user probably made a mistake. (But if there's + # a magic comment to force it into Express mode, don't raise, because that means + # the user should know what they're doing.) + if ( + "app" in var_context + and isinstance(var_context["app"], App) + and find_magic_comment_mode(content[:1000]) is None + ): raise RuntimeError( "This looks like a Shiny Express app because it imports shiny.express, " "but it also looks like a Shiny Core app because it has a variable named " @@ -165,7 +172,7 @@ def set_result(x: object): sys.displayhook = prev_displayhook -_top_level_recall_context_manager: RecallContextManager[Tag] +_top_level_recall_context_manager: RecallContextManager[Tag] | None = None def reset_top_level_recall_context_manager() -> None: @@ -176,6 +183,9 @@ def reset_top_level_recall_context_manager() -> None: def get_top_level_recall_context_manager() -> RecallContextManager[Tag]: + if _top_level_recall_context_manager is None: + raise RuntimeError("No top-level recall context manager has been set.") + return _top_level_recall_context_manager @@ -221,8 +231,16 @@ def app_opts( Whether to enable debug mode. """ - # Store these options only if we're in the UI-rendering phase of Shiny Express. mock_session = get_current_session() + + if mock_session is None: + # We can get here if a Shiny Core app, or if we're in the UI rendering phase of + # a Quarto-Shiny dashboard. + raise RuntimeError( + "express.app_opts() can only be used in a standalone Shiny Express app." + ) + + # Store these options only if we're in the UI-rendering phase of Shiny Express. if not isinstance(mock_session, ExpressMockSession): return diff --git a/shiny/express/ui/_page.py b/shiny/express/ui/_page.py index d005540e9..738d25356 100644 --- a/shiny/express/ui/_page.py +++ b/shiny/express/ui/_page.py @@ -58,7 +58,14 @@ def page_opts( one based on the arguments provided. If not ``None``, this will override all heuristics for choosing page functions. """ - cm = get_top_level_recall_context_manager() + try: + cm = get_top_level_recall_context_manager() + except RuntimeError: + # We can get here if a Shiny Core app, or if we're in the UI rendering phase of + # a Quarto-Shiny dashboard. + raise RuntimeError( + "express.ui.page_opts() can only be used inside of a standalone Shiny Express app." + ) if not isinstance(title, MISSING_TYPE): cm.kwargs["title"] = title diff --git a/shiny/quarto.py b/shiny/quarto.py index 5f0d5efc2..380c354cf 100644 --- a/shiny/quarto.py +++ b/shiny/quarto.py @@ -68,6 +68,7 @@ def convert_code_cells_to_app_py(json_file: str | Path, app_file: str | Path) -> ) app_content = f"""# This file generated by Quarto; do not edit by hand. +# shiny_mode: core from __future__ import annotations