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

feat: Add shinylive url command #20

Merged
merged 41 commits into from
Jan 12, 2024
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
313debf
feat(url): Add `shinylive url`command and `make_shinylive_url()` func…
gadenbuie Jan 4, 2024
d570bfe
export `make_shinylive_url()`
gadenbuie Jan 4, 2024
a231982
chore: app and files could be Paths
gadenbuie Jan 4, 2024
13a4d9d
require lzstring
gadenbuie Jan 4, 2024
b6944fd
remove `__all__` from `__init__.py`
gadenbuie Jan 4, 2024
18cf274
noqa and pyright ingore
gadenbuie Jan 4, 2024
163f452
feat: shinylive url (encode,decode)
gadenbuie Jan 4, 2024
cccffbc
allow piping into `shinylive url decode`
gadenbuie Jan 5, 2024
281e47a
allow piping into `shinylive url encode` and detect app source code
gadenbuie Jan 5, 2024
637725d
negotiate aggressively with the type checker
gadenbuie Jan 5, 2024
e36fbd7
demote unused f string
gadenbuie Jan 5, 2024
7b02ac9
add comment
gadenbuie Jan 5, 2024
dd3d6f9
document --out option
gadenbuie Jan 5, 2024
294341a
require `--help` so that piping into url encode works
gadenbuie Jan 5, 2024
0dea7a4
include files, recursively
gadenbuie Jan 5, 2024
d725e57
less aggressive type check convincing
gadenbuie Jan 5, 2024
a7cc038
rename --out to --dir
gadenbuie Jan 5, 2024
331f958
automatically detect app language when app is the text content
gadenbuie Jan 5, 2024
2b9d3ce
Apply suggestions from code review
gadenbuie Jan 6, 2024
479f55c
import Literal
gadenbuie Jan 6, 2024
b5da6d8
type narrow language from encode CLI -> internal
gadenbuie Jan 6, 2024
f635f55
don't need to import os
gadenbuie Jan 6, 2024
ef8218f
add note about decode result wrt --dir
gadenbuie Jan 6, 2024
2da9c8a
write base64-decoded binary files
gadenbuie Jan 6, 2024
07c952f
detect_app_language() returns "py" or "r"
gadenbuie Jan 6, 2024
8f9e75a
make FileContentJson.type not required
gadenbuie Jan 6, 2024
e1b0b81
only add header param in app mode
gadenbuie Jan 6, 2024
b87490b
if file is str|Path, promote to list
gadenbuie Jan 6, 2024
246cdad
improve FileContentJson typing throughout
gadenbuie Jan 6, 2024
4a96487
exclude _dev folder from checks
gadenbuie Jan 6, 2024
89f4d27
fix syntax for creating FileContenJson objects
gadenbuie Jan 6, 2024
6d5c22a
require typing-extensions
gadenbuie Jan 6, 2024
373f639
separate bundle creation from URL encoding
gadenbuie Jan 6, 2024
d3a7665
add `encode_shinylive_url()` and make only encode/decode public
gadenbuie Jan 8, 2024
dd75236
simplify types and remove need for AppBundle
gadenbuie Jan 8, 2024
6674c6b
move package version into a subpackage
gadenbuie Jan 8, 2024
a9199b7
wrap decode outputs in helper functions, too
gadenbuie Jan 8, 2024
b6bf76f
rename version to _version
gadenbuie Jan 12, 2024
c80e99c
docs: describe feature in changelog
gadenbuie Jan 12, 2024
5a73146
fix one more _version import
gadenbuie Jan 12, 2024
bc0fad7
bump package version to 0.1.3.9000
gadenbuie Jan 12, 2024
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
1 change: 1 addition & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ install_requires =
shiny
click>=8.1.7
appdirs>=1.4.4
lzstring>=1.0.4
gadenbuie marked this conversation as resolved.
Show resolved Hide resolved
tests_require =
pytest>=3
zip_safe = False
Expand Down
6 changes: 6 additions & 0 deletions shinylive/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
"""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

__version__ = _version.SHINYLIVE_PACKAGE_VERSION

__all__ = (
"decode_shinylive_url",
"encode_shinylive_url",
)
150 changes: 150 additions & 0 deletions shinylive/_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import click

from . import _assets, _deps, _export, _version
from ._url import decode_shinylive_url, encode_shinylive_url
from ._utils import print_as_json


Expand Down Expand Up @@ -59,6 +60,7 @@ def list_commands(self, ctx: click.Context) -> list[str]:
# * language-resources
# * app-resources
# * Options: --json-file / stdin (required)
# * url


# #############################################################################
Expand Down Expand Up @@ -469,6 +471,154 @@ 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 for the app.
gadenbuie marked this conversation as resolved.
Show resolved Hide resolved

On macOS, you can copy the URL to the clipboard with:

shinylive url encode app.py | pbcopy
""",
no_args_is_help=True,
)
@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(
"--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(
gadenbuie marked this conversation as resolved.
Show resolved Hide resolved
app: str,
files: Optional[tuple[str, ...]] = None,
mode: str = "editor",
gadenbuie marked this conversation as resolved.
Show resolved Hide resolved
language: Optional[str] = None,
no_header: bool = False,
view: bool = False,
) -> None:
if app == "-":
app_in = sys.stdin.read()
else:
app_in = app

