Skip to content

Commit

Permalink
feat: Add shinylive url command (#20)
Browse files Browse the repository at this point in the history
* feat(url): Add `shinylive url`command and `make_shinylive_url()` function

* export `make_shinylive_url()`

* chore: app and files could be Paths

* require lzstring

* remove `__all__` from `__init__.py`

* noqa and pyright ingore

* feat: shinylive url (encode,decode)

* allow piping into `shinylive url decode`

* allow piping into `shinylive url encode` and detect app source code

Using the "newline trick" (if it has newlines it's source code, not a path)

* negotiate aggressively with the type checker

* demote unused f string

* add comment

* document --out option

* require `--help` so that piping into url encode works

* include files, recursively

* less aggressive type check convincing

* rename --out to --dir

* automatically detect app language when app is the text content

* Apply suggestions from code review

Co-authored-by: Winston Chang <winston@stdout.org>

* import Literal

* type narrow language from encode CLI  -> internal

* don't need to import os

* add note about decode result wrt --dir

* write base64-decoded binary files

* detect_app_language() returns "py" or "r"

* make FileContentJson.type not required

* only add header param in app mode

* if file is str|Path, promote to list

* improve FileContentJson typing throughout

* exclude _dev folder from checks

* fix syntax for creating FileContenJson objects

* require typing-extensions

* separate bundle creation from URL encoding

* add `encode_shinylive_url()` and make only encode/decode public

* simplify types and remove need for AppBundle

by detecting language earlier in aggregate processes

* move package version into a subpackage

* wrap decode outputs in helper functions, too

* rename version to _version

* docs: describe feature in changelog

* fix one more _version import

* bump package version to 0.1.3.9000

---------

Co-authored-by: Winston Chang <winston@stdout.org>
  • Loading branch information
gadenbuie and wch committed Jan 12, 2024
1 parent a959c07 commit 9c5050f
Show file tree
Hide file tree
Showing 7 changed files with 528 additions and 23 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
2 changes: 1 addition & 1 deletion pyrightconfig.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"ignore": ["build", "dist", "typings", "sandbox"],
"ignore": ["build", "dist", "typings", "sandbox", "_dev"],
"typeCheckingMode": "strict",
"reportImportCycles": "none",
"reportUnusedFunction": "none",
Expand Down
6 changes: 4 additions & 2 deletions setup.cfg
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
7 changes: 5 additions & 2 deletions shinylive/__init__.py
Original file line number Diff line number Diff line change
@@ -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")
204 changes: 187 additions & 17 deletions shinylive/_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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:
Expand Down Expand Up @@ -59,6 +69,7 @@ def list_commands(self, ctx: click.Context) -> list[str]:
# * language-resources
# * app-resources
# * Options: --json-file / stdin (required)
# * url


# #############################################################################
Expand All @@ -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:
...

Expand Down Expand Up @@ -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,
)
Expand All @@ -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(
Expand All @@ -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,
)
Expand Down Expand Up @@ -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,
)
Expand All @@ -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)

Expand All @@ -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,
)
Expand All @@ -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)

Expand All @@ -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)


# #############################################################################
Expand Down Expand Up @@ -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(),
},
Expand Down Expand Up @@ -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
Expand All @@ -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) }"

Expand Down
Loading

0 comments on commit 9c5050f

Please sign in to comment.