From 313debf67fc89f0edaf9b756afa04737bc264238 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Thu, 4 Jan 2024 14:08:54 -0500 Subject: [PATCH 01/41] feat(url): Add `shinylive url`command and `make_shinylive_url()` function --- shinylive/_main.py | 72 ++++++++++++++++++++++++++++++++++ shinylive/_url.py | 96 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 168 insertions(+) create mode 100644 shinylive/_url.py diff --git a/shinylive/_main.py b/shinylive/_main.py index 1e22153..3c9a2c4 100644 --- a/shinylive/_main.py +++ b/shinylive/_main.py @@ -8,6 +8,7 @@ import click from . import _assets, _deps, _export, _version +from ._url import make_shinylive_url from ._utils import print_as_json @@ -59,6 +60,7 @@ def list_commands(self, ctx: click.Context) -> list[str]: # * language-resources # * app-resources # * Options: --json-file / stdin (required) +# * url # ############################################################################# @@ -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. +""", + 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", + 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 + + +# ############################################################################# +# ## 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..738f471 --- /dev/null +++ b/shinylive/_url.py @@ -0,0 +1,96 @@ +import base64 +import json +from pathlib import Path +from typing import List, Literal, Optional, TypedDict, cast + +from lzstring import LZString # type: ignore[reportMissingTypeStubs] + + +def make_shinylive_url( + app: str, + files: Optional[tuple[str, ...]] = None, + mode: str = "editor", + language: Optional[str] = None, + 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". + language + The language of the application. Defaults to None. + no_header + Whether to include a header. Defaults to False. + + 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'}" + + 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: + 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: + file_json = json.dumps(file_bundle) + file_lz = LZString.compressToEncodedURIComponent(file_json) # type: ignore[reportUnknownMemberType] + return cast(str, file_lz) From d570bfec6468796d7dcdfb40557ae0b02604a8ba Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Thu, 4 Jan 2024 14:18:58 -0500 Subject: [PATCH 02/41] export `make_shinylive_url()` --- shinylive/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/shinylive/__init__.py b/shinylive/__init__.py index a8d9c47..f9d48dd 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 make_shinylive_url __version__ = _version.SHINYLIVE_PACKAGE_VERSION + +__all__ = ("make_shinylive_url",) From a23198244bda5b593ba4eaac3d5d41fb32a425c3 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Thu, 4 Jan 2024 14:30:06 -0500 Subject: [PATCH 03/41] chore: app and files could be Paths --- shinylive/_url.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/shinylive/_url.py b/shinylive/_url.py index 738f471..254fca2 100644 --- a/shinylive/_url.py +++ b/shinylive/_url.py @@ -7,8 +7,8 @@ def make_shinylive_url( - app: str, - files: Optional[tuple[str, ...]] = None, + app: str | Path, + files: Optional[tuple[str | Path, ...]] = None, mode: str = "editor", language: Optional[str] = None, no_header: bool = False, From 13a4d9d7c860b3bf380107d4402f2ebc19ed95a2 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Thu, 4 Jan 2024 15:06:51 -0500 Subject: [PATCH 04/41] require lzstring --- setup.cfg | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.cfg b/setup.cfg index ba68fdc..217a636 100644 --- a/setup.cfg +++ b/setup.cfg @@ -36,6 +36,7 @@ install_requires = shiny click>=8.1.7 appdirs>=1.4.4 + lzstring>=1.0.4 tests_require = pytest>=3 zip_safe = False From b6944fd028cc9b37b7272170083337a831caafdc Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Thu, 4 Jan 2024 15:17:04 -0500 Subject: [PATCH 05/41] remove `__all__` from `__init__.py` --- shinylive/__init__.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/shinylive/__init__.py b/shinylive/__init__.py index f9d48dd..75a6642 100644 --- a/shinylive/__init__.py +++ b/shinylive/__init__.py @@ -4,5 +4,3 @@ from ._url import make_shinylive_url __version__ = _version.SHINYLIVE_PACKAGE_VERSION - -__all__ = ("make_shinylive_url",) From 18cf274df4db97e331cc4c8bc44d945e7f0e6bdd Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Thu, 4 Jan 2024 15:59:14 -0500 Subject: [PATCH 06/41] noqa and pyright ingore --- shinylive/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shinylive/__init__.py b/shinylive/__init__.py index 75a6642..fe6d088 100644 --- a/shinylive/__init__.py +++ b/shinylive/__init__.py @@ -1,6 +1,6 @@ """A package for packaging Shiny applications that run on Python in the browser.""" from . import _version -from ._url import make_shinylive_url +from ._url import make_shinylive_url # noqa # pyright: ignore __version__ = _version.SHINYLIVE_PACKAGE_VERSION From 163f45270e4e0709537580cdc0a47b59d78a35f4 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Thu, 4 Jan 2024 17:39:57 -0500 Subject: [PATCH 07/41] feat: shinylive url (encode,decode) --- setup.cfg | 1 + shinylive/__init__.py | 7 ++++- shinylive/_main.py | 61 ++++++++++++++++++++++++++++++++++++++----- shinylive/_url.py | 34 +++++++++++++++++------- 4 files changed, 86 insertions(+), 17 deletions(-) diff --git a/setup.cfg b/setup.cfg index 217a636..2426844 100644 --- a/setup.cfg +++ b/setup.cfg @@ -37,6 +37,7 @@ install_requires = click>=8.1.7 appdirs>=1.4.4 lzstring>=1.0.4 + pyperclip>=1.8.2 tests_require = pytest>=3 zip_safe = False diff --git a/shinylive/__init__.py b/shinylive/__init__.py index fe6d088..d75b595 100644 --- a/shinylive/__init__.py +++ b/shinylive/__init__.py @@ -1,6 +1,11 @@ """A package for packaging Shiny applications that run on Python in the browser.""" from . import _version -from ._url import make_shinylive_url # noqa # pyright: ignore +from ._url import decode_shinylive_url, encode_shinylive_url __version__ = _version.SHINYLIVE_PACKAGE_VERSION + +__all__ = ( + "decode_shinylive_url", + "encode_shinylive_url", +) diff --git a/shinylive/_main.py b/shinylive/_main.py index 3c9a2c4..8d48ab9 100644 --- a/shinylive/_main.py +++ b/shinylive/_main.py @@ -8,7 +8,7 @@ import click from . import _assets, _deps, _export, _version -from ._url import make_shinylive_url +from ._url import decode_shinylive_url, encode_shinylive_url from ._utils import print_as_json @@ -472,11 +472,21 @@ def defunct_help(cmd: str) -> str: # ############################################################################# -# ## shinylive.io link +# ## shinylive.io url # ############################################################################# -@main.command( +@main.group( + short_help="Create or decode a shinylive.io URL.", + help=f"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. @@ -511,7 +521,7 @@ def defunct_help(cmd: str) -> str: ) @click.argument("app", type=str, nargs=1, required=True) @click.argument("files", type=str, nargs=-1, required=False) -def url( +def encode( app: str, files: Optional[tuple[str, ...]] = None, mode: str = "editor", @@ -519,12 +529,12 @@ def url( no_header: bool = False, view: bool = False, ) -> None: - url = make_shinylive_url( + url = encode_shinylive_url( app=app, files=files, mode=mode, language=language, - no_header=no_header, + header=not no_header, ) print(url) @@ -536,6 +546,45 @@ def url( return +@url.command( + short_help="Decode a shinylive.io URL.", +) +@click.option("--out", type=str, default=None, help="Output directory.") +@click.option("--json", is_flag=True, default=False, help="Output as JSON.") +@click.argument("url", type=str, nargs=1, required=False) +def decode(url: Optional[str], out: Optional[str], json: bool = False) -> None: + # if `url` is None, read the contents of the clipboard + if url is None: + import pyperclip + + url = pyperclip.paste() + + bundle = decode_shinylive_url(url.strip()) + + 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) + for file in bundle: + with open(out_dir / file["name"], "w") as f_out: + f_out.write(file["content"]) + else: + print("\n---- shinylive.io bundle ----") + for file in bundle: + print(f"## file: {file['name']}") + if "type" in file and file["type"] == "binary": + print("## type: binary") + print("") + print(file["content"]) + + return + + # ############################################################################# # ## Deprecated commands # ############################################################################# diff --git a/shinylive/_url.py b/shinylive/_url.py index 254fca2..652170f 100644 --- a/shinylive/_url.py +++ b/shinylive/_url.py @@ -1,17 +1,23 @@ +from __future__ import annotations + import base64 import json from pathlib import Path from typing import List, Literal, Optional, TypedDict, cast -from lzstring import LZString # type: ignore[reportMissingTypeStubs] + +class FileContentJson(TypedDict): + name: str + content: str + type: Literal["text", "binary"] -def make_shinylive_url( +def encode_shinylive_url( app: str | Path, files: Optional[tuple[str | Path, ...]] = None, mode: str = "editor", language: Optional[str] = None, - no_header: bool = False, + header: bool = True, ) -> str: """ Generate a URL for a [ShinyLive application](https://shinylive.io). @@ -29,8 +35,8 @@ def make_shinylive_url( The mode of the application. Defaults to "editor". language The language of the application. Defaults to None. - no_header - Whether to include a header. Defaults to False. + header + Whether to include a header. Defaults to True. Returns ------- @@ -55,13 +61,19 @@ def make_shinylive_url( base = "https://shinylive.io" - return f"{base}/{language}/{mode}/#{'h=0&' if no_header else ''}code={file_lz}" + return f"{base}/{language}/{mode}/#{'h=0&' if not header else ''}code={file_lz}" -class FileContentJson(TypedDict): - name: str - content: str - type: Literal["text", "binary"] +def decode_shinylive_url(url: str) -> FileContentJson: + from lzstring import LZString # type: ignore[reportMissingTypeStubs] + + bundle_json = cast( + str, + LZString.decompressFromEncodedURIComponent( # type: ignore + url.split("code=")[1] + ), + ) + return cast(FileContentJson, json.loads(bundle_json)) def read_file(file: str | Path, root_dir: str | Path | None = None) -> FileContentJson: @@ -91,6 +103,8 @@ def read_file(file: str | Path, root_dir: str | Path | None = None) -> FileConte 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) From cccffbcedfacb394cebbb0857d0a98f4970d85df Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Thu, 4 Jan 2024 21:21:23 -0500 Subject: [PATCH 08/41] allow piping into `shinylive url decode` --- setup.cfg | 1 - shinylive/_main.py | 45 ++++++++++++++++++++++++++++++------------ shinylive/_url.py | 49 ++++++++++++++++++++++++++++++++++++++-------- 3 files changed, 73 insertions(+), 22 deletions(-) diff --git a/setup.cfg b/setup.cfg index 2426844..217a636 100644 --- a/setup.cfg +++ b/setup.cfg @@ -37,7 +37,6 @@ install_requires = click>=8.1.7 appdirs>=1.4.4 lzstring>=1.0.4 - pyperclip>=1.8.2 tests_require = pytest>=3 zip_safe = False diff --git a/shinylive/_main.py b/shinylive/_main.py index 8d48ab9..895445f 100644 --- a/shinylive/_main.py +++ b/shinylive/_main.py @@ -494,6 +494,10 @@ def url() -> None: APP is the path to the primary Shiny app file. FILES are additional supporting files for the app. + +On macOS, you can copy the URL to the clipboard with: + + shinylive url encode app.py | pbcopy """, no_args_is_help=True, ) @@ -548,18 +552,31 @@ def encode( @url.command( short_help="Decode a shinylive.io URL.", -) -@click.option("--out", type=str, default=None, help="Output directory.") -@click.option("--json", is_flag=True, default=False, help="Output as JSON.") -@click.argument("url", type=str, nargs=1, required=False) -def decode(url: Optional[str], out: Optional[str], json: bool = False) -> None: - # if `url` is None, read the contents of the clipboard - if url is None: - import pyperclip + 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. - url = pyperclip.paste() +On macOS, you can read the URL from the clipboard with: - bundle = decode_shinylive_url(url.strip()) + pbpaste | shinylive url decode +""", +) +@click.option("--out", type=str, default=None, help="Output directory.") +@click.option( + "--json", + is_flag=True, + default=False, + help="Prints the decoded shinylive bundle as JSON to stdout, ignoring --out.", +) +@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) @@ -572,15 +589,17 @@ def decode(url: Optional[str], out: Optional[str], json: bool = False) -> None: os.makedirs(out_dir, exist_ok=True) for file in bundle: with open(out_dir / file["name"], "w") as f_out: - f_out.write(file["content"]) + f_out.write( + file["content"].encode("utf-8", errors="ignore").decode("utf-8") + ) else: - print("\n---- shinylive.io bundle ----") + 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"]) + print(file["content"].encode("utf-8", errors="ignore").decode("utf-8")) return diff --git a/shinylive/_url.py b/shinylive/_url.py index 652170f..d4d89bb 100644 --- a/shinylive/_url.py +++ b/shinylive/_url.py @@ -64,16 +64,49 @@ def encode_shinylive_url( return f"{base}/{language}/{mode}/#{'h=0&' if not header else ''}code={file_lz}" -def decode_shinylive_url(url: str) -> FileContentJson: +def decode_shinylive_url(url: str) -> List[FileContentJson]: from lzstring import LZString # type: ignore[reportMissingTypeStubs] - bundle_json = cast( - str, - LZString.decompressFromEncodedURIComponent( # type: ignore - url.split("code=")[1] - ), - ) - return cast(FileContentJson, json.loads(bundle_json)) + 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: + 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" + if not all(isinstance(value, str) for value in file.values()): + raise ValueError( + f"Invalid shinylive URL: not all items in '{file['name']}' were strings." + ) + + return cast(List[FileContentJson], bundle) def read_file(file: str | Path, root_dir: str | Path | None = None) -> FileContentJson: From 281e47aa547efa4d8ab389b44ddd56efa2e6f164 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Thu, 4 Jan 2024 21:38:11 -0500 Subject: [PATCH 09/41] 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) --- shinylive/_main.py | 9 +++++++-- shinylive/_url.py | 16 +++++++++++++++- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/shinylive/_main.py b/shinylive/_main.py index 895445f..7ed0211 100644 --- a/shinylive/_main.py +++ b/shinylive/_main.py @@ -523,7 +523,7 @@ def url() -> None: @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("app", type=str, nargs=1, required=True, default="-") @click.argument("files", type=str, nargs=-1, required=False) def encode( app: str, @@ -533,8 +533,13 @@ def encode( 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, + app=app_in, files=files, mode=mode, language=language, diff --git a/shinylive/_url.py b/shinylive/_url.py index d4d89bb..5811644 100644 --- a/shinylive/_url.py +++ b/shinylive/_url.py @@ -43,7 +43,21 @@ def encode_shinylive_url( The generated URL for the ShinyLive application. """ root_dir = Path(app).parent - file_bundle = [read_file(app, root_dir)] + + # 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.") + file_bundle = [ + { + "name": f"app.{'py' if language == 'py' else 'R'}", + "content": app, + "type": "text", + } + ] + 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] From 637725d47468badb252f548b1d2c8ba980308e9d Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Thu, 4 Jan 2024 21:38:29 -0500 Subject: [PATCH 10/41] negotiate aggressively with the type checker --- shinylive/_url.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/shinylive/_url.py b/shinylive/_url.py index 5811644..aadb5e4 100644 --- a/shinylive/_url.py +++ b/shinylive/_url.py @@ -71,7 +71,7 @@ def encode_shinylive_url( if file_bundle[0]["name"] not in ["ui.R", "server.R"]: file_bundle[0]["name"] = f"app.{'py' if language == 'py' else 'R'}" - file_lz = lzstring_file_bundle(file_bundle) + file_lz = lzstring_file_bundle(cast(List[FileContentJson], file_bundle)) base = "https://shinylive.io" @@ -100,7 +100,7 @@ def decode_shinylive_url(url: str) -> List[FileContentJson]: "The shinylive URL was not formatted correctly: `code` did not decode to a list." ) - for file in bundle: + 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." @@ -115,7 +115,7 @@ def decode_shinylive_url(url: str) -> List[FileContentJson]: ) elif "type" not in file: file["type"] = "text" - if not all(isinstance(value, str) for value in file.values()): + 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." ) From e36fbd7a6fb5f36d8a94e7c55f8081d3d76c8c85 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Thu, 4 Jan 2024 21:40:03 -0500 Subject: [PATCH 11/41] demote unused f string --- shinylive/_main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shinylive/_main.py b/shinylive/_main.py index 7ed0211..938b7df 100644 --- a/shinylive/_main.py +++ b/shinylive/_main.py @@ -478,7 +478,7 @@ def defunct_help(cmd: str) -> str: @main.group( short_help="Create or decode a shinylive.io URL.", - help=f"Create or decode a shinylive.io URL.", + help="Create or decode a shinylive.io URL.", no_args_is_help=True, cls=OrderedGroup, ) From 7b02ac9b53d8d0f6a4812ff767d5845d2aa99ca5 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Thu, 4 Jan 2024 21:44:31 -0500 Subject: [PATCH 12/41] add comment --- shinylive/_url.py | 1 + 1 file changed, 1 insertion(+) diff --git a/shinylive/_url.py b/shinylive/_url.py index aadb5e4..29a82fa 100644 --- a/shinylive/_url.py +++ b/shinylive/_url.py @@ -123,6 +123,7 @@ def decode_shinylive_url(url: str) -> List[FileContentJson]: 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: file = Path(file) if root_dir is None: From dd3d6f9bc77069502d9d23c3e54b7199a0b309f3 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Thu, 4 Jan 2024 21:46:11 -0500 Subject: [PATCH 13/41] document --out option --- shinylive/_main.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/shinylive/_main.py b/shinylive/_main.py index 938b7df..6f385a0 100644 --- a/shinylive/_main.py +++ b/shinylive/_main.py @@ -568,7 +568,12 @@ def encode( pbpaste | shinylive url decode """, ) -@click.option("--out", type=str, default=None, help="Output directory.") +@click.option( + "--out", + 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, From 294341adfe8990f70d6115170528537caa32bc56 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Fri, 5 Jan 2024 14:23:17 -0500 Subject: [PATCH 14/41] require `--help` so that piping into url encode works --- shinylive/_main.py | 1 - 1 file changed, 1 deletion(-) diff --git a/shinylive/_main.py b/shinylive/_main.py index 6f385a0..2ad6373 100644 --- a/shinylive/_main.py +++ b/shinylive/_main.py @@ -499,7 +499,6 @@ def url() -> None: shinylive url encode app.py | pbcopy """, - no_args_is_help=True, ) @click.option( "-m", From 0dea7a433e139c1a363537cca58782615b697ad3 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Fri, 5 Jan 2024 14:36:52 -0500 Subject: [PATCH 15/41] include files, recursively --- shinylive/_main.py | 2 +- shinylive/_url.py | 37 +++++++++++++++++++++++++++++++++---- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/shinylive/_main.py b/shinylive/_main.py index 2ad6373..22851cc 100644 --- a/shinylive/_main.py +++ b/shinylive/_main.py @@ -493,7 +493,7 @@ def url() -> None: APP is the path to the primary Shiny app file. -FILES are additional supporting files for the app. +FILES are additional supporting files or directories for the app. On macOS, you can copy the URL to the clipboard with: diff --git a/shinylive/_url.py b/shinylive/_url.py index 29a82fa..c428a23 100644 --- a/shinylive/_url.py +++ b/shinylive/_url.py @@ -2,6 +2,7 @@ import base64 import json +import os from pathlib import Path from typing import List, Literal, Optional, TypedDict, cast @@ -29,8 +30,9 @@ def encode_shinylive_url( `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. + A tuple of file or directory paths 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. Defaults to "editor". language @@ -42,13 +44,15 @@ def encode_shinylive_url( ------- 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.") + + app_path = "" + root_dir = Path(".") file_bundle = [ { "name": f"app.{'py' if language == 'py' else 'R'}", @@ -57,10 +61,22 @@ def encode_shinylive_url( } ] else: + app_path = Path(app) + root_dir = app_path.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] + 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 + ] if language is None: language = file_bundle[0]["name"].split(".")[-1].lower() @@ -78,6 +94,19 @@ def encode_shinylive_url( return f"{base}/{language}/{mode}/#{'h=0&' if not header else ''}code={file_lz}" +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] From d725e572ca55513d2ecc0aa1d8a3e0fc0f0a0ba5 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Fri, 5 Jan 2024 15:50:45 -0500 Subject: [PATCH 16/41] less aggressive type check convincing --- shinylive/_url.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/shinylive/_url.py b/shinylive/_url.py index c428a23..7fbe4ba 100644 --- a/shinylive/_url.py +++ b/shinylive/_url.py @@ -123,6 +123,8 @@ def decode_shinylive_url(url: str) -> List[FileContentJson]: 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( @@ -148,8 +150,9 @@ def decode_shinylive_url(url: str) -> List[FileContentJson]: raise ValueError( f"Invalid shinylive URL: not all items in '{file['name']}' were strings." ) + ret.append(FileContentJson(file)) # pyright: ignore - return cast(List[FileContentJson], bundle) + return ret # Copied from https://github.com/posit-dev/py-shiny/blob/main/docs/_renderer.py#L231 From a7cc038bcf0000720ad1a4b9837bd3eda8dd559b Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Fri, 5 Jan 2024 17:12:41 -0500 Subject: [PATCH 17/41] rename --out to --dir --- shinylive/_main.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/shinylive/_main.py b/shinylive/_main.py index 22851cc..f0fdeec 100644 --- a/shinylive/_main.py +++ b/shinylive/_main.py @@ -568,7 +568,7 @@ def encode( """, ) @click.option( - "--out", + "--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. ", @@ -580,7 +580,7 @@ def encode( help="Prints the decoded shinylive bundle as JSON to stdout, ignoring --out.", ) @click.argument("url", type=str, nargs=1, default="-") -def decode(url: str, out: Optional[str] = None, json: bool = False) -> None: +def decode(url: str, dir: Optional[str] = None, json: bool = False) -> None: if url == "-": url_in = sys.stdin.read() else: @@ -591,10 +591,10 @@ def decode(url: str, out: Optional[str] = None, json: bool = False) -> None: print_as_json(bundle) return - if out is not None: + if dir is not None: import os - out_dir = Path(out) + out_dir = Path(dir) os.makedirs(out_dir, exist_ok=True) for file in bundle: with open(out_dir / file["name"], "w") as f_out: From 331f9587e20f5114401998a85f00329def5eb94c Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Fri, 5 Jan 2024 17:44:08 -0500 Subject: [PATCH 18/41] automatically detect app language when app is the text content --- shinylive/_url.py | 41 ++++++++++++++++++++++++++++++++++------- 1 file changed, 34 insertions(+), 7 deletions(-) diff --git a/shinylive/_url.py b/shinylive/_url.py index 7fbe4ba..e9946cd 100644 --- a/shinylive/_url.py +++ b/shinylive/_url.py @@ -3,6 +3,7 @@ import base64 import json import os +import re from pathlib import Path from typing import List, Literal, Optional, TypedDict, cast @@ -45,12 +46,11 @@ def encode_shinylive_url( The generated URL for the ShinyLive application. """ + if language is None: + language = detect_app_language(app) + # 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.") - app_path = "" root_dir = Path(".") file_bundle = [ @@ -78,10 +78,14 @@ def encode_shinylive_url( read_file(file, root_dir) for file in file_list if Path(file) != app_path ] - if language is None: - language = file_bundle[0]["name"].split(".")[-1].lower() + if language in ["py", "python"]: + language = "py" + elif language in ["r", "R"]: + language = "r" else: - language = "py" if language.lower() in ["py", "python"] else "r" + raise ValueError( + f"Language '{language}' is not supported. Please specify one of 'py' or '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"]: @@ -94,6 +98,29 @@ def encode_shinylive_url( return f"{base}/{language}/{mode}/#{'h=0&' if not header else ''}code={file_lz}" +def detect_app_language(app: str | Path) -> str: + 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) + + if not isinstance(app, Path): + 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] = [] From 2b9d3ce2c8fafb31f90c19da031513d19723e1a1 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Sat, 6 Jan 2024 13:10:53 -0500 Subject: [PATCH 19/41] Apply suggestions from code review Co-authored-by: Winston Chang --- shinylive/_main.py | 9 +++------ shinylive/_url.py | 26 +++++++++----------------- 2 files changed, 12 insertions(+), 23 deletions(-) diff --git a/shinylive/_main.py b/shinylive/_main.py index f0fdeec..b7f4521 100644 --- a/shinylive/_main.py +++ b/shinylive/_main.py @@ -527,7 +527,7 @@ def url() -> None: def encode( app: str, files: Optional[tuple[str, ...]] = None, - mode: str = "editor", + mode: Literal["editor", "app"] = "editor", language: Optional[str] = None, no_header: bool = False, view: bool = False, @@ -551,7 +551,6 @@ def encode( import webbrowser webbrowser.open(url) - return @url.command( @@ -577,7 +576,7 @@ def encode( "--json", is_flag=True, default=False, - help="Prints the decoded shinylive bundle as JSON to stdout, ignoring --out.", + 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: @@ -595,7 +594,7 @@ def decode(url: str, dir: Optional[str] = None, json: bool = False) -> None: import os out_dir = Path(dir) - os.makedirs(out_dir, exist_ok=True) + out_dir.mkdir(parents=True, exist_ok=True) for file in bundle: with open(out_dir / file["name"], "w") as f_out: f_out.write( @@ -610,8 +609,6 @@ def decode(url: str, dir: Optional[str] = None, json: bool = False) -> None: print("") print(file["content"].encode("utf-8", errors="ignore").decode("utf-8")) - return - # ############################################################################# # ## Deprecated commands diff --git a/shinylive/_url.py b/shinylive/_url.py index e9946cd..82af977 100644 --- a/shinylive/_url.py +++ b/shinylive/_url.py @@ -5,7 +5,7 @@ import os import re from pathlib import Path -from typing import List, Literal, Optional, TypedDict, cast +from typing import Literal, Optional, TypedDict, cast class FileContentJson(TypedDict): @@ -16,9 +16,9 @@ class FileContentJson(TypedDict): def encode_shinylive_url( app: str | Path, - files: Optional[tuple[str | Path, ...]] = None, - mode: str = "editor", - language: Optional[str] = None, + files: Optional[str | Path | Sequence[str | Path]] = None, + mode: Literal["editor", "app"] = "editor", + language: Optional[Literal["py", "r"]] = None, header: bool = True, ) -> str: """ @@ -35,11 +35,11 @@ def encode_shinylive_url( 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. Defaults to "editor". + The mode of the application, either "editor" or "app". Defaults to "editor". language - The language of the application. Defaults to None. + The language of the application, or None to autodetect the language. Defaults to None. header - Whether to include a header. Defaults to True. + Whether to include a header bar in the UI. This is used only if ``mode`` is "app". Defaults to True. Returns ------- @@ -57,7 +57,6 @@ def encode_shinylive_url( { "name": f"app.{'py' if language == 'py' else 'R'}", "content": app, - "type": "text", } ] else: @@ -78,11 +77,7 @@ def encode_shinylive_url( read_file(file, root_dir) for file in file_list if Path(file) != app_path ] - if language in ["py", "python"]: - language = "py" - elif language in ["r", "R"]: - language = "r" - else: + if language not in ["py", "r"]: raise ValueError( f"Language '{language}' is not supported. Please specify one of 'py' or 'r'." ) @@ -110,8 +105,7 @@ def detect_app_language(app: str | Path) -> str: else: raise ValueError(err_not_detected) - if not isinstance(app, Path): - app = Path(app) + app = Path(app) if app.suffix.lower() == ".py": return "py" @@ -171,8 +165,6 @@ def decode_shinylive_url(url: str) -> List[FileContentJson]: raise ValueError( f"Invalid shinylive URL: unexpected file type '{file['type']}' in '{file['name']}'." ) - elif "type" not in file: - file["type"] = "text" 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." From 479f55caf6881d4e48d688e192aaeff07a3e03eb Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Sat, 6 Jan 2024 13:18:52 -0500 Subject: [PATCH 20/41] import Literal --- shinylive/_main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shinylive/_main.py b/shinylive/_main.py index b7f4521..2535f5e 100644 --- a/shinylive/_main.py +++ b/shinylive/_main.py @@ -3,7 +3,7 @@ import collections import sys from pathlib import Path -from typing import MutableMapping, Optional +from typing import Literal, MutableMapping, Optional import click From b5da6d81f1f2d9442e3889524f2944c1ece4cee1 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Sat, 6 Jan 2024 13:19:23 -0500 Subject: [PATCH 21/41] type narrow language from encode CLI -> internal --- shinylive/_main.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/shinylive/_main.py b/shinylive/_main.py index 2535f5e..61b1244 100644 --- a/shinylive/_main.py +++ b/shinylive/_main.py @@ -537,11 +537,23 @@ def encode( 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 = None + url = encode_shinylive_url( app=app_in, files=files, mode=mode, - language=language, + language=lang, header=not no_header, ) From f635f55daad0e0aac6f79bb09d5aa5d7a69df357 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Sat, 6 Jan 2024 13:20:00 -0500 Subject: [PATCH 22/41] don't need to import os --- shinylive/_main.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/shinylive/_main.py b/shinylive/_main.py index 61b1244..ce176dd 100644 --- a/shinylive/_main.py +++ b/shinylive/_main.py @@ -603,8 +603,6 @@ def decode(url: str, dir: Optional[str] = None, json: bool = False) -> None: return if dir is not None: - import os - out_dir = Path(dir) out_dir.mkdir(parents=True, exist_ok=True) for file in bundle: From ef8218f47790ae854001f86b5983186235ad8f3b Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Sat, 6 Jan 2024 13:21:33 -0500 Subject: [PATCH 23/41] add note about decode result wrt --dir --- shinylive/_main.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/shinylive/_main.py b/shinylive/_main.py index ce176dd..2cfbdea 100644 --- a/shinylive/_main.py +++ b/shinylive/_main.py @@ -573,6 +573,9 @@ def encode( 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 From 2da9c8ab460ae8ef3f24f6e0e0b815e2a74c1ca4 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Sat, 6 Jan 2024 13:26:12 -0500 Subject: [PATCH 24/41] write base64-decoded binary files --- shinylive/_main.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/shinylive/_main.py b/shinylive/_main.py index 2cfbdea..f25cb89 100644 --- a/shinylive/_main.py +++ b/shinylive/_main.py @@ -609,10 +609,16 @@ def decode(url: str, dir: Optional[str] = None, json: bool = False) -> None: out_dir = Path(dir) out_dir.mkdir(parents=True, exist_ok=True) 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") - ) + 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") + ) else: print("") for file in bundle: From 07c952fa6d52b1d0d9d5f3932f5865c312ba5298 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Sat, 6 Jan 2024 13:34:47 -0500 Subject: [PATCH 25/41] detect_app_language() returns "py" or "r" --- shinylive/_url.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shinylive/_url.py b/shinylive/_url.py index 82af977..b0ca95c 100644 --- a/shinylive/_url.py +++ b/shinylive/_url.py @@ -93,7 +93,7 @@ def encode_shinylive_url( return f"{base}/{language}/{mode}/#{'h=0&' if not header else ''}code={file_lz}" -def detect_app_language(app: str | Path) -> str: +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`.""" From 8f9e75a42f48e5bc39883c74248d182bd25e8477 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Sat, 6 Jan 2024 13:35:21 -0500 Subject: [PATCH 26/41] make FileContentJson.type not required --- shinylive/_url.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/shinylive/_url.py b/shinylive/_url.py index b0ca95c..f06f41d 100644 --- a/shinylive/_url.py +++ b/shinylive/_url.py @@ -4,14 +4,23 @@ import json import os import re +import sys from pathlib import Path -from typing import Literal, Optional, TypedDict, cast +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: Literal["text", "binary"] + type: NotRequired[Literal["text", "binary"]] def encode_shinylive_url( From e1b0b8133b9046c9c34d6356e9a99656ce9bf724 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Sat, 6 Jan 2024 13:37:38 -0500 Subject: [PATCH 27/41] only add header param in app mode --- shinylive/_url.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/shinylive/_url.py b/shinylive/_url.py index f06f41d..90fd148 100644 --- a/shinylive/_url.py +++ b/shinylive/_url.py @@ -98,8 +98,9 @@ def encode_shinylive_url( file_lz = lzstring_file_bundle(cast(List[FileContentJson], file_bundle)) base = "https://shinylive.io" + h = "h=0&" if not header and mode == "app" else "" - return f"{base}/{language}/{mode}/#{'h=0&' if not header else ''}code={file_lz}" + return f"{base}/{language}/{mode}/#{h}code={file_lz}" def detect_app_language(app: str | Path) -> Literal["py", "r"]: From b87490b19b316582ded24e543eab568919ccb1b2 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Sat, 6 Jan 2024 13:38:11 -0500 Subject: [PATCH 28/41] if file is str|Path, promote to list --- shinylive/_url.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/shinylive/_url.py b/shinylive/_url.py index 90fd148..5219e83 100644 --- a/shinylive/_url.py +++ b/shinylive/_url.py @@ -73,6 +73,9 @@ def encode_shinylive_url( root_dir = app_path.parent file_bundle = [read_file(app, root_dir)] + if isinstance(files, (str, Path)): + files = [files] + if files is not None: file_list: list[str | Path] = [] From 246cdadd58ad6fefe8e777eca299fdc9ae876552 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Sat, 6 Jan 2024 13:49:58 -0500 Subject: [PATCH 29/41] improve FileContentJson typing throughout --- shinylive/_url.py | 46 +++++++++++++++++++++++++++++++++------------- 1 file changed, 33 insertions(+), 13 deletions(-) diff --git a/shinylive/_url.py b/shinylive/_url.py index 5219e83..d7f334e 100644 --- a/shinylive/_url.py +++ b/shinylive/_url.py @@ -63,10 +63,12 @@ def encode_shinylive_url( app_path = "" root_dir = Path(".") file_bundle = [ - { - "name": f"app.{'py' if language == 'py' else 'R'}", - "content": app, - } + FileContentJson( + { + "name": f"app.{'py' if language == 'py' else 'R'}", + "content": app, + } + ) ] else: app_path = Path(app) @@ -98,7 +100,7 @@ def encode_shinylive_url( if file_bundle[0]["name"] not in ["ui.R", "server.R"]: file_bundle[0]["name"] = f"app.{'py' if language == 'py' else 'R'}" - file_lz = lzstring_file_bundle(cast(List[FileContentJson], file_bundle)) + file_lz = lzstring_file_bundle([FileContentJson(f) for f in file_bundle]) base = "https://shinylive.io" h = "h=0&" if not header and mode == "app" else "" @@ -141,7 +143,7 @@ def listdir_recursive(dir: str | Path) -> list[str]: return all_files -def decode_shinylive_url(url: str) -> List[FileContentJson]: +def decode_shinylive_url(url: str) -> list[FileContentJson]: from lzstring import LZString # type: ignore[reportMissingTypeStubs] url = url.strip() @@ -157,7 +159,7 @@ def decode_shinylive_url(url: str) -> List[FileContentJson]: except Exception: raise ValueError("Could not parse and decode the shinylive URL code payload.") - ret: List[FileContentJson] = [] + ret: list[FileContentJson] = [] # bundle should be an array of FileContentJson objects, otherwise raise an error if not isinstance(bundle, list): @@ -174,15 +176,33 @@ def decode_shinylive_url(url: str) -> List[FileContentJson]: 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']}'." - ) + + 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(FileContentJson(file)) # pyright: ignore + ret.append(FileContentJson(fc)) return ret @@ -214,7 +234,7 @@ def read_file(file: str | Path, root_dir: str | Path | None = None) -> FileConte } -def lzstring_file_bundle(file_bundle: List[FileContentJson]) -> str: +def lzstring_file_bundle(file_bundle: list[FileContentJson]) -> str: from lzstring import LZString # type: ignore[reportMissingTypeStubs] file_json = json.dumps(file_bundle) From 4a96487ebeb560f95e4ca7566a1693aa496027e6 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Sat, 6 Jan 2024 13:54:15 -0500 Subject: [PATCH 30/41] exclude _dev folder from checks --- pyrightconfig.json | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 217a636..08cb660 100644 --- a/setup.cfg +++ b/setup.cfg @@ -70,7 +70,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 From 89f4d271e4c673e554db70a31267d81dba43fcc9 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Sat, 6 Jan 2024 14:11:05 -0500 Subject: [PATCH 31/41] fix syntax for creating FileContenJson objects --- shinylive/_url.py | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/shinylive/_url.py b/shinylive/_url.py index d7f334e..9ee34bf 100644 --- a/shinylive/_url.py +++ b/shinylive/_url.py @@ -62,14 +62,11 @@ def encode_shinylive_url( if isinstance(app, str) and "\n" in app: app_path = "" root_dir = Path(".") - file_bundle = [ - FileContentJson( - { - "name": f"app.{'py' if language == 'py' else 'R'}", - "content": app, - } - ) - ] + app_fc: FileContentJson = { + "name": f"app.{'py' if language == 'py' else 'R'}", + "content": app, + } + file_bundle = [app_fc] else: app_path = Path(app) root_dir = app_path.parent @@ -100,7 +97,7 @@ def encode_shinylive_url( if file_bundle[0]["name"] not in ["ui.R", "server.R"]: file_bundle[0]["name"] = f"app.{'py' if language == 'py' else 'R'}" - file_lz = lzstring_file_bundle([FileContentJson(f) for f in file_bundle]) + file_lz = lzstring_file_bundle(file_bundle) base = "https://shinylive.io" h = "h=0&" if not header and mode == "app" else "" @@ -202,7 +199,7 @@ def decode_shinylive_url(url: str) -> list[FileContentJson]: raise ValueError( f"Invalid shinylive URL: not all items in '{file['name']}' were strings." ) - ret.append(FileContentJson(fc)) + ret.append(fc) return ret From 6d5c22a2ff98d9f2be5b3265361fbdbc355c62b7 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Sat, 6 Jan 2024 14:37:24 -0500 Subject: [PATCH 32/41] require typing-extensions --- setup.cfg | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.cfg b/setup.cfg index 08cb660..41fe59f 100644 --- a/setup.cfg +++ b/setup.cfg @@ -37,6 +37,7 @@ install_requires = click>=8.1.7 appdirs>=1.4.4 lzstring>=1.0.4 + typing-extensions>=4.0.1 tests_require = pytest>=3 zip_safe = False From 373f639ed81336f2a1393b9726d2a2a1dc48a0b9 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Sat, 6 Jan 2024 15:16:31 -0500 Subject: [PATCH 33/41] separate bundle creation from URL encoding --- shinylive/__init__.py | 11 ++++- shinylive/_main.py | 30 +++++++++--- shinylive/_url.py | 106 +++++++++++++++++++++++++++++------------- 3 files changed, 107 insertions(+), 40 deletions(-) diff --git a/shinylive/__init__.py b/shinylive/__init__.py index d75b595..75952d8 100644 --- a/shinylive/__init__.py +++ b/shinylive/__init__.py @@ -1,11 +1,18 @@ """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 ._url import ( + create_shinylive_bundle_file, + create_shinylive_bundle_text, + create_shinylive_url, + decode_shinylive_url, +) __version__ = _version.SHINYLIVE_PACKAGE_VERSION __all__ = ( "decode_shinylive_url", - "encode_shinylive_url", + "create_shinylive_url", + "create_shinylive_bundle_text", + "create_shinylive_bundle_file", ) diff --git a/shinylive/_main.py b/shinylive/_main.py index f25cb89..b457699 100644 --- a/shinylive/_main.py +++ b/shinylive/_main.py @@ -8,7 +8,12 @@ import click from . import _assets, _deps, _export, _version -from ._url import decode_shinylive_url, encode_shinylive_url +from ._url import ( + create_shinylive_bundle_file, + create_shinylive_bundle_text, + create_shinylive_url, + decode_shinylive_url, +) from ._utils import print_as_json @@ -519,6 +524,9 @@ def url() -> None: @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." ) @@ -529,6 +537,7 @@ def encode( 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: @@ -549,15 +558,24 @@ def encode( else: lang = None - url = encode_shinylive_url( - app=app_in, - files=files, + 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["files"]) + if not view: + return + + url = create_shinylive_url( + bundle, mode=mode, - language=lang, header=not no_header, ) - print(url) + if not json: + print(url) if view: import webbrowser diff --git a/shinylive/_url.py b/shinylive/_url.py index 9ee34bf..eaebb16 100644 --- a/shinylive/_url.py +++ b/shinylive/_url.py @@ -23,13 +23,55 @@ class FileContentJson(TypedDict): type: NotRequired[Literal["text", "binary"]] -def encode_shinylive_url( - app: str | Path, - files: Optional[str | Path | Sequence[str | Path]] = None, +class AppBundle(TypedDict): + language: Literal["py", "r"] + files: list[FileContentJson] + + +def create_shinylive_url( + bundle: AppBundle, mode: Literal["editor", "app"] = "editor", - language: Optional[Literal["py", "r"]] = None, header: bool = True, ) -> str: + """ """ + + file_lz = lzstring_file_bundle(bundle["files"]) + + base = "https://shinylive.io" + h = "h=0&" if not header and mode == "app" else "" + + return f"{base}/{bundle['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 = ".", +) -> AppBundle: + 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 { + "language": language, + "files": 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, +) -> AppBundle: """ Generate a URL for a [ShinyLive application](https://shinylive.io). @@ -57,20 +99,34 @@ def encode_shinylive_url( 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'." + ) - # if app has a newline, then it's app content, not a path - if isinstance(app, str) and "\n" in app: - app_path = "" - root_dir = Path(".") - app_fc: FileContentJson = { - "name": f"app.{'py' if language == 'py' else 'R'}", - "content": app, - } - file_bundle = [app_fc] - else: - app_path = Path(app) - root_dir = app_path.parent - file_bundle = [read_file(app, root_dir)] + 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 { + "language": language, + "files": 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] @@ -88,21 +144,7 @@ def encode_shinylive_url( read_file(file, root_dir) for file in file_list if Path(file) != app_path ] - if language not in ["py", "r"]: - raise ValueError( - f"Language '{language}' is not supported. Please specify one of 'py' or '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'}" - - file_lz = lzstring_file_bundle(file_bundle) - - base = "https://shinylive.io" - h = "h=0&" if not header and mode == "app" else "" - - return f"{base}/{language}/{mode}/#{h}code={file_lz}" + return file_bundle def detect_app_language(app: str | Path) -> Literal["py", "r"]: From d3a7665f325bfebec5b68f475b4a072ee2df2966 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Mon, 8 Jan 2024 10:23:29 -0500 Subject: [PATCH 34/41] add `encode_shinylive_url()` and make only encode/decode public --- shinylive/__init__.py | 14 ++------------ shinylive/_url.py | 20 ++++++++++++++++++-- 2 files changed, 20 insertions(+), 14 deletions(-) diff --git a/shinylive/__init__.py b/shinylive/__init__.py index 75952d8..bd9e03b 100644 --- a/shinylive/__init__.py +++ b/shinylive/__init__.py @@ -1,18 +1,8 @@ """A package for packaging Shiny applications that run on Python in the browser.""" from . import _version -from ._url import ( - create_shinylive_bundle_file, - create_shinylive_bundle_text, - create_shinylive_url, - decode_shinylive_url, -) +from ._url import decode_shinylive_url, encode_shinylive_url __version__ = _version.SHINYLIVE_PACKAGE_VERSION -__all__ = ( - "decode_shinylive_url", - "create_shinylive_url", - "create_shinylive_bundle_text", - "create_shinylive_bundle_file", -) +__all__ = ("decode_shinylive_url", "encode_shinylive_url") diff --git a/shinylive/_url.py b/shinylive/_url.py index eaebb16..924f369 100644 --- a/shinylive/_url.py +++ b/shinylive/_url.py @@ -28,13 +28,29 @@ class AppBundle(TypedDict): files: list[FileContentJson] +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: + if language is not None and language not in ["py", "r"]: + raise ValueError(f"Invalid language '{language}', must be either 'py' or 'r'.") + + if isinstance(app, str) and "\n" in app: + bundle = create_shinylive_bundle_text(app, files, language) + else: + bundle = create_shinylive_bundle_file(app, files, language) + + return create_shinylive_url(bundle, mode=mode, header=header) + + def create_shinylive_url( bundle: AppBundle, mode: Literal["editor", "app"] = "editor", header: bool = True, ) -> str: - """ """ - file_lz = lzstring_file_bundle(bundle["files"]) base = "https://shinylive.io" From dd75236be38858294ef9e4a96eedbb9c97b3a7dd Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Mon, 8 Jan 2024 10:32:52 -0500 Subject: [PATCH 35/41] simplify types and remove need for AppBundle by detecting language earlier in aggregate processes --- shinylive/_main.py | 6 ++-- shinylive/_url.py | 89 ++++++++++++++++++++++------------------------ 2 files changed, 47 insertions(+), 48 deletions(-) diff --git a/shinylive/_main.py b/shinylive/_main.py index b457699..2e9f24b 100644 --- a/shinylive/_main.py +++ b/shinylive/_main.py @@ -13,6 +13,7 @@ create_shinylive_bundle_text, create_shinylive_url, decode_shinylive_url, + detect_app_language, ) from ._utils import print_as_json @@ -556,7 +557,7 @@ def encode( f"Invalid language '{language}', must be one of 'py', 'python', 'r', 'R'." ) else: - lang = None + lang = detect_app_language(app_in) if "\n" in app_in: bundle = create_shinylive_bundle_text(app_in, files, lang) @@ -564,12 +565,13 @@ def encode( bundle = create_shinylive_bundle_file(app_in, files, lang) if json: - print_as_json(bundle["files"]) + print_as_json(bundle) if not view: return url = create_shinylive_url( bundle, + lang, mode=mode, header=not no_header, ) diff --git a/shinylive/_url.py b/shinylive/_url.py index 924f369..8c8fc25 100644 --- a/shinylive/_url.py +++ b/shinylive/_url.py @@ -23,11 +23,6 @@ class FileContentJson(TypedDict): type: NotRequired[Literal["text", "binary"]] -class AppBundle(TypedDict): - language: Literal["py", "r"] - files: list[FileContentJson] - - def encode_shinylive_url( app: str | Path, files: Optional[str | Path | Sequence[str | Path]] = None, @@ -35,28 +30,61 @@ def encode_shinylive_url( 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, language) + bundle = create_shinylive_bundle_text(app, files, lang) else: - bundle = create_shinylive_bundle_file(app, files, language) + bundle = create_shinylive_bundle_file(app, files, lang) - return create_shinylive_url(bundle, mode=mode, header=header) + return create_shinylive_url(bundle, lang, mode=mode, header=header) def create_shinylive_url( - bundle: AppBundle, + bundle: list[FileContentJson], + language: Literal["py", "r"], mode: Literal["editor", "app"] = "editor", header: bool = True, ) -> str: - file_lz = lzstring_file_bundle(bundle["files"]) + 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}/{bundle['language']}/{mode}/#{h}code={file_lz}" + return f"{base}/{language}/{mode}/#{h}code={file_lz}" def create_shinylive_bundle_text( @@ -64,7 +92,7 @@ def create_shinylive_bundle_text( files: Optional[str | Path | Sequence[str | Path]] = None, language: Optional[Literal["py", "r"]] = None, root_dir: str | Path = ".", -) -> AppBundle: +) -> list[FileContentJson]: if language is None: language = detect_app_language(app) elif language not in ["py", "r"]: @@ -77,42 +105,14 @@ def create_shinylive_bundle_text( "content": app, } - return { - "language": language, - "files": add_supporting_files_to_bundle(app_fc, files, root_dir), - } + 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, -) -> AppBundle: - """ - 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 or directory paths 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. - """ - +) -> list[FileContentJson]: if language is None: language = detect_app_language(app) elif language not in ["py", "r"]: @@ -128,10 +128,7 @@ def create_shinylive_bundle_file( if app_fc["name"] not in ["ui.R", "server.R"]: app_fc["name"] = f"app.{'py' if language == 'py' else 'R'}" - return { - "language": language, - "files": add_supporting_files_to_bundle(app_fc, files, root_dir, app_path), - } + return add_supporting_files_to_bundle(app_fc, files, root_dir, app_path) def add_supporting_files_to_bundle( From 6674c6bc17d081fb2f5754920cfdfd542a3d3b55 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Mon, 8 Jan 2024 11:23:22 -0500 Subject: [PATCH 36/41] move package version into a subpackage --- setup.cfg | 2 +- shinylive/__init__.py | 4 +-- shinylive/_assets.py | 2 +- shinylive/_deps.py | 2 +- shinylive/_main.py | 29 ++++++++++--------- .../{_version.py => version/__init__.py} | 0 6 files changed, 20 insertions(+), 19 deletions(-) rename shinylive/{_version.py => version/__init__.py} (100%) diff --git a/setup.cfg b/setup.cfg index 41fe59f..10066fd 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 diff --git a/shinylive/__init__.py b/shinylive/__init__.py index bd9e03b..f839578 100644 --- a/shinylive/__init__.py +++ b/shinylive/__init__.py @@ -1,8 +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/_assets.py b/shinylive/_assets.py index 88ab81c..f477992 100644 --- a/shinylive/_assets.py +++ b/shinylive/_assets.py @@ -9,7 +9,7 @@ from typing import Optional from ._utils import tar_safe_extractall -from ._version import SHINYLIVE_ASSETS_VERSION +from .version import SHINYLIVE_ASSETS_VERSION def download_shinylive( diff --git a/shinylive/_deps.py b/shinylive/_deps.py index 642d623..4c29cf0 100644 --- a/shinylive/_deps.py +++ b/shinylive/_deps.py @@ -20,7 +20,7 @@ from ._app_json import FileContentJson from ._assets import ensure_shinylive_assets, repodata_json_file, shinylive_assets_dir -from ._version import SHINYLIVE_ASSETS_VERSION +from .version import SHINYLIVE_ASSETS_VERSION # Files in Pyodide that should always be included. BASE_PYODIDE_FILES = { diff --git a/shinylive/_main.py b/shinylive/_main.py index 2e9f24b..4af8012 100644 --- a/shinylive/_main.py +++ b/shinylive/_main.py @@ -7,7 +7,7 @@ import click -from . import _assets, _deps, _export, _version +from . import _assets, _deps, _export from ._url import ( create_shinylive_bundle_file, create_shinylive_bundle_text, @@ -16,6 +16,7 @@ detect_app_language, ) 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. @@ -36,8 +37,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: @@ -82,7 +83,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: ... @@ -193,7 +194,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, ) @@ -215,7 +216,7 @@ 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) @@ -242,7 +243,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, ) @@ -278,7 +279,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, ) @@ -300,7 +301,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) @@ -321,7 +322,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, ) @@ -345,7 +346,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) @@ -393,8 +394,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(), }, @@ -467,7 +468,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 diff --git a/shinylive/_version.py b/shinylive/version/__init__.py similarity index 100% rename from shinylive/_version.py rename to shinylive/version/__init__.py From a9199b7f74d0173594628824285a958db8f7f2ea Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Mon, 8 Jan 2024 13:14:34 -0500 Subject: [PATCH 37/41] wrap decode outputs in helper functions, too --- shinylive/_main.py | 24 ++++-------------------- shinylive/_url.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 20 deletions(-) diff --git a/shinylive/_main.py b/shinylive/_main.py index 4af8012..b7d0257 100644 --- a/shinylive/_main.py +++ b/shinylive/_main.py @@ -11,9 +11,11 @@ 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 @@ -627,27 +629,9 @@ def decode(url: str, dir: Optional[str] = None, json: bool = False) -> None: return if dir is not None: - out_dir = Path(dir) - 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") - ) + write_files_from_shinylive_io(bundle, dir) 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")) + print(create_shinylive_chunk_contents(bundle)) # ############################################################################# diff --git a/shinylive/_url.py b/shinylive/_url.py index 8c8fc25..849994c 100644 --- a/shinylive/_url.py +++ b/shinylive/_url.py @@ -259,6 +259,38 @@ def decode_shinylive_url(url: str) -> list[FileContentJson]: 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) From b6bf76f4864492bcc1951e930d2c78ceca500cc8 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Fri, 12 Jan 2024 16:46:23 -0500 Subject: [PATCH 38/41] rename version to _version --- setup.cfg | 2 +- shinylive/__init__.py | 2 +- shinylive/_deps.py | 2 +- shinylive/_main.py | 6 +++--- shinylive/{version => _version}/__init__.py | 0 5 files changed, 6 insertions(+), 6 deletions(-) rename shinylive/{version => _version}/__init__.py (100%) diff --git a/setup.cfg b/setup.cfg index 10066fd..d9b8a25 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = shinylive -version = attr: shinylive.version.SHINYLIVE_PACKAGE_VERSION +version = attr: shinylive._version.SHINYLIVE_PACKAGE_VERSION author = Winston Chang author_email = winston@posit.co url = https://github.com/posit-dev/py-shinylive diff --git a/shinylive/__init__.py b/shinylive/__init__.py index f839578..96c2fea 100644 --- a/shinylive/__init__.py +++ b/shinylive/__init__.py @@ -1,7 +1,7 @@ """A package for packaging Shiny applications that run on Python in the browser.""" from ._url import decode_shinylive_url, encode_shinylive_url -from .version import SHINYLIVE_PACKAGE_VERSION +from ._version import SHINYLIVE_PACKAGE_VERSION __version__ = SHINYLIVE_PACKAGE_VERSION diff --git a/shinylive/_deps.py b/shinylive/_deps.py index 4c29cf0..642d623 100644 --- a/shinylive/_deps.py +++ b/shinylive/_deps.py @@ -20,7 +20,7 @@ from ._app_json import FileContentJson from ._assets import ensure_shinylive_assets, repodata_json_file, shinylive_assets_dir -from .version import SHINYLIVE_ASSETS_VERSION +from ._version import SHINYLIVE_ASSETS_VERSION # Files in Pyodide that should always be included. BASE_PYODIDE_FILES = { diff --git a/shinylive/_main.py b/shinylive/_main.py index b7d0257..5f1ba7d 100644 --- a/shinylive/_main.py +++ b/shinylive/_main.py @@ -18,7 +18,7 @@ write_files_from_shinylive_io, ) from ._utils import print_as_json -from .version import SHINYLIVE_ASSETS_VERSION, SHINYLIVE_PACKAGE_VERSION +from ._version import SHINYLIVE_ASSETS_VERSION, SHINYLIVE_PACKAGE_VERSION # Make sure commands are listed in the order they are added in the code. @@ -222,7 +222,7 @@ def download( _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( @@ -357,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) # ############################################################################# diff --git a/shinylive/version/__init__.py b/shinylive/_version/__init__.py similarity index 100% rename from shinylive/version/__init__.py rename to shinylive/_version/__init__.py From c80e99c36e9dd09c134698c597814e8e220794a8 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Fri, 12 Jan 2024 16:52:44 -0500 Subject: [PATCH 39/41] docs: describe feature in changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) 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`. From 5a731467e10728fc4a451ba9999b0fe86584bf5e Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Fri, 12 Jan 2024 16:53:30 -0500 Subject: [PATCH 40/41] fix one more _version import --- shinylive/_assets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shinylive/_assets.py b/shinylive/_assets.py index f477992..88ab81c 100644 --- a/shinylive/_assets.py +++ b/shinylive/_assets.py @@ -9,7 +9,7 @@ from typing import Optional from ._utils import tar_safe_extractall -from .version import SHINYLIVE_ASSETS_VERSION +from ._version import SHINYLIVE_ASSETS_VERSION def download_shinylive( From bc0fad767a75bafd64a274da135c00a1b960a954 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Fri, 12 Jan 2024 17:02:55 -0500 Subject: [PATCH 41/41] bump package version to 0.1.3.9000 --- shinylive/_version/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shinylive/_version/__init__.py b/shinylive/_version/__init__.py index 7c0ce88..8a0e4a7 100644 --- a/shinylive/_version/__init__.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"