Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 20 additions & 3 deletions shiny/express/_is_express.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
36 changes: 27 additions & 9 deletions shiny/express/_run.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
):
"""
Expand Down Expand Up @@ -230,14 +236,19 @@ 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.)
static_assets_paths = {k: Path(v) for k, v in static_assets.items()}

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

Expand All @@ -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"]

Expand All @@ -264,13 +278,17 @@ 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():
if not path.is_absolute():
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
83 changes: 67 additions & 16 deletions shiny/quarto.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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

Expand All @@ -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
# =============================================================================
Expand Down