"""Tools for parsing ipynb files.""" from __future__ import annotations from pathlib import Path __all__ = ("convert_code_cells_to_app_py", "get_shiny_deps") import ast from typing import Literal, cast from ._typing_extensions import NotRequired, TypedDict QuartoShinyCodeCellClass = Literal["python", "r", "cell-code", "hidden"] QuartoShinyCodeCellContext = Literal["ui", "server", "server-setup"] class QuartoShinyCodeCell(TypedDict): text: str context: list[QuartoShinyCodeCellContext] classes: list[QuartoShinyCodeCellClass] class QuartoShinyCodeCells(TypedDict): schema_version: int cells: list[QuartoShinyCodeCell] html_file: str def convert_code_cells_to_app_py(json_file: str | Path, app_file: str | Path) -> None: """Parse an code cell JSON file and output an app.py file.""" import json from textwrap import indent json_file = Path(json_file) app_file = Path(app_file) if app_file.exists(): with open(app_file, "r", encoding="utf-8") 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." ) with open(json_file, "r", encoding="utf-8") as f: data = cast(QuartoShinyCodeCells, json.load(f)) if data["schema_version"] != 1: raise ValueError("Only schema_version 1 is supported.") cells = data["cells"] session_code_cell_texts: list[str] = [] global_code_cell_texts: list[str] = [] for cell in cells: if "python" not in cell["classes"]: continue if "server-setup" in cell["context"]: 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" ) app_content = f"""# This file generated by Quarto; do not edit by hand. # shiny_mode: core from __future__ import annotations from pathlib import Path from shiny import App, Inputs, Outputs, Session, ui {"".join(global_code_cell_texts)} def server(input: Inputs, output: Outputs, session: Session) -> None: {"".join(session_code_cell_texts)} return None _static_assets = ##STATIC_ASSETS_PLACEHOLDER## _static_assets = {{"/" + sa: Path(__file__).parent / sa for sa in _static_assets}} app = App( Path(__file__).parent / "{data["html_file"]}", server, static_assets=_static_assets, ) """ with open(app_file, "w", encoding="utf-8") as f: f.write(app_content) # ============================================================================= # HTML Dependency types # ============================================================================= class QuartoHtmlDepItem(TypedDict): name: str path: str attribs: NotRequired[dict[str, str]] class QuartoHtmlDepServiceworkerItem(TypedDict): source: str destination: str class QuartoHtmlDependency(TypedDict): name: str version: NotRequired[str] scripts: NotRequired[list[str | QuartoHtmlDepItem]] stylesheets: NotRequired[list[str | QuartoHtmlDepItem]] resources: NotRequired[list[QuartoHtmlDepItem]] meta: NotRequired[dict[str, str]] serviceworkers: NotRequired[list[QuartoHtmlDepServiceworkerItem]] def placeholder_dep() -> QuartoHtmlDependency: return { "name": "shiny-dependency-placeholder", "version": "9.9.9", "meta": {"shiny-dependency-placeholder": ""}, } def get_shiny_deps() -> str: import json return json.dumps([placeholder_dep()], indent=2) # ============================================================================= # Functions for checking if code has a star import # ============================================================================= def validate_code_has_no_star_import(content: str) -> None: """ Check if Python code has a star import at the top level and if so raise an error. Parameters ---------- content A string with Python code. Returns ------- : None """ if code_has_star_import(content): raise ValueError( "'import *' statements cannot be used in a regular Shiny code block in Quarto.\n" "Please move '*' imports to a code block with '#| context: setup', or used named imports instead.\n" ) def code_has_star_import(content: str) -> bool: try: tree = ast.parse(content) detector = DetectImportStarVisitor() detector.visit(tree) except Exception: return False return detector.found_star_import class DetectImportStarVisitor(ast.NodeVisitor): def __init__(self): super().__init__() self.found_star_import = False def visit_ImportFrom(self, node: ast.ImportFrom): if any(alias.name == "*" for alias in node.names): self.found_star_import = True # Visit top-level nodes. def visit_Module(self, node: ast.Module): super().generic_visit(node) # Don't recurse into any nodes, so the we'll only ever look at top-level nodes. def generic_visit(self, node: ast.AST): pass