url = encode_shinylive_url(
app=app_in,
files=files,
mode=mode,
language=language,
header=not no_header,
)

print(url)

if view:
import webbrowser

webbrowser.open(url)
return
gadenbuie marked this conversation as resolved.
Show resolved Hide resolved


@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.

On macOS, you can read the URL from the clipboard with:

pbpaste | shinylive url decode
""",
)
@click.option(
"--out",
gadenbuie marked this conversation as resolved.
Show resolved Hide resolved
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. ",
gadenbuie marked this conversation as resolved.
Show resolved Hide resolved
)
@click.option(
"--json",
is_flag=True,
default=False,
help="Prints the decoded shinylive bundle as JSON to stdout, ignoring --out.",
gadenbuie marked this conversation as resolved.
Show resolved Hide resolved
)
@click.argument("url", type=str, nargs=1, default="-")
def decode(url: str, out: 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 out is not None:
import os

out_dir = Path(out)
os.makedirs(out_dir, exist_ok=True)
gadenbuie marked this conversation as resolved.
Show resolved Hide resolved
for file in bundle:
with open(out_dir / file["name"], "w") as f_out:
f_out.write(
file["content"].encode("utf-8", errors="ignore").decode("utf-8")
)
gadenbuie marked this conversation as resolved.
Show resolved Hide resolved
else:
print("")
for file in bundle:
print(f"## file: {file['name']}")
if "type" in file and file["type"] == "binary":
print("## type: binary")
print("")
print(file["content"].encode("utf-8", errors="ignore").decode("utf-8"))
gadenbuie marked this conversation as resolved.
Show resolved Hide resolved

return
gadenbuie marked this conversation as resolved.
Show resolved Hide resolved


# #############################################################################
# ## Deprecated commands
# #############################################################################


def defunct_error_txt(cmd: str) -> str:
return f"Error: { defunct_help(cmd) }"

Expand Down
158 changes: 158 additions & 0 deletions shinylive/_url.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
from __future__ import annotations

import base64
import json
from pathlib import Path
from typing import List, Literal, Optional, TypedDict, cast
gadenbuie marked this conversation as resolved.
Show resolved Hide resolved


class FileContentJson(TypedDict):
name: str
content: str
type: Literal["text", "binary"]
gadenbuie marked this conversation as resolved.
Show resolved Hide resolved


def encode_shinylive_url(
app: str | Path,
gadenbuie marked this conversation as resolved.
Show resolved Hide resolved
gadenbuie marked this conversation as resolved.
Show resolved Hide resolved
files: Optional[tuple[str | Path, ...]] = None,
gadenbuie marked this conversation as resolved.
Show resolved Hide resolved
mode: str = "editor",
gadenbuie marked this conversation as resolved.
Show resolved Hide resolved
language: Optional[str] = None,
gadenbuie marked this conversation as resolved.
Show resolved Hide resolved
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
A tuple of file paths to include in the application. On shinylive, these files
will be given stored relative to the main `app` file.
mode
The mode of the application. Defaults to "editor".
gadenbuie marked this conversation as resolved.
Show resolved Hide resolved
language
The language of the application. Defaults to None.
gadenbuie marked this conversation as resolved.
Show resolved Hide resolved
header
Whether to include a header. Defaults to True.
gadenbuie marked this conversation as resolved.
Show resolved Hide resolved

Returns
-------
The generated URL for the ShinyLive application.
"""
root_dir = Path(app).parent

# if app has a newline, then it's app content, not a path
if isinstance(app, str) and "\n" in app:
# now language is required
if language is None:
raise ValueError("If `app` is a string, then `language` must be specified.")
gadenbuie marked this conversation as resolved.
Show resolved Hide resolved
file_bundle = [
{
"name": f"app.{'py' if language == 'py' else 'R'}",
"content": app,
"type": "text",
gadenbuie marked this conversation as resolved.
Show resolved Hide resolved
}
]
else:
file_bundle = [read_file(app, root_dir)]

if files is not None:
file_bundle = file_bundle + [read_file(file, root_dir) for file in files]

if language is None:
language = file_bundle[0]["name"].split(".")[-1].lower()
else:
language = "py" if language.lower() in ["py", "python"] else "r"

# if first file is not named either `ui.R` or `server.R`, then make it app.{language}
if file_bundle[0]["name"] not in ["ui.R", "server.R"]:
file_bundle[0]["name"] = f"app.{'py' if language == 'py' else 'R'}"
gadenbuie marked this conversation as resolved.
Show resolved Hide resolved

file_lz = lzstring_file_bundle(cast(List[FileContentJson], file_bundle))

base = "https://shinylive.io"

return f"{base}/{language}/{mode}/#{'h=0&' if not header else ''}code={file_lz}"
gadenbuie marked this conversation as resolved.
Show resolved Hide resolved


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.")

# 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`."
)
if "type" in file and file["type"] not in ["text", "binary"]:
raise ValueError(
f"Invalid shinylive URL: unexpected file type '{file['type']}' in '{file['name']}'."
)
elif "type" not in file:
file["type"] = "text"
gadenbuie marked this conversation as resolved.
Show resolved Hide resolved
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."
)

return cast(List[FileContentJson], bundle)


# 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:
gadenbuie marked this conversation as resolved.
Show resolved Hide resolved
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,
gadenbuie marked this conversation as resolved.
Show resolved Hide resolved
}


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)