diff --git a/CHANGELOG.md b/CHANGELOG.md index 5920553..75609f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [UNRELEASED] + +* Added `shinylive url encode` and `shinylive url decode` commands to encode local apps into a shinylive.io URL or decode a shinylive.io URL into local files. These commands are accompanied by `encode_shinylive_url()` and `decode_shinylive_url()` functions for programmatic use. (#20) + ## [0.1.3] - 2024-12-19 * Fixed `shinylive assets install-from-local`. diff --git a/pyrightconfig.json b/pyrightconfig.json index 4441f7a..6efd18e 100644 --- a/pyrightconfig.json +++ b/pyrightconfig.json @@ -1,5 +1,5 @@ { - "ignore": ["build", "dist", "typings", "sandbox"], + "ignore": ["build", "dist", "typings", "sandbox", "_dev"], "typeCheckingMode": "strict", "reportImportCycles": "none", "reportUnusedFunction": "none", diff --git a/setup.cfg b/setup.cfg index ba68fdc..d9b8a25 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = shinylive -version = attr: shinylive.__version__ +version = attr: shinylive._version.SHINYLIVE_PACKAGE_VERSION author = Winston Chang author_email = winston@posit.co url = https://github.com/posit-dev/py-shinylive @@ -36,6 +36,8 @@ install_requires = shiny click>=8.1.7 appdirs>=1.4.4 + lzstring>=1.0.4 + typing-extensions>=4.0.1 tests_require = pytest>=3 zip_safe = False @@ -69,7 +71,7 @@ console_scripts = # F405: Name may be undefined, or defined from star imports # W503: Line break occurred before a binary operator ignore = E302, E501, F403, F405, W503 -exclude = docs, .venv +exclude = docs, .venv, _dev [isort] profile=black diff --git a/shinylive/__init__.py b/shinylive/__init__.py index a8d9c47..96c2fea 100644 --- a/shinylive/__init__.py +++ b/shinylive/__init__.py @@ -1,5 +1,8 @@ """A package for packaging Shiny applications that run on Python in the browser.""" -from . import _version +from ._url import decode_shinylive_url, encode_shinylive_url +from ._version import SHINYLIVE_PACKAGE_VERSION -__version__ = _version.SHINYLIVE_PACKAGE_VERSION +__version__ = SHINYLIVE_PACKAGE_VERSION + +__all__ = ("decode_shinylive_url", "encode_shinylive_url") diff --git a/shinylive/_main.py b/shinylive/_main.py index 1e22153..5f1ba7d 100644 --- a/shinylive/_main.py +++ b/shinylive/_main.py @@ -3,12 +3,22 @@ import collections import sys from pathlib import Path -from typing import MutableMapping, Optional +from typing import Literal, MutableMapping, Optional import click -from . import _assets, _deps, _export, _version +from . import _assets, _deps, _export +from ._url import ( + create_shinylive_bundle_file, + create_shinylive_bundle_text, + create_shinylive_chunk_contents, + create_shinylive_url, + decode_shinylive_url, + detect_app_language, + write_files_from_shinylive_io, +) from ._utils import print_as_json +from ._version import SHINYLIVE_ASSETS_VERSION, SHINYLIVE_PACKAGE_VERSION # Make sure commands are listed in the order they are added in the code. @@ -29,8 +39,8 @@ def list_commands(self, ctx: click.Context) -> list[str]: version_txt = f""" \b - shinylive Python package version: {_version.SHINYLIVE_PACKAGE_VERSION} - shinylive web assets version: {_assets.SHINYLIVE_ASSETS_VERSION} + shinylive Python package version: {SHINYLIVE_PACKAGE_VERSION} + shinylive web assets version: {SHINYLIVE_ASSETS_VERSION} """ # CLI structure: @@ -59,6 +69,7 @@ def list_commands(self, ctx: click.Context) -> list[str]: # * language-resources # * app-resources # * Options: --json-file / stdin (required) +# * url # ############################################################################# @@ -74,7 +85,7 @@ def list_commands(self, ctx: click.Context) -> list[str]: ) # > Add a --version option which immediately prints the version number and exits the # > program. -@click.version_option(_version.SHINYLIVE_PACKAGE_VERSION, message="%(version)s") +@click.version_option(SHINYLIVE_PACKAGE_VERSION, message="%(version)s") def main() -> None: ... @@ -185,7 +196,7 @@ def assets_info( @click.option( "--version", type=str, - default=_version.SHINYLIVE_ASSETS_VERSION, + default=SHINYLIVE_ASSETS_VERSION, help="Shinylive version to download.", show_default=True, ) @@ -207,11 +218,11 @@ def download( url: Optional[str], ) -> None: if version is None: # pyright: ignore[reportUnnecessaryComparison] - version = _version.SHINYLIVE_ASSETS_VERSION + version = SHINYLIVE_ASSETS_VERSION _assets.download_shinylive(destdir=upgrade_dir(dir), version=version, url=url) -cleanup_help = f"Remove all versions of local assets except the currently-used version, {_assets.SHINYLIVE_ASSETS_VERSION}." +cleanup_help = f"Remove all versions of local assets except the currently-used version, {SHINYLIVE_ASSETS_VERSION}." @assets.command( @@ -234,7 +245,7 @@ def cleanup( short_help="Remove a specific version of local copies of assets.", help=f"""Remove a specific version (`VERSION`) of local copies of assets." - For example, `VERSION` might be `{ _version.SHINYLIVE_ASSETS_VERSION }`. + For example, `VERSION` might be `{ SHINYLIVE_ASSETS_VERSION }`. """, no_args_is_help=True, ) @@ -270,7 +281,7 @@ def remove( @click.option( "--version", type=str, - default=_version.SHINYLIVE_ASSETS_VERSION, + default=SHINYLIVE_ASSETS_VERSION, help="Version of the shinylive assets being copied.", show_default=True, ) @@ -292,7 +303,7 @@ def install_from_local( ) -> None: dir = upgrade_dir(dir) if version is None: # pyright: ignore[reportUnnecessaryComparison] - version = _version.SHINYLIVE_ASSETS_VERSION + version = SHINYLIVE_ASSETS_VERSION print(f"Copying shinylive-{version} from {build} to {dir}") _assets.copy_shinylive_local(source_dir=build, destdir=dir, version=version) @@ -313,7 +324,7 @@ def install_from_local( @click.option( "--version", type=str, - default=_version.SHINYLIVE_ASSETS_VERSION, + default=SHINYLIVE_ASSETS_VERSION, help="Version of shinylive assets being linked.", show_default=True, ) @@ -337,7 +348,7 @@ def link_from_local( raise click.UsageError("Must specify BUILD") dir = upgrade_dir(dir) if version is None: # pyright: ignore[reportUnnecessaryComparison] - version = _version.SHINYLIVE_ASSETS_VERSION + version = SHINYLIVE_ASSETS_VERSION print(f"Creating symlink for shinylive-{version} from {build} to {dir}") _assets.link_shinylive_local(source_dir=build, destdir=dir, version=version) @@ -346,7 +357,7 @@ def link_from_local( help="Print the version of the Shinylive assets.", ) def version() -> None: - print(_assets.SHINYLIVE_ASSETS_VERSION) + print(SHINYLIVE_ASSETS_VERSION) # ############################################################################# @@ -385,8 +396,8 @@ def extension() -> None: def extension_info() -> None: print_as_json( { - "version": _version.SHINYLIVE_PACKAGE_VERSION, - "assets_version": _version.SHINYLIVE_ASSETS_VERSION, + "version": SHINYLIVE_PACKAGE_VERSION, + "assets_version": SHINYLIVE_ASSETS_VERSION, "scripts": { "codeblock-to-json": _assets.codeblock_to_json_file(), }, @@ -459,7 +470,7 @@ def app_resources( def defunct_help(cmd: str) -> str: return f"""The shinylive CLI command `{cmd}` is defunct. -You are using a newer version of the Python shinylive package ({ _version.SHINYLIVE_PACKAGE_VERSION }) with an older +You are using a newer version of the Python shinylive package ({ SHINYLIVE_PACKAGE_VERSION }) with an older version of the Quarto shinylive extension, and these versions are not compatible. Please update your Quarto shinylive extension by running this command in the top level @@ -469,6 +480,165 @@ def defunct_help(cmd: str) -> str: """ +# ############################################################################# +# ## shinylive.io url +# ############################################################################# + + +@main.group( + short_help="Create or decode a shinylive.io URL.", + help="Create or decode a shinylive.io URL.", + no_args_is_help=True, + cls=OrderedGroup, +) +def url() -> None: + pass + + +@url.command( + short_help="Create a shinylive.io URL from local files.", + help=""" +Create a shinylive.io URL for a Shiny app from local files. + +APP is the path to the primary Shiny app file. + +FILES are additional supporting files or directories for the app. + +On macOS, you can copy the URL to the clipboard with: + + shinylive url encode app.py | pbcopy +""", +) +@click.option( + "-m", + "--mode", + type=click.Choice(["editor", "app"]), + required=True, + default="editor", + help="The shinylive mode: include the editor or show only the app.", +) +@click.option( + "-l", + "--language", + type=click.Choice(["python", "py", "R", "r"]), + required=False, + default=None, + help="The primary language used to run the app, by default inferred from the app file.", +) +@click.option( + "-v", "--view", is_flag=True, default=False, help="Open the link in a browser." +) +@click.option( + "--json", is_flag=True, default=False, help="Print the bundle as JSON to stdout." +) +@click.option( + "--no-header", is_flag=True, default=False, help="Hide the Shinylive header." +) +@click.argument("app", type=str, nargs=1, required=True, default="-") +@click.argument("files", type=str, nargs=-1, required=False) +def encode( + app: str, + files: Optional[tuple[str, ...]] = None, + mode: Literal["editor", "app"] = "editor", + language: Optional[str] = None, + json: bool = False, + no_header: bool = False, + view: bool = False, +) -> None: + if app == "-": + app_in = sys.stdin.read() + else: + app_in = app + + if language is not None: + if language in ["py", "python"]: + lang = "py" + elif language in ["r", "R"]: + lang = "r" + else: + raise click.UsageError( + f"Invalid language '{language}', must be one of 'py', 'python', 'r', 'R'." + ) + else: + lang = detect_app_language(app_in) + + if "\n" in app_in: + bundle = create_shinylive_bundle_text(app_in, files, lang) + else: + bundle = create_shinylive_bundle_file(app_in, files, lang) + + if json: + print_as_json(bundle) + if not view: + return + + url = create_shinylive_url( + bundle, + lang, + mode=mode, + header=not no_header, + ) + + if not json: + print(url) + + if view: + import webbrowser + + webbrowser.open(url) + + +@url.command( + short_help="Decode a shinylive.io URL.", + help=""" +Decode a shinylive.io URL. + +URL is the shinylive editor or app URL. If not specified, the URL will be read from +stdin, allowing you to read the URL from a file or the clipboard. + +When `--dir` is provided, the decoded files will be written to the specified directory. +Otherwise, the contents of the shinylive app will be printed to stdout. + +On macOS, you can read the URL from the clipboard with: + + pbpaste | shinylive url decode +""", +) +@click.option( + "--dir", + type=str, + default=None, + help="Output directory into which the app's files will be written. The directory is created if it does not exist. ", +) +@click.option( + "--json", + is_flag=True, + default=False, + help="Prints the decoded shinylive bundle as JSON to stdout, ignoring --dir.", +) +@click.argument("url", type=str, nargs=1, default="-") +def decode(url: str, dir: Optional[str] = None, json: bool = False) -> None: + if url == "-": + url_in = sys.stdin.read() + else: + url_in = url + bundle = decode_shinylive_url(str(url_in)) + + if json: + print_as_json(bundle) + return + + if dir is not None: + write_files_from_shinylive_io(bundle, dir) + else: + print(create_shinylive_chunk_contents(bundle)) + + +# ############################################################################# +# ## Deprecated commands +# ############################################################################# + + def defunct_error_txt(cmd: str) -> str: return f"Error: { defunct_help(cmd) }" diff --git a/shinylive/_url.py b/shinylive/_url.py new file mode 100644 index 0000000..849994c --- /dev/null +++ b/shinylive/_url.py @@ -0,0 +1,326 @@ +from __future__ import annotations + +import base64 +import json +import os +import re +import sys +from pathlib import Path +from typing import Literal, Optional, Sequence, cast + +# Even though TypedDict is available in Python 3.8, because it's used with NotRequired, +# they should both come from the same typing module. +# https://peps.python.org/pep-0655/#usage-in-python-3-11 +if sys.version_info >= (3, 11): + from typing import NotRequired, TypedDict +else: + from typing_extensions import NotRequired, TypedDict + + +class FileContentJson(TypedDict): + name: str + content: str + type: NotRequired[Literal["text", "binary"]] + + +def encode_shinylive_url( + app: str | Path, + files: Optional[str | Path | Sequence[str | Path]] = None, + language: Optional[Literal["py", "r"]] = None, + mode: Literal["editor", "app"] = "editor", + header: bool = True, +) -> str: + """ + Generate a URL for a [ShinyLive application](https://shinylive.io). + + Parameters + ---------- + app + The main app file of the ShinyLive application. This file should be a Python + `app.py` or an R `app.R`, `ui.R`, or `server.R` file. This file will be renamed + `app.py` or `app.R` for shinylive, unless it's named `ui.R` or `server.R`. + files + File(s) or directory path(s) to include in the application. On shinylive, these + files will be stored relative to the main `app` file. If an entry in files is a + directory, then all files in that directory will be included, recursively. + mode + The mode of the application, either "editor" or "app". Defaults to "editor". + language + The language of the application, or None to autodetect the language. Defaults to None. + header + Whether to include a header bar in the UI. This is used only if ``mode`` is "app". Defaults to True. + + Returns + ------- + The generated URL for the ShinyLive application. + """ + + if language is not None and language not in ["py", "r"]: + raise ValueError(f"Invalid language '{language}', must be either 'py' or 'r'.") + + lang = language if language is not None else detect_app_language(app) + + if isinstance(app, str) and "\n" in app: + bundle = create_shinylive_bundle_text(app, files, lang) + else: + bundle = create_shinylive_bundle_file(app, files, lang) + + return create_shinylive_url(bundle, lang, mode=mode, header=header) + + +def create_shinylive_url( + bundle: list[FileContentJson], + language: Literal["py", "r"], + mode: Literal["editor", "app"] = "editor", + header: bool = True, +) -> str: + if language not in ["py", "r"]: + raise ValueError(f"Invalid language '{language}', must be either 'py' or 'r'.") + if mode not in ["editor", "app"]: + raise ValueError(f"Invalid mode '{mode}', must be either 'editor' or 'app'.") + + file_lz = lzstring_file_bundle(bundle) + + base = "https://shinylive.io" + h = "h=0&" if not header and mode == "app" else "" + + return f"{base}/{language}/{mode}/#{h}code={file_lz}" + + +def create_shinylive_bundle_text( + app: str, + files: Optional[str | Path | Sequence[str | Path]] = None, + language: Optional[Literal["py", "r"]] = None, + root_dir: str | Path = ".", +) -> list[FileContentJson]: + if language is None: + language = detect_app_language(app) + elif language not in ["py", "r"]: + raise ValueError( + f"Language '{language}' is not supported. Please specify one of 'py' or 'r'." + ) + + app_fc: FileContentJson = { + "name": f"app.{'py' if language == 'py' else 'R'}", + "content": app, + } + + return add_supporting_files_to_bundle(app_fc, files, root_dir) + + +def create_shinylive_bundle_file( + app: str | Path, + files: Optional[str | Path | Sequence[str | Path]] = None, + language: Optional[Literal["py", "r"]] = None, +) -> list[FileContentJson]: + if language is None: + language = detect_app_language(app) + elif language not in ["py", "r"]: + raise ValueError( + f"Language '{language}' is not supported. Please specify one of 'py' or 'r'." + ) + + app_path = Path(app) + root_dir = app_path.parent + app_fc = read_file(app, root_dir) + + # if the app is not named either `ui.R` or `server.R`, then make it app.py or app.R + if app_fc["name"] not in ["ui.R", "server.R"]: + app_fc["name"] = f"app.{'py' if language == 'py' else 'R'}" + + return add_supporting_files_to_bundle(app_fc, files, root_dir, app_path) + + +def add_supporting_files_to_bundle( + app: FileContentJson, + files: Optional[str | Path | Sequence[str | Path]] = None, + root_dir: str | Path = ".", + app_path: str | Path = "", +) -> list[FileContentJson]: + app_path = Path(app_path) + + file_bundle = [app] + + if isinstance(files, (str, Path)): + files = [files] + + if files is not None: + file_list: list[str | Path] = [] + + for file in files: + if Path(file).is_dir(): + file_list.extend(listdir_recursive(file)) + else: + file_list.append(file) + + file_bundle = file_bundle + [ + read_file(file, root_dir) for file in file_list if Path(file) != app_path + ] + + return file_bundle + + +def detect_app_language(app: str | Path) -> Literal["py", "r"]: + err_not_detected = """ + Could not automatically detect the language of the app. Please specify `language`.""" + + if isinstance(app, str) and "\n" in app: + if re.search(r"^(import|from) shiny", app, re.MULTILINE): + return "py" + elif re.search(r"^library\(shiny\)", app, re.MULTILINE): + return "r" + else: + raise ValueError(err_not_detected) + + app = Path(app) + + if app.suffix.lower() == ".py": + return "py" + elif app.suffix.lower() == ".r": + return "r" + else: + raise ValueError(err_not_detected) + + +def listdir_recursive(dir: str | Path) -> list[str]: + dir = Path(dir) + all_files: list[str] = [] + + for root, dirs, files in os.walk(dir): + for file in files: + all_files.append(os.path.join(root, file)) + for dir in dirs: + all_files.extend(listdir_recursive(dir)) + + return all_files + + +def decode_shinylive_url(url: str) -> list[FileContentJson]: + from lzstring import LZString # type: ignore[reportMissingTypeStubs] + + url = url.strip() + + try: + bundle_json = cast( + str, + LZString.decompressFromEncodedURIComponent( # type: ignore + url.split("code=")[1] + ), + ) + bundle = json.loads(bundle_json) + except Exception: + raise ValueError("Could not parse and decode the shinylive URL code payload.") + + ret: list[FileContentJson] = [] + + # bundle should be an array of FileContentJson objects, otherwise raise an error + if not isinstance(bundle, list): + raise ValueError( + "The shinylive URL was not formatted correctly: `code` did not decode to a list." + ) + + for file in bundle: # type: ignore + if not isinstance(file, dict): + raise ValueError( + "Invalid shinylive URL: `code` did not decode to a list of dictionaries." + ) + if not all(key in file for key in ["name", "content"]): + raise ValueError( + "Invalid shinylive URL: `code` included an object that was missing required fields `name` or `content`." + ) + + for key in ["name", "content"]: + if not isinstance(file[key], str): + raise ValueError( + f"Invalid shinylive URL: encoded file bundle contains an file where `{key}` was not a string." + ) + + fc: FileContentJson = { + "name": file["name"], + "content": file["content"], + } + + if "type" in file: + if file["type"] == "binary": + fc["type"] = "binary" + elif file["type"] == "text": + pass + else: + raise ValueError( + f"Invalid shinylive URL: unexpected file type '{file['type']}' in '{file['name']}'." + ) + + if not all(isinstance(value, str) for value in file.values()): # type: ignore + raise ValueError( + f"Invalid shinylive URL: not all items in '{file['name']}' were strings." + ) + ret.append(fc) + + return ret + + +def create_shinylive_chunk_contents(bundle: list[FileContentJson]) -> str: + lines: list[str] = [] + for file in bundle: + lines.append(f"## file: {file['name']}") + if "type" in file and file["type"] == "binary": + lines.append("## type: binary") + lines.append(file["content"].encode("utf-8", errors="ignore").decode("utf-8")) + lines.append("") + + return "\n".join(lines) + + +def write_files_from_shinylive_io( + bundle: list[FileContentJson], dest: str | Path +) -> Path: + out_dir = Path(dest) + out_dir.mkdir(parents=True, exist_ok=True) + for file in bundle: + if "type" in file and file["type"] == "binary": + import base64 + + with open(out_dir / file["name"], "wb") as f_out: + f_out.write(base64.b64decode(file["content"])) + else: + with open(out_dir / file["name"], "w") as f_out: + f_out.write( + file["content"].encode("utf-8", errors="ignore").decode("utf-8") + ) + + return out_dir + + +# Copied from https://github.com/posit-dev/py-shiny/blob/main/docs/_renderer.py#L231 +def read_file(file: str | Path, root_dir: str | Path | None = None) -> FileContentJson: + file = Path(file) + if root_dir is None: + root_dir = Path("/") + root_dir = Path(root_dir) + + type: Literal["text", "binary"] = "text" + + try: + with open(file, "r") as f: + file_content = f.read() + type = "text" + except UnicodeDecodeError: + # If text failed, try binary. + with open(file, "rb") as f: + file_content_bin = f.read() + file_content = base64.b64encode(file_content_bin).decode("utf-8") + type = "binary" + + return { + "name": str(file.relative_to(root_dir)), + "content": file_content, + "type": type, + } + + +def lzstring_file_bundle(file_bundle: list[FileContentJson]) -> str: + from lzstring import LZString # type: ignore[reportMissingTypeStubs] + + file_json = json.dumps(file_bundle) + file_lz = LZString.compressToEncodedURIComponent(file_json) # type: ignore[reportUnknownMemberType] + return cast(str, file_lz) diff --git a/shinylive/_version.py b/shinylive/_version/__init__.py similarity index 75% rename from shinylive/_version.py rename to shinylive/_version/__init__.py index 7c0ce88..8a0e4a7 100644 --- a/shinylive/_version.py +++ b/shinylive/_version/__init__.py @@ -1,5 +1,5 @@ # The version of this Python package. -SHINYLIVE_PACKAGE_VERSION = "0.1.3" +SHINYLIVE_PACKAGE_VERSION = "0.1.3.9000" # This is the version of the Shinylive assets to use. SHINYLIVE_ASSETS_VERSION = "0.2.4"