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 3 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
3 changes: 3 additions & 0 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 make_shinylive_url

__version__ = _version.SHINYLIVE_PACKAGE_VERSION

__all__ = ("make_shinylive_url",)
72 changes: 72 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 make_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,76 @@ def defunct_help(cmd: str) -> str:
"""


# #############################################################################
# ## shinylive.io link
# #############################################################################


@main.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
""",
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)
@click.argument("files", type=str, nargs=-1, required=False)
def url(
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:
url = make_shinylive_url(
app=app,
files=files,
mode=mode,
language=language,
no_header=no_header,
)

print(url)

if view:
import webbrowser

webbrowser.open(url)
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
96 changes: 96 additions & 0 deletions shinylive/_url.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
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

from lzstring import LZString # type: ignore[reportMissingTypeStubs]


def make_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
no_header: bool = False,
) -> 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
no_header
Whether to include a header. Defaults to False.
gadenbuie marked this conversation as resolved.
Show resolved Hide resolved

Returns
-------
The generated URL for the ShinyLive application.
"""
root_dir = Path(app).parent
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(file_bundle)

base = "https://shinylive.io"

return f"{base}/{language}/{mode}/#{'h=0&' if no_header else ''}code={file_lz}"


class FileContentJson(TypedDict):
name: str
content: str
type: Literal["text", "binary"]


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:
file_json = json.dumps(file_bundle)
file_lz = LZString.compressToEncodedURIComponent(file_json) # type: ignore[reportUnknownMemberType]
return cast(str, file_lz)
Loading