From ee53d92a84671e7ee372563d4f469cf89e6554d1 Mon Sep 17 00:00:00 2001 From: Winston Chang Date: Thu, 7 Mar 2024 09:11:29 -0600 Subject: [PATCH 1/5] Don't import ui in Quarto Shiny docs --- shiny/quarto.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shiny/quarto.py b/shiny/quarto.py index 5f0d5efc2..7aed06f43 100644 --- a/shiny/quarto.py +++ b/shiny/quarto.py @@ -72,7 +72,7 @@ def convert_code_cells_to_app_py(json_file: str | Path, app_file: str | Path) -> 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)} From 281939d81db86273b4cba46b73ea712c4227401f Mon Sep 17 00:00:00 2001 From: Winston Chang Date: Mon, 11 Mar 2024 17:24:06 -0500 Subject: [PATCH 2/5] Add basic support for Express in Quarto docs --- shiny/express/_is_express.py | 23 ++++++++++++++++--- shiny/quarto.py | 44 +++++++++++++++++++++++++++++++----- 2 files changed, 58 insertions(+), 9 deletions(-) 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/quarto.py b/shiny/quarto.py index 7aed06f43..d9209d44a 100644 --- a/shiny/quarto.py +++ b/shiny/quarto.py @@ -32,6 +32,8 @@ 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) @@ -63,22 +65,52 @@ 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: + app_content = f"""# This file generated by Quarto; do not edit by hand. + +from __future__ import annotations + +from pathlib import Path +from shiny.express import app_opts + +_static_assets = ##STATIC_ASSETS_PLACEHOLDER## +_static_assets = {{"/" + sa: Path(__file__).parent / sa for sa in _static_assets}} +app_opts( + # ui_html=Path(__file__).parent / "{data["html_file"]}", + static_assets=_static_assets +) + +{global_code_text} + +{session_code_text} + +""" + else: + session_code_text = indent(session_code_text, " ") - 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 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)} +{session_code_text} return None From 5ad4c28e0ae12cae34754ea3e36950e2e7ef98d7 Mon Sep 17 00:00:00 2001 From: Winston Chang Date: Mon, 11 Mar 2024 19:03:18 -0500 Subject: [PATCH 3/5] For Quarto-Shiny Express, use UI generated by Quarto --- shiny/express/_run.py | 34 ++++++++++++++++++++++++++-------- shiny/quarto.py | 2 +- 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/shiny/express/_run.py b/shiny/express/_run.py index cdfc9a9d0..6fa0641a1 100644 --- a/shiny/express/_run.py +++ b/shiny/express/_run.py @@ -1,7 +1,6 @@ from __future__ import annotations import ast -import os import sys from pathlib import Path from typing import cast @@ -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 | dict[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 d9209d44a..94c667dcd 100644 --- a/shiny/quarto.py +++ b/shiny/quarto.py @@ -87,7 +87,7 @@ def convert_code_cells_to_app_py(json_file: str | Path, app_file: str | Path) -> _static_assets = ##STATIC_ASSETS_PLACEHOLDER## _static_assets = {{"/" + sa: Path(__file__).parent / sa for sa in _static_assets}} app_opts( - # ui_html=Path(__file__).parent / "{data["html_file"]}", + ui=Path(__file__).parent / "{data["html_file"]}", static_assets=_static_assets ) From 11a1a4af4b726e21b54aaa5ab06a2281acb258f3 Mon Sep 17 00:00:00 2001 From: Winston Chang Date: Mon, 11 Mar 2024 21:37:23 -0500 Subject: [PATCH 4/5] Better typing --- shiny/express/_run.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/shiny/express/_run.py b/shiny/express/_run.py index 6fa0641a1..c6e9b56e3 100644 --- a/shiny/express/_run.py +++ b/shiny/express/_run.py @@ -3,7 +3,7 @@ import ast import sys from pathlib import Path -from typing import cast +from typing import Mapping, cast from htmltools import Tag, TagList @@ -207,7 +207,7 @@ class AppOpts(TypedDict): @no_example() def app_opts( *, - static_assets: str | Path | 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, ): From 64ef943e9e246fee4de58221170e6d44b8d7533d Mon Sep 17 00:00:00 2001 From: Winston Chang Date: Mon, 11 Mar 2024 21:39:37 -0500 Subject: [PATCH 5/5] Fix evaluation of context:setup chunks --- shiny/quarto.py | 51 +++++++++++++++++++++++++++++++++---------------- 1 file changed, 35 insertions(+), 16 deletions(-) diff --git a/shiny/quarto.py b/shiny/quarto.py index 94c667dcd..a6ca183c8 100644 --- a/shiny/quarto.py +++ b/shiny/quarto.py @@ -36,15 +36,7 @@ def convert_code_cells_to_app_py(json_file: str | Path, app_file: str | Path) -> 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)) @@ -77,28 +69,39 @@ def convert_code_cells_to_app_py(json_file: str | Path, app_file: str | Path) -> # 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. from __future__ import annotations from pathlib import Path -from shiny.express import app_opts +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( +_app_opts( ui=Path(__file__).parent / "{data["html_file"]}", static_assets=_static_assets ) -{global_code_text} +if _get_current_session() is not None: +{indent(global_code_text, " ")} -{session_code_text} +{indent(session_code_text, " ")} """ else: - session_code_text = indent(session_code_text, " ") - app_content = f"""# This file generated by Quarto; do not edit by hand. from __future__ import annotations @@ -110,7 +113,7 @@ def convert_code_cells_to_app_py(json_file: str | Path, app_file: str | Path) -> def server(input: Inputs, output: Outputs, session: Session) -> None: -{session_code_text} +{indent(session_code_text, " ")} return None @@ -129,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 # =============================================================================