diff --git a/shiny/express/_is_express.py b/shiny/express/_is_express.py index f2f1b643d..c42b5c6db 100644 --- a/shiny/express/_is_express.py +++ b/shiny/express/_is_express.py @@ -44,13 +44,30 @@ def is_express_app(app: str, app_dir: str | None) -> bool: # Read the file, parse it, and look for any imports of shiny.express. with open(app_path) as f: content = f.read() - tree = ast.parse(content, app_path) - detector = DetectShinyExpressVisitor() - detector.visit(tree) + return is_express_app_content(content) except Exception: return False + +def is_express_app_content(content: str) -> bool: + """ + Given Python code provided as a string, detect whether it is a Shiny express app. + + Parameters + ---------- + content + Python code for the app, provided as a string. + + Returns + ------- + : + `True` if it is a Shiny express app, `False` otherwise. + """ + tree = ast.parse(content) + detector = DetectShinyExpressVisitor() + detector.visit(tree) + return detector.found_shiny_express_import diff --git a/shiny/express/_run.py b/shiny/express/_run.py index cdfc9a9d0..c6e9b56e3 100644 --- a/shiny/express/_run.py +++ b/shiny/express/_run.py @@ -1,10 +1,9 @@ from __future__ import annotations import ast -import os import sys from pathlib import Path -from typing import cast +from typing import Mapping, cast from htmltools import Tag, TagList @@ -82,10 +81,16 @@ def express_server(input: Inputs, output: Outputs, session: Session): app_opts = _merge_app_opts(app_opts, mock_session.app_opts) app_opts = _normalize_app_opts(app_opts, file.parent) + # If there's a `ui` option in the app_opts, then use that as the UI instead of the + # one that was generated by running the app code. + if "ui" in app_opts: + app_ui = app_opts["ui"] + del app_opts["ui"] + app = App( app_ui, express_server, - **app_opts, # pyright: ignore[reportArgumentType] + **app_opts, # pyright: ignore[reportArgumentType, reportCallIssue] ) return app @@ -195,14 +200,15 @@ def __getattr__(self, name: str): class AppOpts(TypedDict): static_assets: NotRequired[dict[str, Path]] + ui: NotRequired[Path] debug: NotRequired[bool] @no_example() def app_opts( - static_assets: ( - str | os.PathLike[str] | dict[str, str | Path] | MISSING_TYPE - ) = MISSING, + *, + static_assets: str | Path | Mapping[str, str | Path] | MISSING_TYPE = MISSING, + ui: str | Path | MISSING_TYPE = MISSING, debug: bool | MISSING_TYPE = MISSING, ): """ @@ -230,7 +236,7 @@ def app_opts( return if not isinstance(static_assets, MISSING_TYPE): - if isinstance(static_assets, (str, os.PathLike)): + if isinstance(static_assets, (str, Path)): static_assets = {"/": Path(static_assets)} # Convert string values to Paths. (Need new var name to help type checker.) @@ -238,6 +244,11 @@ def app_opts( mock_session.app_opts["static_assets"] = static_assets_paths + if not isinstance(ui, MISSING_TYPE): + if not isinstance(ui, (str, Path)): + raise TypeError(f"ui must be a string or Path, not {type(ui).__name__}") + mock_session.app_opts["ui"] = Path(ui) + if not isinstance(debug, MISSING_TYPE): mock_session.app_opts["debug"] = debug @@ -256,6 +267,9 @@ def _merge_app_opts(app_opts: AppOpts, app_opts_new: AppOpts) -> AppOpts: elif "static_assets" in app_opts_new: app_opts["static_assets"] = app_opts_new["static_assets"].copy() + if "ui" in app_opts_new: + app_opts["ui"] = app_opts_new["ui"] + if "debug" in app_opts_new: app_opts["debug"] = app_opts_new["debug"] @@ -264,8 +278,8 @@ def _merge_app_opts(app_opts: AppOpts, app_opts_new: AppOpts) -> AppOpts: def _normalize_app_opts(app_opts: AppOpts, parent_dir: Path) -> AppOpts: """ - Normalize the app options, ensuring that all paths in static_assets are absolute. - Modifies the original in place. + Normalize the app options, ensuring that all paths in `ui` and `static_assets` are + absolute. Modifies the original in place and returns it. """ if "static_assets" in app_opts: for mount_point, path in app_opts["static_assets"].items(): @@ -273,4 +287,8 @@ def _normalize_app_opts(app_opts: AppOpts, parent_dir: Path) -> AppOpts: path = parent_dir / path app_opts["static_assets"][mount_point] = path + if "ui" in app_opts: + if not app_opts["ui"].is_absolute(): + app_opts["ui"] = parent_dir / app_opts["ui"] + return app_opts diff --git a/shiny/quarto.py b/shiny/quarto.py index 5f0d5efc2..a6ca183c8 100644 --- a/shiny/quarto.py +++ b/shiny/quarto.py @@ -32,17 +32,11 @@ def convert_code_cells_to_app_py(json_file: str | Path, app_file: str | Path) -> import json from textwrap import indent + from .express._is_express import is_express_app_content + json_file = Path(json_file) app_file = Path(app_file) - - if app_file.exists(): - with open(app_file, "r") as f: - first_line = f.readline().strip() - if first_line != "# This file generated by Quarto; do not edit by hand.": - raise ValueError( - f"Not overwriting app file {app_file}, because it does not appear to be generated by Quarto. " - " If this is incorrect, remove the file and try again." - ) + _check_for_non_autogenerated_file(app_file) with open(json_file, "r") as f: data = cast(QuartoShinyCodeCells, json.load(f)) @@ -63,22 +57,63 @@ def convert_code_cells_to_app_py(json_file: str | Path, app_file: str | Path) -> global_code_cell_texts.append(cell["text"] + "\n\n# " + "=" * 72 + "\n\n") elif "server" in cell["context"]: validate_code_has_no_star_import(cell["text"]) - session_code_cell_texts.append( - indent(cell["text"], " ") + "\n\n # " + "=" * 72 + "\n\n" - ) + session_code_cell_texts.append(cell["text"] + "\n\n# " + "=" * 72 + "\n\n") + + global_code_text = "".join(global_code_cell_texts) + session_code_text = "".join(session_code_cell_texts) + + is_express = is_express_app_content(global_code_text) or is_express_app_content( + session_code_text + ) + + # If the code in the qmd is for a Shiny Express app, then we need to do something + # different from a regular Shiny app. + if is_express: + + # In a Shiny Express app, during the UI rendering phase, we don't really need + # run any code, other than setting up app options. This is because the UI has + # already been generated by Quarto, in an HTML file. So after the initial setup, + # we check if there's a current session, and if not, we just return. + # + # The global (or context:setup) code chunks have already been run by Quarto. + # Those chunks do not need to be run during the Shiny Express UI phase (because + # they were already run during Quarto's UI phase), but they do need to be run + # once per session, so they go inside the block that is run `if + # _get_current_session() is not None:`. + app_content = f"""# This file generated by Quarto; do not edit by hand. - app_content = f"""# This file generated by Quarto; do not edit by hand. +from __future__ import annotations + +from pathlib import Path +from shiny.session import get_current_session as _get_current_session +from shiny.express import app_opts as _app_opts + +_static_assets = ##STATIC_ASSETS_PLACEHOLDER## +_static_assets = {{"/" + sa: Path(__file__).parent / sa for sa in _static_assets}} +_app_opts( + ui=Path(__file__).parent / "{data["html_file"]}", + static_assets=_static_assets +) + +if _get_current_session() is not None: +{indent(global_code_text, " ")} + +{indent(session_code_text, " ")} + +""" + else: + app_content = f"""# This file generated by Quarto; do not edit by hand. from __future__ import annotations from pathlib import Path -from shiny import App, Inputs, Outputs, Session, ui +from shiny import App, Inputs, Outputs, Session -{"".join(global_code_cell_texts)} +{global_code_text} def server(input: Inputs, output: Outputs, session: Session) -> None: -{"".join(session_code_cell_texts)} +{indent(session_code_text, " ")} return None @@ -97,6 +132,22 @@ def server(input: Inputs, output: Outputs, session: Session) -> None: f.write(app_content) +def _check_for_non_autogenerated_file(file: Path) -> None: + """ + Check if a file exists and is _not_ autogenerated by Quarto. If so, raise an error. + If the file does not exist, or it exists and is autogenerated by Quarto, just + return. + """ + if file.exists(): + with open(file, "r") as f: + first_line = f.readline().strip() + if first_line != "# This file generated by Quarto; do not edit by hand.": + raise ValueError( + f"Not overwriting app file {file}, because it does not appear to be generated by Quarto. " + " If this is incorrect, remove the file and try again." + ) + + # ============================================================================= # HTML Dependency types # =============================================================================