Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for Quarto integration #746

Merged
merged 20 commits into from
Oct 11, 2023
Merged
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
2 changes: 1 addition & 1 deletion setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ install_requires =
starlette>=0.17.1
websockets>=10.0
python-multipart
htmltools>=0.2.1
htmltools @ git+https://github.com/posit-dev/py-htmltools.git
click>=8.1.4
markdown-it-py>=1.1.0
# This is needed for markdown-it-py. Without it, when loading shiny/ui/_markdown.py,
Expand Down
4 changes: 3 additions & 1 deletion shiny/__init__.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
"""A package for building reactive web applications."""

__version__ = "0.5.1.9000"
__version__ = "0.5.1.9003"

from ._shinyenv import is_pyodide as _is_pyodide

# User-facing subpackages that should be available on `from shiny import *`
from . import quarto
from . import reactive
from . import render
from .session import (
Expand Down Expand Up @@ -37,6 +38,7 @@

__all__ = (
# public sub-packages
"quarto",
"reactive",
"render",
"session",
Expand Down
42 changes: 38 additions & 4 deletions shiny/_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,22 @@
import copy
import os
import secrets
from pathlib import Path
from typing import Any, Callable, Optional, cast

import starlette.applications
import starlette.exceptions
import starlette.middleware
import starlette.routing
import starlette.websockets
from htmltools import HTMLDependency, HTMLDocument, RenderedHTML, Tag, TagList
from htmltools import (
HTMLDependency,
HTMLDocument,
HTMLTextDocument,
RenderedHTML,
Tag,
TagList,
)
from starlette.requests import Request
from starlette.responses import HTMLResponse, JSONResponse, Response
from starlette.types import ASGIApp, Message, Receive, Scope, Send
Expand Down Expand Up @@ -89,7 +97,7 @@ def server(input: Inputs, output: Outputs, session: Session):

def __init__(
self,
ui: Tag | TagList | Callable[[Request], Tag | TagList],
ui: Tag | TagList | Callable[[Request], Tag | TagList] | Path,
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jcheng5 What do you think about taking a Path object to indicate that it should read the UI from a file?

server: Optional[Callable[[Inputs, Outputs, Session], None]],
*,
static_assets: Optional["str" | "os.PathLike[str]"] = None,
Expand Down Expand Up @@ -146,6 +154,12 @@ def _server(inputs: Inputs, outputs: Outputs, session: Session):
raise TypeError("App UI cannot be a coroutine function")
# Dynamic UI: just store the function for later
self.ui = cast("Callable[[Request], Tag | TagList]", ui)
elif isinstance(ui, Path):
if not ui.is_absolute():
raise ValueError("Path to UI must be absolute")

self.ui = self._render_page_from_file(ui, lib_prefix=self.lib_prefix)

else:
# Static UI: render the UI now and save the results
self.ui = self._render_page(
Expand Down Expand Up @@ -370,9 +384,29 @@ def _render_page(self, ui: Tag | TagList, lib_prefix: str) -> RenderedHTML:
self._ensure_web_dependencies(rendered["dependencies"])
return rendered

def _render_page_from_file(self, file: Path, lib_prefix: str) -> RenderedHTML:
with open(file, "r") as f:
page_html = f.read()

doc = HTMLTextDocument(
page_html,
deps=[require_deps(), jquery_deps(), shiny_deps()],
deps_replace_pattern='<meta name="shiny-dependency-placeholder" content="">',
)

rendered = doc.render(lib_prefix=lib_prefix)
self._ensure_web_dependencies(rendered["dependencies"])

return rendered


def is_uifunc(x: Tag | TagList | Callable[[Request], Tag | TagList]):
if isinstance(x, Tag) or isinstance(x, TagList) or not callable(x):
def is_uifunc(x: Path | Tag | TagList | Callable[[Request], Tag | TagList]):
if (
isinstance(x, Path)
or isinstance(x, Tag)
or isinstance(x, TagList)
or not callable(x)
):
return False
else:
return True
Expand Down
43 changes: 41 additions & 2 deletions shiny/_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ def main() -> None:

stop_shortcut = "Ctrl+C"

RELOAD_INCLUDES_DEFAULT = ("*.py", "*.css", "*.js", "*.htm", "*.html", "*.png")


@main.command(
help=f"""Run a Shiny app (press {stop_shortcut} to stop).
Expand Down Expand Up @@ -76,7 +78,7 @@ def main() -> None:
"--reload",
is_flag=True,
default=False,
help="Enable auto-reload, when these types of files change: .py .css .js .html",
help="Enable auto-reload. See --reload-includes for types of files that are monitored for changes.",
)
@click.option(
"--reload-dir",
Expand All @@ -86,6 +88,13 @@ def main() -> None:
"addition to the app's parent directory. Can be used more than once.",
type=click.Path(exists=True),
)
@click.option(
"--reload-includes",
"reload_includes",
default=",".join(RELOAD_INCLUDES_DEFAULT),
help="File glob(s) to indicate which files should be monitored for changes. Defaults"
f' to "{",".join(RELOAD_INCLUDES_DEFAULT)}".',
)
@click.option(
"--ws-max-size",
type=int,
Expand Down Expand Up @@ -131,19 +140,22 @@ def run(
autoreload_port: int,
reload: bool,
reload_dirs: tuple[str, ...],
reload_includes: str,
ws_max_size: int,
log_level: str,
app_dir: str,
factory: bool,
launch_browser: bool,
) -> None:
reload_includes_list = reload_includes.split(",")
return run_app(
app,
host=host,
port=port,
autoreload_port=autoreload_port,
reload=reload,
reload_dirs=list(reload_dirs),
reload_includes=reload_includes_list,
ws_max_size=ws_max_size,
log_level=log_level,
app_dir=app_dir,
Expand All @@ -159,6 +171,7 @@ def run_app(
autoreload_port: int = 0,
reload: bool = False,
reload_dirs: Optional[list[str]] = None,
reload_includes: list[str] | tuple[str, ...] = RELOAD_INCLUDES_DEFAULT,
ws_max_size: int = 16777216,
log_level: Optional[str] = None,
app_dir: Optional[str] = ".",
Expand Down Expand Up @@ -188,6 +201,12 @@ def run_app(
hot-reload. Set to 0 to use a random port.
reload
Enable auto-reload.
reload_dirs
List of directories (in addition to the app directory) to watch for changes that
will trigger app reloading.
reload_includes
List or tuple of file globs to indicate which files should be monitored for
changes.
ws_max_size
WebSocket max size message in bytes.
log_level
Expand Down Expand Up @@ -273,7 +292,8 @@ def run_app(
"reload": reload,
# Adding `reload_includes` param while `reload=False` produces an warning
# https://github.com/encode/uvicorn/blob/d43afed1cfa018a85c83094da8a2dd29f656d676/uvicorn/config.py#L298-L304
"reload_includes": ["*.py", "*.css", "*.js", "*.htm", "*.html", "*.png"],
"reload_includes": list(reload_includes),
"reload_excludes": [".*", "*.py[cod]", "__pycache__", "env", "venv"],
"reload_dirs": reload_dirs,
}

Expand Down Expand Up @@ -480,7 +500,26 @@ def static_assets(command: str) -> None:
raise click.UsageError(f"Unknown command: {command}")


@main.command(help="""Convert a JSON file with code cells to a py file.""")
@click.argument(
"json_file",
type=str,
)
@click.argument(
"py_file",
type=str,
)
def cells_to_app(json_file: str, py_file: str) -> None:
shiny.quarto.convert_code_cells_to_app_py(json_file, py_file)


@main.command(help="""Get Shiny's HTML dependencies as JSON.""")
def get_shiny_deps() -> None:
print(shiny.quarto.get_shiny_deps())


class ReloadArgs(TypedDict):
reload: NotRequired[bool]
reload_includes: NotRequired[list[str]]
reload_excludes: NotRequired[list[str]]
reload_dirs: NotRequired[list[str]]
114 changes: 114 additions & 0 deletions shiny/quarto.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
"""Tools for parsing ipynb files."""
from __future__ import annotations

from pathlib import Path

__all__ = ("convert_code_cells_to_app_py", "get_shiny_deps")

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)

with open(json_file, "r") 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"]:
session_code_cell_texts.append(
indent(cell["text"], " ") + "\n\n # " + "=" * 72 + "\n\n"
)

app_content = f"""
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) }

app = App(
Path(__file__).parent / "{ data["html_file"] }",
server,
static_assets=Path(__file__).parent,
)
"""

with open(app_file, "w") 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)
Loading