From f8298026aee298fadac439260c822f943a67cb83 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Mon, 22 Jan 2024 11:51:12 -0500 Subject: [PATCH 01/30] Refactor `url_{encode,decode}` to return a ShinyliveIoApp object --- shinylive/__init__.py | 4 +- shinylive/_main.py | 41 ++--- shinylive/_url.py | 418 ++++++++++++++++++++++++++---------------- 3 files changed, 273 insertions(+), 190 deletions(-) diff --git a/shinylive/__init__.py b/shinylive/__init__.py index 96c2fea..4527fb5 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 ._url import decode_shinylive_url, encode_shinylive_url +from ._url import url_decode, url_encode from ._version import SHINYLIVE_PACKAGE_VERSION __version__ = SHINYLIVE_PACKAGE_VERSION -__all__ = ("decode_shinylive_url", "encode_shinylive_url") +__all__ = ("url_decode", "url_encode") diff --git a/shinylive/_main.py b/shinylive/_main.py index 5f1ba7d..e63f0ed 100644 --- a/shinylive/_main.py +++ b/shinylive/_main.py @@ -8,15 +8,7 @@ import click from . import _assets, _deps, _export -from ._url import ( - create_shinylive_bundle_file, - create_shinylive_bundle_text, - create_shinylive_chunk_contents, - create_shinylive_url, - decode_shinylive_url, - detect_app_language, - write_files_from_shinylive_io, -) +from ._url import detect_app_language, url_decode, url_encode from ._utils import print_as_json from ._version import SHINYLIVE_ASSETS_VERSION, SHINYLIVE_PACKAGE_VERSION @@ -562,30 +554,23 @@ def encode( else: lang = detect_app_language(app_in) - if "\n" in app_in: - bundle = create_shinylive_bundle_text(app_in, files, lang) - else: - bundle = create_shinylive_bundle_file(app_in, files, lang) + sl_app = url_encode( + app_in, files=files, language=lang, mode=mode, header=not no_header + ) if json: - print_as_json(bundle) + print(sl_app.json(indent=None)) if not view: return - url = create_shinylive_url( - bundle, - lang, - mode=mode, - header=not no_header, - ) + sl_app.mode = mode + sl_app.header = not no_header if not json: - print(url) + print(sl_app.url()) if view: - import webbrowser - - webbrowser.open(url) + sl_app.view() @url.command( @@ -622,16 +607,16 @@ def decode(url: str, dir: Optional[str] = None, json: bool = False) -> None: url_in = sys.stdin.read() else: url_in = url - bundle = decode_shinylive_url(str(url_in)) + sl_app = url_decode(url_in) if json: - print_as_json(bundle) + print(sl_app.json(indent=None)) return if dir is not None: - write_files_from_shinylive_io(bundle, dir) + sl_app.write_files(dir) else: - print(create_shinylive_chunk_contents(bundle)) + print(sl_app.chunk_contents()) # ############################################################################# diff --git a/shinylive/_url.py b/shinylive/_url.py index 849994c..d4d0782 100644 --- a/shinylive/_url.py +++ b/shinylive/_url.py @@ -6,7 +6,7 @@ import re import sys from pathlib import Path -from typing import Literal, Optional, Sequence, cast +from typing import Any, 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. @@ -23,13 +23,231 @@ class FileContentJson(TypedDict): type: NotRequired[Literal["text", "binary"]] -def encode_shinylive_url( +SHINYLIVE_CODE_TEMPLATE = """ +```{{shinylive-{language}}} +#| standalone: true +#| components: [{components}] +#| layout: {layout} +#| viewerHeight: {viewerHeight} + +{contents} +``` +""" + + +class ShinyliveIoApp: + def __init__( + self, + bundle: list[FileContentJson], + language: Optional[Literal["py", "r"]], + ): + self._bundle = bundle + if language is None: + self._language = detect_app_language(bundle[0]["content"]) + else: + if language not in ["py", "r"]: + raise ValueError( + f"Invalid language '{language}', must be either 'py' or 'r'." + ) + self._language = language + + self._mode: Literal["editor", "app"] = "editor" + self._header: bool = True + + @property + def mode(self) -> Literal["editor", "app"]: + return self._mode + + @mode.setter + def mode(self, value: Literal["editor", "app"]): + if value not in ["editor", "app"]: + raise ValueError("Invalid mode, must be either 'editor' or 'app'.") + self._mode = value + + @property + def header(self) -> bool: + return self._header + + @header.setter + def header(self, value: bool): + if not isinstance(value, bool): + raise ValueError("Invalid header value, must be a boolean.") + self._header = value + + def __str__(self) -> str: + return self.url() + + def url( + self, + mode: Optional[Literal["editor", "app"]] = None, + header: Optional[bool] = None, + ) -> str: + mode = mode or self.mode + header = header if header is not None else self.header + + if mode not in ["editor", "app"]: + raise ValueError( + f"Invalid mode '{mode}', must be either 'editor' or 'app'." + ) + + file_lz = lzstring_file_bundle(self._bundle) + + base = "https://shinylive.io" + h = "h=0&" if not header and mode == "app" else "" + + return f"{base}/{self._language}/{mode}/#{h}code={file_lz}" + + def view(self): + import webbrowser + + webbrowser.open(self.url()) + + def chunk_contents(self) -> str: + lines: list[str] = [] + for file in self._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 chunk( + self, + components: Sequence[Literal["editor", "viewer"]] = ("editor", "viewer"), + layout: Literal["horizontal", "vertical"] = "horizontal", + viewerHeight: int = 500, + ) -> str: + if layout not in ["horizontal", "vertical"]: + raise ValueError( + f"Invalid layout '{layout}', must be either 'horizontal' or 'vertical'." + ) + + if not isinstance(components, Sequence) or not all( + component in ["editor", "viewer"] for component in components + ): + raise ValueError( + f"Invalid components '{components}', must be a list or tuple of 'editor' or 'viewer'." + ) + + return SHINYLIVE_CODE_TEMPLATE.format( + language=self._language, + components=", ".join(components), + layout=layout, + viewerHeight=viewerHeight, + contents=self.chunk_contents(), + ) + + def json(self, **kwargs: Any) -> str: + return json.dumps(self._bundle, **kwargs) + + def write_files(self, dest: str | Path) -> Path: + out_dir = Path(dest) + out_dir.mkdir(parents=True, exist_ok=True) + for file in self._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 + + +class ShinyliveIoAppLocal(ShinyliveIoApp): + def __init__( + self, + app: str | Path, + files: Optional[str | Path | Sequence[str | Path]] = None, + language: Optional[Literal["py", "r"]] = None, + ): + 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'." + ) + + self._bundle: list[FileContentJson] = [] + + self._app_path = Path(app) + self._root_dir = self._app_path.parent + app_fc = read_file(app, self._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'}" + + self._bundle.append(app_fc) + self.add_files(files) + + def add_files( + self, + files: Optional[str | Path | Sequence[str | Path]] = None, + ) -> None: + if files is None: + return + + if isinstance(files, (str, Path)): + files = [files] + + for file in files or []: + if Path(file) == self._app_path: + continue + self.add_file(file) + + def add_file_contents(self, file_contents: dict[str, str]) -> None: + for file in file_contents: + self._bundle.append( + { + "name": file, + "content": file_contents[file], + } + ) + + def add_file(self, file: str | Path) -> None: + self._bundle.append(read_file(file, self._root_dir)) + + +class ShinyliveIoAppText(ShinyliveIoAppLocal): + def __init__( + self, + app: str, + files: Optional[str | Path | Sequence[str | Path]] = None, + language: Optional[Literal["py", "r"]] = None, + ): + 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'." + ) + + default_app_file = f"app.{'py' if language == 'py' else 'R'}" + + self._bundle: list[FileContentJson] = [] + self._language = language + self._root_dir: Path = Path(".") + self._app_path: Path = Path(".") + self.add_file_contents({default_app_file: app}) + self.add_files(files) + + +def url_encode( 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: +) -> ShinyliveIoApp: """ Generate a URL for a [ShinyLive application](https://shinylive.io). @@ -52,7 +270,7 @@ def encode_shinylive_url( Returns ------- - The generated URL for the ShinyLive application. + A ShinyliveIoApp object. Use the `.url()` method to retrieve the Shinylive URL. """ if language is not None and language not in ["py", "r"]: @@ -61,144 +279,21 @@ def encode_shinylive_url( lang = language if language is not None else detect_app_language(app) if isinstance(app, str) and "\n" in app: - bundle = create_shinylive_bundle_text(app, files, lang) + sl_app = ShinyliveIoAppText(app, files, lang) else: - bundle = create_shinylive_bundle_file(app, files, lang) - - return create_shinylive_url(bundle, lang, mode=mode, header=header) - - -def create_shinylive_url( - bundle: list[FileContentJson], - language: Literal["py", "r"], - mode: Literal["editor", "app"] = "editor", - header: bool = True, -) -> str: - if language not in ["py", "r"]: - raise ValueError(f"Invalid language '{language}', must be either 'py' or 'r'.") - if mode not in ["editor", "app"]: - raise ValueError(f"Invalid mode '{mode}', must be either 'editor' or 'app'.") - - file_lz = lzstring_file_bundle(bundle) - - base = "https://shinylive.io" - h = "h=0&" if not header and mode == "app" else "" - - return f"{base}/{language}/{mode}/#{h}code={file_lz}" - - -def create_shinylive_bundle_text( - app: str, - files: Optional[str | Path | Sequence[str | Path]] = None, - language: Optional[Literal["py", "r"]] = None, - root_dir: str | Path = ".", -) -> list[FileContentJson]: - if language is None: - language = detect_app_language(app) - elif language not in ["py", "r"]: - raise ValueError( - f"Language '{language}' is not supported. Please specify one of 'py' or 'r'." - ) - - app_fc: FileContentJson = { - "name": f"app.{'py' if language == 'py' else 'R'}", - "content": app, - } - - return add_supporting_files_to_bundle(app_fc, files, root_dir) - - -def create_shinylive_bundle_file( - app: str | Path, - files: Optional[str | Path | Sequence[str | Path]] = None, - language: Optional[Literal["py", "r"]] = None, -) -> list[FileContentJson]: - if language is None: - language = detect_app_language(app) - elif language not in ["py", "r"]: - raise ValueError( - f"Language '{language}' is not supported. Please specify one of 'py' or 'r'." - ) - - app_path = Path(app) - root_dir = app_path.parent - app_fc = read_file(app, root_dir) - - # if the app is not named either `ui.R` or `server.R`, then make it app.py or app.R - if app_fc["name"] not in ["ui.R", "server.R"]: - app_fc["name"] = f"app.{'py' if language == 'py' else 'R'}" - - return add_supporting_files_to_bundle(app_fc, files, root_dir, app_path) - - -def add_supporting_files_to_bundle( - app: FileContentJson, - files: Optional[str | Path | Sequence[str | Path]] = None, - root_dir: str | Path = ".", - app_path: str | Path = "", -) -> list[FileContentJson]: - app_path = Path(app_path) + sl_app = ShinyliveIoAppLocal(app, files, lang) - file_bundle = [app] + sl_app.mode = mode + sl_app.header = header - if isinstance(files, (str, Path)): - files = [files] + return sl_app - if files is not None: - file_list: list[str | Path] = [] - for file in files: - if Path(file).is_dir(): - file_list.extend(listdir_recursive(file)) - else: - file_list.append(file) - - file_bundle = file_bundle + [ - read_file(file, root_dir) for file in file_list if Path(file) != app_path - ] - - return file_bundle - - -def detect_app_language(app: str | Path) -> Literal["py", "r"]: - err_not_detected = """ - Could not automatically detect the language of the app. Please specify `language`.""" - - if isinstance(app, str) and "\n" in app: - if re.search(r"^(import|from) shiny", app, re.MULTILINE): - return "py" - elif re.search(r"^library\(shiny\)", app, re.MULTILINE): - return "r" - else: - raise ValueError(err_not_detected) - - app = Path(app) - - if app.suffix.lower() == ".py": - return "py" - elif app.suffix.lower() == ".r": - return "r" - else: - raise ValueError(err_not_detected) - - -def listdir_recursive(dir: str | Path) -> list[str]: - dir = Path(dir) - all_files: list[str] = [] - - for root, dirs, files in os.walk(dir): - for file in files: - all_files.append(os.path.join(root, file)) - for dir in dirs: - all_files.extend(listdir_recursive(dir)) - - return all_files - - -def decode_shinylive_url(url: str) -> list[FileContentJson]: +def url_decode(url: str) -> ShinyliveIoApp: from lzstring import LZString # type: ignore[reportMissingTypeStubs] url = url.strip() + language = "r" if "shinylive.io/r/" in url else "py" try: bundle_json = cast( @@ -256,39 +351,42 @@ def decode_shinylive_url(url: str) -> list[FileContentJson]: ) ret.append(fc) - return ret + return ShinyliveIoApp(ret, language=language) -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("") +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`.""" - return "\n".join(lines) + if isinstance(app, str) and "\n" in app: + if re.search(r"^(import|from) shiny", app, re.MULTILINE): + return "py" + elif re.search(r"^library\(shiny\)", app, re.MULTILINE): + return "r" + else: + raise ValueError(err_not_detected) + app = Path(app) -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 + if app.suffix.lower() == ".py": + return "py" + elif app.suffix.lower() == ".r": + return "r" + else: + raise ValueError(err_not_detected) - 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 +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 # Copied from https://github.com/posit-dev/py-shiny/blob/main/docs/_renderer.py#L231 From babd4fe0e783c3cb444fc31076576fb2003a6268 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Mon, 22 Jan 2024 12:02:53 -0500 Subject: [PATCH 02/30] Add +/- methods for ShinyliveIoAppLocal --- shinylive/_url.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/shinylive/_url.py b/shinylive/_url.py index d4d0782..3c9b503 100644 --- a/shinylive/_url.py +++ b/shinylive/_url.py @@ -216,6 +216,28 @@ def add_file_contents(self, file_contents: dict[str, str]) -> None: def add_file(self, file: str | Path) -> None: self._bundle.append(read_file(file, self._root_dir)) + def __add__(self, other: str | Path) -> None: + self.add_file(other) + + def __sub__(self, other: str | Path) -> None: + file_names = [file["name"] for file in self._bundle] + + if other in file_names: + # find the index of the file to remove + index = file_names.index(other) + self._bundle.pop(index) + return + + root_dir = self._root_dir.absolute() + + other_path = str(Path(other).absolute().relative_to(root_dir)) + if other_path in file_names: + index = file_names.index(other_path) + self._bundle.pop(index) + return + + raise ValueError(f"File '{other}' not found in app bundle.") + class ShinyliveIoAppText(ShinyliveIoAppLocal): def __init__( From 88a086cab18f12d3d07f74e49ea54e95cbd3039a Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Mon, 22 Jan 2024 12:17:37 -0500 Subject: [PATCH 03/30] docs: document all the things --- shinylive/_url.py | 183 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 178 insertions(+), 5 deletions(-) diff --git a/shinylive/_url.py b/shinylive/_url.py index 3c9b503..5a6de5c 100644 --- a/shinylive/_url.py +++ b/shinylive/_url.py @@ -36,6 +36,10 @@ class FileContentJson(TypedDict): class ShinyliveIoApp: + """ + Create an instance of a Shiny App for use with shinylive.io. + """ + def __init__( self, bundle: list[FileContentJson], @@ -56,20 +60,63 @@ def __init__( @property def mode(self) -> Literal["editor", "app"]: + """ + Is the shinylive.io app in editor or app mode? + + Returns + ------- + Literal["editor", "app"] + The current mode of the ShinyliveIoApp. + """ return self._mode @mode.setter - def mode(self, value: Literal["editor", "app"]): + def mode(self, value: Literal["editor", "app"]) -> None: + """ + Set the mode of the shinylive.io app. + + Parameters + ---------- + value : Literal["editor", "app"] + The new mode to set. + + Raises + ------ + ValueError + If the new mode is not 'editor' or 'app'. + """ if value not in ["editor", "app"]: raise ValueError("Invalid mode, must be either 'editor' or 'app'.") self._mode = value @property def header(self) -> bool: + """ + Should the Shiny header be included in the app preview? This property is only + used if the app is in 'app' mode. + + Returns + ------- + bool + `True` if the header should be included, `False` otherwise. + """ return self._header @header.setter - def header(self, value: bool): + def header(self, value: bool) -> None: + """ + Toggle whether or not to include the Shiny header in the app preview. + + Parameters + ---------- + value : bool + Whether the header should be included or not. + + Raises + ------ + ValueError + If the new header value is not boolean. + """ if not isinstance(value, bool): raise ValueError("Invalid header value, must be a boolean.") self._header = value @@ -82,6 +129,24 @@ def url( mode: Optional[Literal["editor", "app"]] = None, header: Optional[bool] = None, ) -> str: + """ + Get the URL of the ShinyLive application. + + Parameters + ---------- + mode + The mode of the application, either "editor" or "app". Defaults to the + current mode. + + header + Whether to include a header bar in the UI. This is used only if ``mode`` is + "app". Defaults to the current header value. + + Returns + ------- + str + The URL of the ShinyLive application. + """ mode = mode or self.mode header = header if header is not None else self.header @@ -97,12 +162,24 @@ def url( return f"{base}/{self._language}/{mode}/#{h}code={file_lz}" - def view(self): + def view(self) -> None: + """ + Open the ShinyLive application in a browser. + """ import webbrowser webbrowser.open(self.url()) def chunk_contents(self) -> str: + """ + Create the contents of a shinylive chunk based on the files in the app. This + output does not include the shinylive chunk header or options. + + Returns + ------- + str + The contents of the shinylive chunk. + """ lines: list[str] = [] for file in self._bundle: lines.append(f"## file: {file['name']}") @@ -121,6 +198,26 @@ def chunk( layout: Literal["horizontal", "vertical"] = "horizontal", viewerHeight: int = 500, ) -> str: + """ + Create a shinylive chunk based on the files in the app for use in a Quarto + web document. + + Parameters + ---------- + components + Which components to include in the chunk. Defaults to both "editor" and + "viewer". + layout + The layout of the components, either "horizontal" or "vertical". Defaults + to "horizontal". + viewerHeight + The height of the viewer component in pixels. Defaults to 500. + + Returns + ------- + str + The full shinylive chunk, including the chunk header and options. + """ if layout not in ["horizontal", "vertical"]: raise ValueError( f"Invalid layout '{layout}', must be either 'horizontal' or 'vertical'." @@ -142,9 +239,35 @@ def chunk( ) def json(self, **kwargs: Any) -> str: + """ + Get the JSON representation of the ShinyLive application. + + Parameters + ---------- + kwargs + Keyword arguments passed to `json.dumps`. + + Returns + ------- + str + The JSON representation of the ShinyLive application. + """ return json.dumps(self._bundle, **kwargs) def write_files(self, dest: str | Path) -> Path: + """ + Write the files in the ShinyLive application to a directory. + + Parameters + ---------- + dest + The directory to write the files to. + + Returns + ------- + Path + The directory that the files were written to. + """ out_dir = Path(dest) out_dir.mkdir(parents=True, exist_ok=True) for file in self._bundle: @@ -163,6 +286,10 @@ def write_files(self, dest: str | Path) -> Path: class ShinyliveIoAppLocal(ShinyliveIoApp): + """ + Create an instance of a Shiny App from local files for use with shinylive.io. + """ + def __init__( self, app: str | Path, @@ -193,6 +320,15 @@ def add_files( self, files: Optional[str | Path | Sequence[str | Path]] = None, ) -> None: + """ + Add files to the ShinyLive application. + + Parameters + ---------- + 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 files is None: return @@ -205,6 +341,14 @@ def add_files( self.add_file(file) def add_file_contents(self, file_contents: dict[str, str]) -> None: + """ + Directly adds a text file to the Shinylive app. + + Parameters + ---------- + file_contents + A dictionary of file names and file contents. + """ for file in file_contents: self._bundle.append( { @@ -214,6 +358,16 @@ def add_file_contents(self, file_contents: dict[str, str]) -> None: ) def add_file(self, file: str | Path) -> None: + """ + Add a file to the ShinyLive application. + + Parameters + ---------- + file + File or directory path to include in the application. On shinylive, this + file will be stored relative to the main `app` file. All files should be + contained in the same directory as or a subdirectory of the main `app` file. + """ self._bundle.append(read_file(file, self._root_dir)) def __add__(self, other: str | Path) -> None: @@ -240,6 +394,11 @@ def __sub__(self, other: str | Path) -> None: class ShinyliveIoAppText(ShinyliveIoAppLocal): + """ + Create an instance of a Shiny App from a string containig the `app.py` or `app.R` + file contents for use with shinylive.io. + """ + def __init__( self, app: str, @@ -286,9 +445,11 @@ def url_encode( 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. + 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. + Whether to include a header bar in the UI. This is used only if ``mode`` is + "app". Defaults to True. Returns ------- @@ -312,6 +473,18 @@ def url_encode( def url_decode(url: str) -> ShinyliveIoApp: + """ + Decode a Shinylive URL into a ShinyliveIoApp object. + + Parameters + ---------- + url + The Shinylive URL to decode. + + Returns + ------- + A ShinyliveIoApp object. + """ from lzstring import LZString # type: ignore[reportMissingTypeStubs] url = url.strip() From 4ae8018f55937a8517c4a37e776c7c3913b99f16 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Mon, 22 Jan 2024 12:21:07 -0500 Subject: [PATCH 04/30] feat(url_decode): Track mode/header in object --- shinylive/_url.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/shinylive/_url.py b/shinylive/_url.py index 5a6de5c..ed34378 100644 --- a/shinylive/_url.py +++ b/shinylive/_url.py @@ -546,7 +546,12 @@ def url_decode(url: str) -> ShinyliveIoApp: ) ret.append(fc) - return ShinyliveIoApp(ret, language=language) + app = ShinyliveIoApp(ret, language=language) + + app.mode = "app" if f"{language}/app/" in url else "editor" + app.header = False if "h=0" in url else True + + return app def detect_app_language(app: str | Path) -> Literal["py", "r"]: From 279a93426cfdbe0c916a69354bc878d722c8bf7e Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Mon, 22 Jan 2024 12:43:47 -0500 Subject: [PATCH 05/30] tests: Add some very basic tests --- tests/test_url.py | 106 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 tests/test_url.py diff --git a/tests/test_url.py b/tests/test_url.py new file mode 100644 index 0000000..d94b61a --- /dev/null +++ b/tests/test_url.py @@ -0,0 +1,106 @@ +"""Tests for shinylive.io URL encoding and decoding.""" + +from shinylive._url import * + +LINKS = { + "py": { + "editor": "https://shinylive.io/py/editor/#code=NobwRAdghgtgpmAXGKAHVA6VBPMAaMAYwHsIAXOcpMAMwCdiYACAZwAsBLCbJjmVYnTJMAgujxM6lACZw6EgK4cAOhDABfALpA5g", + "app": "https://shinylive.io/py/app/#code=NobwRAdghgtgpmAXGKAHVA6VBPMAaMAYwHsIAXOcpMAMwCdiYACAZwAsBLCbJjmVYnTJMAgujxM6lACZw6EgK4cAOhDABfALpA", + "app_no_header": "https://shinylive.io/py/app/#h=0&code=NobwRAdghgtgpmAXGKAHVA6VBPMAaMAYwHsIAXOcpMAMwCdiYACAZwAsBLCbJjmVYnTJMAgujxM6lACZw6EgK4cAOhDABfALpA", + }, + "r": { + "editor": "https://shinylive.io/r/editor/#code=NobwRAdghgtgpmAXGKAHVA6ASmANGAYwHsIAXOMpMAGwEsAjAJykYE8AKAZwAtaJWAlAB0IYAL4BdIA", + "app": "https://shinylive.io/r/app/#code=NobwRAdghgtgpmAXGKAHVA6ASmANGAYwHsIAXOMpMAGwEsAjAJykYE8AKAZwAtaJWAlAB0IYAL4BdIA", + "app_no_header": "https://shinylive.io/r/app/#h=0&code=NobwRAdghgtgpmAXGKAHVA6ASmANGAYwHsIAXOMpMAGwEsAjAJykYE8AKAZwAtaJWAlAB0IYAL4BdIA", + }, +} + + +def test_decode_py_editor(): + app = url_decode(LINKS["py"]["editor"]) + assert app._language == "py" + assert app._mode == "editor" + assert app._bundle[0]["name"] == "app.py" + assert "from shiny import" in app._bundle[0]["content"] + assert "type" not in app._bundle[0] + + +def test_decode_py_app(): + app = url_decode(LINKS["py"]["app"]) + assert app._language == "py" + assert app._mode == "app" + assert app._header + assert app._bundle[0]["name"] == "app.py" + assert "from shiny import" in app._bundle[0]["content"] + assert "type" not in app._bundle[0] + + +def test_decode_py_app_no_header(): + app = url_decode(LINKS["py"]["app_no_header"]) + assert app._language == "py" + assert app._mode == "app" + assert not app._header + assert app._bundle[0]["name"] == "app.py" + assert "from shiny import" in app._bundle[0]["content"] + assert "type" not in app._bundle[0] + + +def test_decode_r_editor(): + app = url_decode(LINKS["r"]["editor"]) + assert app._language == "r" + assert app._mode == "editor" + assert app._bundle[0]["name"] == "app.R" + assert "library(shiny)" in app._bundle[0]["content"] + assert "type" not in app._bundle[0] + + +def test_decode_r_app(): + app = url_decode(LINKS["r"]["app"]) + assert app._language == "r" + assert app._mode == "app" + assert app._header + assert app._bundle[0]["name"] == "app.R" + assert "library(shiny)" in app._bundle[0]["content"] + assert "type" not in app._bundle[0] + + +def test_decode_r_app_no_header(): + app = url_decode(LINKS["r"]["app_no_header"]) + assert app._language == "r" + assert app._mode == "app" + assert not app._header + assert app._bundle[0]["name"] == "app.R" + assert "library(shiny)" in app._bundle[0]["content"] + assert "type" not in app._bundle[0] + + +def test_encode_py_app_content(): + app_code = "from shiny.express import ui\nui.div()" + app = url_encode(app_code) + + assert app._language == "py" + assert str(app) == app.url() + assert app._bundle == [ + { + "name": "app.py", + "content": app_code, + } + ] + assert "## file: app.py" in app.chunk_contents() + assert app_code in app.chunk_contents() + + +def test_encode_r_app_content(): + app_code = "library(shiny)\n\nshinyApp(pageFluid(), function(...) { })" + app = url_encode(app_code) + + assert app._language == "r" + assert str(app) == app.url() + assert app._bundle == [ + { + "name": "app.R", + "content": app_code, + } + ] + assert "## file: app.R" in app.chunk_contents() + assert app_code in app.chunk_contents() From d7d17fa5ba7a0cdae2554518c2da7b945dd4246f Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Mon, 22 Jan 2024 12:53:32 -0500 Subject: [PATCH 06/30] docs: update news --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 75609f6..74892df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [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) +* 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 `url_encode()` and `url_decode()` functions for programmatic use, returning a `ShinyliveIoApp` instance with helpful methods to get the app URL, save the app locally, or create a shinylive quarto chunk from the app's files. (#20, #23) ## [0.1.3] - 2024-12-19 From d992c1d6d21fbb283764b8d722999dc440f8dae6 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Mon, 22 Jan 2024 13:41:26 -0500 Subject: [PATCH 07/30] fix: Missed setting language --- shinylive/_url.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/shinylive/_url.py b/shinylive/_url.py index ed34378..18c399f 100644 --- a/shinylive/_url.py +++ b/shinylive/_url.py @@ -304,6 +304,7 @@ def __init__( ) self._bundle: list[FileContentJson] = [] + self._language = language self._app_path = Path(app) self._root_dir = self._app_path.parent @@ -311,7 +312,7 @@ def __init__( # 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'}" + app_fc["name"] = f"app.{'py' if self._language == 'py' else 'R'}" self._bundle.append(app_fc) self.add_files(files) From 84819049829d809321f039ce4075da75491033d3 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Mon, 22 Jan 2024 14:22:24 -0500 Subject: [PATCH 08/30] fix: chunk engine is `shinylive-python` not `shinylive-py` --- shinylive/_url.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shinylive/_url.py b/shinylive/_url.py index 18c399f..c2eca8e 100644 --- a/shinylive/_url.py +++ b/shinylive/_url.py @@ -231,7 +231,7 @@ def chunk( ) return SHINYLIVE_CODE_TEMPLATE.format( - language=self._language, + language="python" if self._language == "py" else "r", components=", ".join(components), layout=layout, viewerHeight=viewerHeight, From 789e3950523036cad6ea06326a480b351d9a1dd4 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Mon, 22 Jan 2024 14:23:11 -0500 Subject: [PATCH 09/30] fix: Use snakecase for `viewer_height` --- shinylive/_url.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/shinylive/_url.py b/shinylive/_url.py index c2eca8e..f62da20 100644 --- a/shinylive/_url.py +++ b/shinylive/_url.py @@ -28,7 +28,7 @@ class FileContentJson(TypedDict): #| standalone: true #| components: [{components}] #| layout: {layout} -#| viewerHeight: {viewerHeight} +#| viewerHeight: {viewer_height} {contents} ``` @@ -196,7 +196,7 @@ def chunk( self, components: Sequence[Literal["editor", "viewer"]] = ("editor", "viewer"), layout: Literal["horizontal", "vertical"] = "horizontal", - viewerHeight: int = 500, + viewer_height: int = 500, ) -> str: """ Create a shinylive chunk based on the files in the app for use in a Quarto @@ -210,7 +210,7 @@ def chunk( layout The layout of the components, either "horizontal" or "vertical". Defaults to "horizontal". - viewerHeight + viewer_height The height of the viewer component in pixels. Defaults to 500. Returns @@ -234,7 +234,7 @@ def chunk( language="python" if self._language == "py" else "r", components=", ".join(components), layout=layout, - viewerHeight=viewerHeight, + viewer_height=viewer_height, contents=self.chunk_contents(), ) From 4614ffbc9b71eddabd15fb0f76a515d274c8058a Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Tue, 23 Jan 2024 09:23:42 -0500 Subject: [PATCH 10/30] several improvements * Implement `__add__` and `__sub__` for all ShinyliveIoApp objects * +/- now return copies * `root_dir` can be None, new files are added "flattened" * More documentation --- shinylive/_url.py | 196 ++++++++++++++++++++++++++++++---------------- 1 file changed, 129 insertions(+), 67 deletions(-) diff --git a/shinylive/_url.py b/shinylive/_url.py index f62da20..7bce8c5 100644 --- a/shinylive/_url.py +++ b/shinylive/_url.py @@ -1,6 +1,7 @@ from __future__ import annotations import base64 +import copy import json import os import re @@ -38,8 +39,20 @@ class FileContentJson(TypedDict): class ShinyliveIoApp: """ Create an instance of a Shiny App for use with shinylive.io. + + Parameters + ---------- + bundle + The file bundle of the ShinyLive application. This should be a list of files + as a dictionary of "name", "content" and optionally `"type": "binary"` for + binary file types. (`"type": "text"` is the default and can be omitted.) + language + The language of the application, or None to autodetect the language. Defaults + to None. """ + __slots__ = ("_bundle", "_language", "_mode", "_header", "_app_path", "_root_dir") + def __init__( self, bundle: list[FileContentJson], @@ -57,6 +70,8 @@ def __init__( self._mode: Literal["editor", "app"] = "editor" self._header: bool = True + self._app_path: Optional[Path] = None + self._root_dir: Optional[Path] = None @property def mode(self) -> Literal["editor", "app"]: @@ -98,7 +113,7 @@ def header(self) -> bool: Returns ------- bool - `True` if the header should be included, `False` otherwise. + ``True`` if the header should be included, ``False`` otherwise. """ return self._header @@ -245,7 +260,7 @@ def json(self, **kwargs: Any) -> str: Parameters ---------- kwargs - Keyword arguments passed to `json.dumps`. + Keyword arguments passed to ``json.dumps``. Returns ------- @@ -284,51 +299,23 @@ def write_files(self, dest: str | Path) -> Path: return out_dir - -class ShinyliveIoAppLocal(ShinyliveIoApp): - """ - Create an instance of a Shiny App from local files for use with shinylive.io. - """ - - def __init__( - self, - app: str | Path, - files: Optional[str | Path | Sequence[str | Path]] = None, - language: Optional[Literal["py", "r"]] = None, - ): - 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'." - ) - - self._bundle: list[FileContentJson] = [] - self._language = language - - self._app_path = Path(app) - self._root_dir = self._app_path.parent - app_fc = read_file(app, self._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 self._language == 'py' else 'R'}" - - self._bundle.append(app_fc) - self.add_files(files) - def add_files( self, files: Optional[str | Path | Sequence[str | Path]] = None, ) -> None: """ - Add files to the ShinyLive application. + Add files to the ShinyLive application. For more control over the file name, + use the ``add_file`` method. Parameters ---------- 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. + File(s) or directory path(s) to include in the application. On shinylive, + these files will be stored relative to the main ``app`` file. Use the + ``add_file`` method to add a single file if you need to rename the files. + In app bundles created from local files, added files will be stored relative + to the location of the local ``app`` file. In app bundles created from text, + files paths are flattened to include only the file name. """ if files is None: return @@ -337,7 +324,7 @@ def add_files( files = [files] for file in files or []: - if Path(file) == self._app_path: + if self._app_path is not None and Path(file) == self._app_path: continue self.add_file(file) @@ -358,7 +345,7 @@ def add_file_contents(self, file_contents: dict[str, str]) -> None: } ) - def add_file(self, file: str | Path) -> None: + def add_file(self, file: str | Path, name: Optional[str | Path] = None) -> None: """ Add a file to the ShinyLive application. @@ -366,48 +353,125 @@ def add_file(self, file: str | Path) -> None: ---------- file File or directory path to include in the application. On shinylive, this - file will be stored relative to the main `app` file. All files should be - contained in the same directory as or a subdirectory of the main `app` file. + file will be stored relative to the main ``app`` file. All files should be + contained in the same directory as or a subdirectory of the main ``app`` file. + + name + The name of the file to be used in the app. If not provided, the file name + will be used, using the relative path from the main ``app`` file if the + ``ShinyliveIoApp`` was created from local files. """ - self._bundle.append(read_file(file, self._root_dir)) + file_new = read_file(file, self._root_dir) + if name is not None: + file_new["name"] = str(name) + self._bundle.append(file_new) - def __add__(self, other: str | Path) -> None: - self.add_file(other) + def __add__(self, other: str | Path) -> ShinyliveIoApp: + new: ShinyliveIoApp = copy.deepcopy(self) + new.add_file(other) + return new - def __sub__(self, other: str | Path) -> None: + def __sub__(self, other: str | Path) -> ShinyliveIoApp: file_names = [file["name"] for file in self._bundle] + index = None if other in file_names: # find the index of the file to remove index = file_names.index(other) - self._bundle.pop(index) - return - root_dir = self._root_dir.absolute() + if self._root_dir is not None: + root_dir = self._root_dir.absolute() - other_path = str(Path(other).absolute().relative_to(root_dir)) - if other_path in file_names: - index = file_names.index(other_path) - self._bundle.pop(index) - return + other_path = str(Path(other).absolute().relative_to(root_dir)) + if other_path in file_names: + index = file_names.index(other_path) + + if index is None: + raise ValueError(f"File '{other}' not found in app bundle.") - raise ValueError(f"File '{other}' not found in app bundle.") + new: ShinyliveIoApp = copy.deepcopy(self) + new._bundle.pop(index) + return new + + +class ShinyliveIoAppLocal(ShinyliveIoApp): + """ + Create an instance of a Shiny App from local files for use with 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. + language + The language of the application, or None to autodetect the language. Defaults + to None. + """ + + def __init__( + self, + app: str | Path, + files: Optional[str | Path | Sequence[str | Path]] = None, + language: Optional[Literal["py", "r"]] = None, + ): + 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'." + ) + + self._bundle: list[FileContentJson] = [] + self._language = language + + self._app_path = Path(app) + self._root_dir = self._app_path.parent + app_fc = read_file(app, self._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 self._language == 'py' else 'R'}" + + self._bundle.append(app_fc) + self.add_files(files) class ShinyliveIoAppText(ShinyliveIoAppLocal): """ - Create an instance of a Shiny App from a string containig the `app.py` or `app.R` + Create an instance of a Shiny App from a string containing the `app.py` or `app.R` file contents for use with shinylive.io. + + Parameters + ---------- + app_code + The text contents of the main app file for the ShinyLive application. This file + will be renamed `app.py` or `app.R` for shinylive. + 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. + language + The language of the application, or None to autodetect the language. Defaults + to None. + root_dir + The root directory of the application,used to determine the relative + path of supporting files to the main ``app`` file. Defaults to ``None``, meaning + that additional files are added in a flattened structure. """ def __init__( self, - app: str, + app_code: str, files: Optional[str | Path | Sequence[str | Path]] = None, language: Optional[Literal["py", "r"]] = None, + root_dir: Optional[str | Path] = None, ): if language is None: - language = detect_app_language(app) + language = detect_app_language(app_code) elif language not in ["py", "r"]: raise ValueError( f"Language '{language}' is not supported. Please specify one of 'py' or 'r'." @@ -417,9 +481,9 @@ def __init__( self._bundle: list[FileContentJson] = [] self._language = language - self._root_dir: Path = Path(".") - self._app_path: Path = Path(".") - self.add_file_contents({default_app_file: app}) + if root_dir is not None: + self._root_dir = Path(root_dir) + self.add_file_contents({default_app_file: app_code}) self.add_files(files) @@ -441,8 +505,7 @@ def url_encode( `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. + files will be stored relative to the main `app` file. mode The mode of the application, either "editor" or "app". Defaults to "editor". language @@ -593,9 +656,6 @@ def listdir_recursive(dir: str | Path) -> list[str]: # Copied from https://github.com/posit-dev/py-shiny/blob/main/docs/_renderer.py#L231 def read_file(file: str | Path, root_dir: str | Path | None = None) -> FileContentJson: file = Path(file) - if root_dir is None: - root_dir = Path("/") - root_dir = Path(root_dir) type: Literal["text", "binary"] = "text" @@ -610,8 +670,10 @@ def read_file(file: str | Path, root_dir: str | Path | None = None) -> FileConte file_content = base64.b64encode(file_content_bin).decode("utf-8") type = "binary" + file_name = str(file.relative_to(root_dir)) if root_dir else file.name + return { - "name": str(file.relative_to(root_dir)), + "name": file_name, "content": file_content, "type": type, } From be826713492d34681d4cc4ff623a2aceaa69b10a Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Tue, 23 Jan 2024 09:58:14 -0500 Subject: [PATCH 11/30] feat: Add `add_dir()` method --- shinylive/_url.py | 81 ++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 73 insertions(+), 8 deletions(-) diff --git a/shinylive/_url.py b/shinylive/_url.py index 7bce8c5..506dda9 100644 --- a/shinylive/_url.py +++ b/shinylive/_url.py @@ -328,6 +328,42 @@ def add_files( continue self.add_file(file) + def add_dir( + self, + dir: str | Path, + flatten: bool = False, + overwrite: bool = False, + ) -> None: + """ + Add all files in a directory to the ShinyLive application. + + Parameters + ---------- + dir + The directory to add to the application. + flatten + Whether or not to flatten the directory structure. Defaults to ``False``. + When ``True``, all files are added to the root directory of the application, + otherwise all files are added into a directory with the same name as the + input ``dir``. + overwrite + Whether or not to overwrite an existing file with the same name. Defaults + to ``False``. + """ + dir = Path(dir) + if not dir.is_dir(): + raise ValueError(f"Directory '{dir}' does not exist or is not a directory.") + + for file in listdir_recursive(dir): + if not flatten: + name = os.path.join( + os.path.basename(dir), + str(Path(file).relative_to(dir)), + ) + else: + name = str(Path(file).relative_to(dir)) + self.add_file(file, name, overwrite=overwrite) + def add_file_contents(self, file_contents: dict[str, str]) -> None: """ Directly adds a text file to the Shinylive app. @@ -345,7 +381,12 @@ def add_file_contents(self, file_contents: dict[str, str]) -> None: } ) - def add_file(self, file: str | Path, name: Optional[str | Path] = None) -> None: + def add_file( + self, + file: str | Path, + name: Optional[str | Path] = None, + overwrite: bool = False, + ) -> None: """ Add a file to the ShinyLive application. @@ -355,20 +396,38 @@ def add_file(self, file: str | Path, name: Optional[str | Path] = None) -> None: File or directory path to include in the application. On shinylive, this file will be stored relative to the main ``app`` file. All files should be contained in the same directory as or a subdirectory of the main ``app`` file. - name The name of the file to be used in the app. If not provided, the file name will be used, using the relative path from the main ``app`` file if the ``ShinyliveIoApp`` was created from local files. + overwrite + Whether or not to overwrite an existing file with the same name. Defaults + to ``False``. """ file_new = read_file(file, self._root_dir) if name is not None: file_new["name"] = str(name) + + file_names = [file["name"] for file in self._bundle] + + if any([name == file_new["name"] for name in file_names]): + if overwrite: + index = file_names.index(file_new["name"]) + self._bundle[index] = file_new + else: + raise ValueError( + f"File '{file_new['name']}' already exists in app bundle and `overwrite` was `False`." + ) + self._bundle.append(file_new) def __add__(self, other: str | Path) -> ShinyliveIoApp: + other = Path(other) new: ShinyliveIoApp = copy.deepcopy(self) - new.add_file(other) + if other.is_dir(): + new.add_dir(other) + else: + new.add_file(other) return new def __sub__(self, other: str | Path) -> ShinyliveIoApp: @@ -481,8 +540,7 @@ def __init__( self._bundle: list[FileContentJson] = [] self._language = language - if root_dir is not None: - self._root_dir = Path(root_dir) + self._root_dir = Path(root_dir) if root_dir is not None else None self.add_file_contents({default_app_file: app_code}) self.add_files(files) @@ -641,14 +699,21 @@ def detect_app_language(app: str | Path) -> Literal["py", "r"]: def listdir_recursive(dir: str | Path) -> list[str]: + """ + List files in a directory, recursively. Ignores directories or files that start with + "." or "__". + """ dir = Path(dir) all_files: list[str] = [] for root, dirs, files in os.walk(dir): + # Exclude files and directories that start with `.` or `__` + dirs[:] = [d for d in dirs if not (d.startswith(".") or d.startswith("__"))] + files[:] = [f for f in files if not (f.startswith(".") or f.startswith("__"))] + for file in files: - all_files.append(os.path.join(root, file)) - for dir in dirs: - all_files.extend(listdir_recursive(dir)) + if not (file.startswith(".") or file.startswith("__")): + all_files.append(os.path.join(root, file)) return all_files From bf0c6619b18fdfeb3e686f6c4d75fe95eb1ad068 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Tue, 23 Jan 2024 11:31:11 -0500 Subject: [PATCH 12/30] chore: ShinyLive -> shinylive --- shinylive/_url.py | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/shinylive/_url.py b/shinylive/_url.py index 506dda9..9476cc5 100644 --- a/shinylive/_url.py +++ b/shinylive/_url.py @@ -43,7 +43,7 @@ class ShinyliveIoApp: Parameters ---------- bundle - The file bundle of the ShinyLive application. This should be a list of files + The file bundle of the shinylive application. This should be a list of files as a dictionary of "name", "content" and optionally `"type": "binary"` for binary file types. (`"type": "text"` is the default and can be omitted.) language @@ -145,7 +145,7 @@ def url( header: Optional[bool] = None, ) -> str: """ - Get the URL of the ShinyLive application. + Get the URL of the shinylive application. Parameters ---------- @@ -160,7 +160,7 @@ def url( Returns ------- str - The URL of the ShinyLive application. + The URL of the shinylive application. """ mode = mode or self.mode header = header if header is not None else self.header @@ -179,7 +179,7 @@ def url( def view(self) -> None: """ - Open the ShinyLive application in a browser. + Open the shinylive application in a browser. """ import webbrowser @@ -255,7 +255,7 @@ def chunk( def json(self, **kwargs: Any) -> str: """ - Get the JSON representation of the ShinyLive application. + Get the JSON representation of the shinylive application. Parameters ---------- @@ -265,13 +265,13 @@ def json(self, **kwargs: Any) -> str: Returns ------- str - The JSON representation of the ShinyLive application. + The JSON representation of the shinylive application. """ return json.dumps(self._bundle, **kwargs) def write_files(self, dest: str | Path) -> Path: """ - Write the files in the ShinyLive application to a directory. + Write the files in the shinylive application to a directory. Parameters ---------- @@ -304,7 +304,7 @@ def add_files( files: Optional[str | Path | Sequence[str | Path]] = None, ) -> None: """ - Add files to the ShinyLive application. For more control over the file name, + Add files to the shinylive application. For more control over the file name, use the ``add_file`` method. Parameters @@ -335,7 +335,7 @@ def add_dir( overwrite: bool = False, ) -> None: """ - Add all files in a directory to the ShinyLive application. + Add all files in a directory to the shinylive application. Parameters ---------- @@ -388,7 +388,7 @@ def add_file( overwrite: bool = False, ) -> None: """ - Add a file to the ShinyLive application. + Add a file to the shinylive application. Parameters ---------- @@ -460,7 +460,7 @@ class ShinyliveIoAppLocal(ShinyliveIoApp): Parameters ---------- app - The main app file of the ShinyLive application. This file should be a Python + 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`. @@ -508,7 +508,7 @@ class ShinyliveIoAppText(ShinyliveIoAppLocal): Parameters ---------- app_code - The text contents of the main app file for the ShinyLive application. This file + The text contents of the main app file for the shinylive application. This file will be renamed `app.py` or `app.R` for shinylive. files File(s) or directory path(s) to include in the application. On shinylive, @@ -553,12 +553,12 @@ def url_encode( header: bool = True, ) -> ShinyliveIoApp: """ - Generate a URL for a [ShinyLive application](https://shinylive.io). + 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 + 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 From 885f84effe67292a59d9a40d87f8027b4fe45252 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Tue, 23 Jan 2024 11:36:28 -0500 Subject: [PATCH 13/30] remove some unnecessary pythonic fanciness --- shinylive/_url.py | 65 ----------------------------------------------- 1 file changed, 65 deletions(-) diff --git a/shinylive/_url.py b/shinylive/_url.py index 9476cc5..a856ff9 100644 --- a/shinylive/_url.py +++ b/shinylive/_url.py @@ -51,8 +51,6 @@ class ShinyliveIoApp: to None. """ - __slots__ = ("_bundle", "_language", "_mode", "_header", "_app_path", "_root_dir") - def __init__( self, bundle: list[FileContentJson], @@ -73,69 +71,6 @@ def __init__( self._app_path: Optional[Path] = None self._root_dir: Optional[Path] = None - @property - def mode(self) -> Literal["editor", "app"]: - """ - Is the shinylive.io app in editor or app mode? - - Returns - ------- - Literal["editor", "app"] - The current mode of the ShinyliveIoApp. - """ - return self._mode - - @mode.setter - def mode(self, value: Literal["editor", "app"]) -> None: - """ - Set the mode of the shinylive.io app. - - Parameters - ---------- - value : Literal["editor", "app"] - The new mode to set. - - Raises - ------ - ValueError - If the new mode is not 'editor' or 'app'. - """ - if value not in ["editor", "app"]: - raise ValueError("Invalid mode, must be either 'editor' or 'app'.") - self._mode = value - - @property - def header(self) -> bool: - """ - Should the Shiny header be included in the app preview? This property is only - used if the app is in 'app' mode. - - Returns - ------- - bool - ``True`` if the header should be included, ``False`` otherwise. - """ - return self._header - - @header.setter - def header(self, value: bool) -> None: - """ - Toggle whether or not to include the Shiny header in the app preview. - - Parameters - ---------- - value : bool - Whether the header should be included or not. - - Raises - ------ - ValueError - If the new header value is not boolean. - """ - if not isinstance(value, bool): - raise ValueError("Invalid header value, must be a boolean.") - self._header = value - def __str__(self) -> str: return self.url() From b299175e2a6ba399c0bc6b480a0221f57c2a2f39 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Tue, 23 Jan 2024 11:37:03 -0500 Subject: [PATCH 14/30] Make mode, header, host public and document --- shinylive/_url.py | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/shinylive/_url.py b/shinylive/_url.py index a856ff9..fec5622 100644 --- a/shinylive/_url.py +++ b/shinylive/_url.py @@ -49,12 +49,23 @@ class ShinyliveIoApp: language The language of the application, or None to autodetect the language. Defaults to None. + mode + The mode of the application, used when creating a shinylive.io URL. Accepted + values are either "editor" or "app"; defaults to "editor". + header + Whether to include a header bar in the UI when creating a shinylive.io URL. This + is used only if ``mode`` is "app". Defaults to True. + host + The host URL of the shinylive application. Defaults to "https://shinylive.io". """ def __init__( self, bundle: list[FileContentJson], language: Optional[Literal["py", "r"]], + mode: Literal["editor", "app"] = "editor", + header: bool = True, + host: str = "https://shinylive.io", ): self._bundle = bundle if language is None: @@ -66,8 +77,19 @@ def __init__( ) self._language = language - self._mode: Literal["editor", "app"] = "editor" - self._header: bool = True + if mode not in ["editor", "app"]: + raise ValueError( + f"Invalid mode '{mode}', must be either 'editor' or 'app'." + ) + + if not isinstance(header, bool): + raise ValueError( + f"Invalid header '{header}', must be either True or False." + ) + + self.mode: Literal["editor", "app"] = mode + self.header: bool = True + self.host: str = host self._app_path: Optional[Path] = None self._root_dir: Optional[Path] = None From 05d9bccfcaaab0d85b3445e6f70c094a95cd4db0 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Tue, 23 Jan 2024 11:57:45 -0500 Subject: [PATCH 15/30] Add alternate constructors --- shinylive/_url.py | 230 ++++++++++++++++++++++++++-------------------- 1 file changed, 129 insertions(+), 101 deletions(-) diff --git a/shinylive/_url.py b/shinylive/_url.py index fec5622..108b64d 100644 --- a/shinylive/_url.py +++ b/shinylive/_url.py @@ -8,6 +8,7 @@ import sys from pathlib import Path from typing import Any, Literal, Optional, Sequence, cast +from urllib.parse import urlparse # Even though TypedDict is available in Python 3.8, because it's used with NotRequired, # they should both come from the same typing module. @@ -93,6 +94,123 @@ def __init__( self._app_path: Optional[Path] = None self._root_dir: Optional[Path] = None + @classmethod + def from_local( + cls, + app: str | Path, + files: Optional[str | Path | Sequence[str | Path]] = None, + language: Optional[Literal["py", "r"]] = None, + **kwargs: Any, + ) -> ShinyliveIoApp: + """ + Create an instance of a Shiny App from local files for use with 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. + language + The language of the application, or None to autodetect the language. Defaults + to None. + """ + 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'." + ) + + self = cls([], language=language, **kwargs) + + self._app_path = Path(app) + self._root_dir = self._app_path.parent + app_fc = read_file(app, self._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 self._language == 'py' else 'R'}" + + self._bundle.append(app_fc) + self.add_files(files) + return self + + @classmethod + def from_text( + cls, + app_text: str, + files: Optional[str | Path | Sequence[str | Path]] = None, + language: Optional[Literal["py", "r"]] = None, + root_dir: Optional[str | Path] = None, + **kwargs: Any, + ) -> ShinyliveIoApp: + """ + Create an instance of a Shiny App from a string containing the `app.py` or `app.R` + file contents for use with shinylive.io. + + Parameters + ---------- + app_code + The text contents of the main app file for the shinylive application. This file + will be renamed `app.py` or `app.R` for shinylive. + 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. + language + The language of the application, or None to autodetect the language. Defaults + to None. + root_dir + The root directory of the application,used to determine the relative + path of supporting files to the main ``app`` file. Defaults to ``None``, meaning + that additional files are added in a flattened structure. + """ + if language is None: + language = detect_app_language(app_text) + elif language not in ["py", "r"]: + raise ValueError( + f"Language '{language}' is not supported. Please specify one of 'py' or 'r'." + ) + + default_app_file = f"app.{'py' if language == 'py' else 'R'}" + + self = cls([], language=language, **kwargs) + + self._root_dir = Path(root_dir) if root_dir is not None else None + self.add_file_contents({default_app_file: app_text}) + self.add_files(files) + return self + + @classmethod + def from_url(cls, url: str) -> ShinyliveIoApp: + """ + Create an instance of a Shiny App from a shinylive.io URL. + + Parameters + ---------- + url + The shinylive.io URL to decode. + """ + + url = url.strip() + bundle = bundle_from_url(url) + language = "r" if "shinylive.io/r/" in url else "py" + mode = "app" if f"{language}/app/" in url else "editor" + header = False if "h=0" in url else True + scheme, netloc, *_ = urlparse(url) + + return cls( + bundle, + language=language, + mode=mode, + header=header, + host=f"{scheme}://{netloc}", + ) + def __str__(self) -> str: return self.url() @@ -100,6 +218,7 @@ def url( self, mode: Optional[Literal["editor", "app"]] = None, header: Optional[bool] = None, + host: Optional[str] = None, ) -> str: """ Get the URL of the shinylive application. @@ -109,10 +228,12 @@ def url( mode The mode of the application, either "editor" or "app". Defaults to the current mode. - header Whether to include a header bar in the UI. This is used only if ``mode`` is "app". Defaults to the current header value. + host + The host URL of the shinylive application. Defaults to the current host URL, + which is typically ``"https://shinylive.io"``. Returns ------- @@ -129,7 +250,8 @@ def url( file_lz = lzstring_file_bundle(self._bundle) - base = "https://shinylive.io" + base = host or self.host + h = "h=0&" if not header and mode == "app" else "" return f"{base}/{self._language}/{mode}/#{h}code={file_lz}" @@ -410,98 +532,6 @@ def __sub__(self, other: str | Path) -> ShinyliveIoApp: return new -class ShinyliveIoAppLocal(ShinyliveIoApp): - """ - Create an instance of a Shiny App from local files for use with 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. - language - The language of the application, or None to autodetect the language. Defaults - to None. - """ - - def __init__( - self, - app: str | Path, - files: Optional[str | Path | Sequence[str | Path]] = None, - language: Optional[Literal["py", "r"]] = None, - ): - 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'." - ) - - self._bundle: list[FileContentJson] = [] - self._language = language - - self._app_path = Path(app) - self._root_dir = self._app_path.parent - app_fc = read_file(app, self._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 self._language == 'py' else 'R'}" - - self._bundle.append(app_fc) - self.add_files(files) - - -class ShinyliveIoAppText(ShinyliveIoAppLocal): - """ - Create an instance of a Shiny App from a string containing the `app.py` or `app.R` - file contents for use with shinylive.io. - - Parameters - ---------- - app_code - The text contents of the main app file for the shinylive application. This file - will be renamed `app.py` or `app.R` for shinylive. - 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. - language - The language of the application, or None to autodetect the language. Defaults - to None. - root_dir - The root directory of the application,used to determine the relative - path of supporting files to the main ``app`` file. Defaults to ``None``, meaning - that additional files are added in a flattened structure. - """ - - def __init__( - self, - app_code: str, - files: Optional[str | Path | Sequence[str | Path]] = None, - language: Optional[Literal["py", "r"]] = None, - root_dir: Optional[str | Path] = None, - ): - if language is None: - language = detect_app_language(app_code) - elif language not in ["py", "r"]: - raise ValueError( - f"Language '{language}' is not supported. Please specify one of 'py' or 'r'." - ) - - default_app_file = f"app.{'py' if language == 'py' else 'R'}" - - self._bundle: list[FileContentJson] = [] - self._language = language - self._root_dir = Path(root_dir) if root_dir is not None else None - self.add_file_contents({default_app_file: app_code}) - self.add_files(files) - - def url_encode( app: str | Path, files: Optional[str | Path | Sequence[str | Path]] = None, @@ -564,10 +594,13 @@ def url_decode(url: str) -> ShinyliveIoApp: ------- A ShinyliveIoApp object. """ + return ShinyliveIoApp.from_url(url) + + +def bundle_from_url(url: str) -> list[FileContentJson]: from lzstring import LZString # type: ignore[reportMissingTypeStubs] url = url.strip() - language = "r" if "shinylive.io/r/" in url else "py" try: bundle_json = cast( @@ -625,12 +658,7 @@ def url_decode(url: str) -> ShinyliveIoApp: ) ret.append(fc) - app = ShinyliveIoApp(ret, language=language) - - app.mode = "app" if f"{language}/app/" in url else "editor" - app.header = False if "h=0" in url else True - - return app + return ret def detect_app_language(app: str | Path) -> Literal["py", "r"]: From d7af4956224677ee0b9c81b8890c425f3793524e Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Tue, 23 Jan 2024 11:58:39 -0500 Subject: [PATCH 16/30] Rename class ShinyliveApp --- shinylive/_url.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/shinylive/_url.py b/shinylive/_url.py index 108b64d..d2e34ad 100644 --- a/shinylive/_url.py +++ b/shinylive/_url.py @@ -37,7 +37,7 @@ class FileContentJson(TypedDict): """ -class ShinyliveIoApp: +class ShinyliveApp: """ Create an instance of a Shiny App for use with shinylive.io. @@ -101,7 +101,7 @@ def from_local( files: Optional[str | Path | Sequence[str | Path]] = None, language: Optional[Literal["py", "r"]] = None, **kwargs: Any, - ) -> ShinyliveIoApp: + ) -> ShinyliveApp: """ Create an instance of a Shiny App from local files for use with shinylive.io. @@ -148,7 +148,7 @@ def from_text( language: Optional[Literal["py", "r"]] = None, root_dir: Optional[str | Path] = None, **kwargs: Any, - ) -> ShinyliveIoApp: + ) -> ShinyliveApp: """ Create an instance of a Shiny App from a string containing the `app.py` or `app.R` file contents for use with shinylive.io. @@ -186,7 +186,7 @@ def from_text( return self @classmethod - def from_url(cls, url: str) -> ShinyliveIoApp: + def from_url(cls, url: str) -> ShinyliveApp: """ Create an instance of a Shiny App from a shinylive.io URL. @@ -500,16 +500,16 @@ def add_file( self._bundle.append(file_new) - def __add__(self, other: str | Path) -> ShinyliveIoApp: + def __add__(self, other: str | Path) -> ShinyliveApp: other = Path(other) - new: ShinyliveIoApp = copy.deepcopy(self) + new: ShinyliveApp = copy.deepcopy(self) if other.is_dir(): new.add_dir(other) else: new.add_file(other) return new - def __sub__(self, other: str | Path) -> ShinyliveIoApp: + def __sub__(self, other: str | Path) -> ShinyliveApp: file_names = [file["name"] for file in self._bundle] index = None @@ -527,7 +527,7 @@ def __sub__(self, other: str | Path) -> ShinyliveIoApp: if index is None: raise ValueError(f"File '{other}' not found in app bundle.") - new: ShinyliveIoApp = copy.deepcopy(self) + new: ShinyliveApp = copy.deepcopy(self) new._bundle.pop(index) return new @@ -538,7 +538,7 @@ def url_encode( language: Optional[Literal["py", "r"]] = None, mode: Literal["editor", "app"] = "editor", header: bool = True, -) -> ShinyliveIoApp: +) -> ShinyliveApp: """ Generate a URL for a [shinylive application](https://shinylive.io). @@ -581,7 +581,7 @@ def url_encode( return sl_app -def url_decode(url: str) -> ShinyliveIoApp: +def url_decode(url: str) -> ShinyliveApp: """ Decode a Shinylive URL into a ShinyliveIoApp object. @@ -594,7 +594,7 @@ def url_decode(url: str) -> ShinyliveIoApp: ------- A ShinyliveIoApp object. """ - return ShinyliveIoApp.from_url(url) + return ShinyliveApp.from_url(url) def bundle_from_url(url: str) -> list[FileContentJson]: From 7aa7f945d649f71f73c017cb83601b1e04211a0c Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Tue, 23 Jan 2024 12:00:24 -0500 Subject: [PATCH 17/30] update `url_encode()` to return a string --- shinylive/_url.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/shinylive/_url.py b/shinylive/_url.py index d2e34ad..a227d55 100644 --- a/shinylive/_url.py +++ b/shinylive/_url.py @@ -538,7 +538,8 @@ def url_encode( language: Optional[Literal["py", "r"]] = None, mode: Literal["editor", "app"] = "editor", header: bool = True, -) -> ShinyliveApp: + host: str = "https://shinylive.io", +) -> str: """ Generate a URL for a [shinylive application](https://shinylive.io). @@ -560,6 +561,7 @@ def url_encode( Whether to include a header bar in the UI. This is used only if ``mode`` is "app". Defaults to True. + Returns ------- A ShinyliveIoApp object. Use the `.url()` method to retrieve the Shinylive URL. @@ -571,14 +573,15 @@ def url_encode( lang = language if language is not None else detect_app_language(app) if isinstance(app, str) and "\n" in app: - sl_app = ShinyliveIoAppText(app, files, lang) + sl_app = ShinyliveApp.from_text( + app, files, lang, mode=mode, header=header, host=host + ) else: - sl_app = ShinyliveIoAppLocal(app, files, lang) - - sl_app.mode = mode - sl_app.header = header + sl_app = ShinyliveApp.from_local( + app, files, lang, mode=mode, header=header, host=host + ) - return sl_app + return sl_app.url() def url_decode(url: str) -> ShinyliveApp: From 57614ca268fd917ecc54b057553db914a3b22235 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Tue, 23 Jan 2024 12:03:47 -0500 Subject: [PATCH 18/30] flip order of logical section, to prioritize True --- shinylive/_url.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/shinylive/_url.py b/shinylive/_url.py index a227d55..a3062b6 100644 --- a/shinylive/_url.py +++ b/shinylive/_url.py @@ -434,13 +434,18 @@ def add_dir( raise ValueError(f"Directory '{dir}' does not exist or is not a directory.") for file in listdir_recursive(dir): - if not flatten: + if flatten: + # The contents of the directory are added into the "root" of the app + # bundle, e.g. `"../some_dir/www"` adds its contents to the bundle, + # i.e. the bundle will have `styles.css` and `app.js`, etc. + name = str(Path(file).relative_to(dir)) + else: + # The directory is added into the bundle, e.g. `"../some_dir/www"` + # is added as `www/` in the bundle. name = os.path.join( os.path.basename(dir), str(Path(file).relative_to(dir)), ) - else: - name = str(Path(file).relative_to(dir)) self.add_file(file, name, overwrite=overwrite) def add_file_contents(self, file_contents: dict[str, str]) -> None: From 2392841e12cf7d4a7531af8bbf252f74912a8ce2 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Tue, 23 Jan 2024 12:04:25 -0500 Subject: [PATCH 19/30] export ShinyliveApp --- shinylive/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/shinylive/__init__.py b/shinylive/__init__.py index 4527fb5..3173e99 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 ._url import url_decode, url_encode +from ._url import ShinyliveApp, url_decode, url_encode from ._version import SHINYLIVE_PACKAGE_VERSION __version__ = SHINYLIVE_PACKAGE_VERSION -__all__ = ("url_decode", "url_encode") +__all__ = ("ShinyliveApp", "url_decode", "url_encode") From 8762f7a245927b5f440337cdaaea2ac0abc9be62 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Tue, 23 Jan 2024 12:10:27 -0500 Subject: [PATCH 20/30] simplify setting language attribute --- shinylive/_url.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/shinylive/_url.py b/shinylive/_url.py index a3062b6..c33ebfc 100644 --- a/shinylive/_url.py +++ b/shinylive/_url.py @@ -70,13 +70,13 @@ def __init__( ): self._bundle = bundle if language is None: - self._language = detect_app_language(bundle[0]["content"]) + lang = detect_app_language(bundle[0]["content"]) else: if language not in ["py", "r"]: raise ValueError( f"Invalid language '{language}', must be either 'py' or 'r'." ) - self._language = language + lang = language if mode not in ["editor", "app"]: raise ValueError( @@ -91,6 +91,7 @@ def __init__( self.mode: Literal["editor", "app"] = mode self.header: bool = True self.host: str = host + self._language: Literal["py", "r"] = lang self._app_path: Optional[Path] = None self._root_dir: Optional[Path] = None From d323be6ae7ea17b7dc42575923835675198bf9db Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Tue, 23 Jan 2024 12:10:47 -0500 Subject: [PATCH 21/30] update CLI to use new ShinyliveApp constructors --- shinylive/_main.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/shinylive/_main.py b/shinylive/_main.py index e63f0ed..6c858b4 100644 --- a/shinylive/_main.py +++ b/shinylive/_main.py @@ -8,7 +8,7 @@ import click from . import _assets, _deps, _export -from ._url import detect_app_language, url_decode, url_encode +from ._url import ShinyliveApp, detect_app_language, url_decode from ._utils import print_as_json from ._version import SHINYLIVE_ASSETS_VERSION, SHINYLIVE_PACKAGE_VERSION @@ -554,18 +554,20 @@ def encode( else: lang = detect_app_language(app_in) - sl_app = url_encode( - app_in, files=files, language=lang, mode=mode, header=not no_header - ) + if app == "-": + sl_app = ShinyliveApp.from_text( + app_in, files=files, language=lang, mode=mode, header=not no_header + ) + else: + sl_app = ShinyliveApp.from_local( + app_in, files=files, language=lang, mode=mode, header=not no_header + ) if json: print(sl_app.json(indent=None)) if not view: return - sl_app.mode = mode - sl_app.header = not no_header - if not json: print(sl_app.url()) From 1420ac02811172ff27d10cf01c32cce6f04bc2e5 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Tue, 23 Jan 2024 12:10:55 -0500 Subject: [PATCH 22/30] tests: fix tests --- tests/test_url.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/tests/test_url.py b/tests/test_url.py index d94b61a..33b3b1d 100644 --- a/tests/test_url.py +++ b/tests/test_url.py @@ -19,7 +19,7 @@ def test_decode_py_editor(): app = url_decode(LINKS["py"]["editor"]) assert app._language == "py" - assert app._mode == "editor" + assert app.mode == "editor" assert app._bundle[0]["name"] == "app.py" assert "from shiny import" in app._bundle[0]["content"] assert "type" not in app._bundle[0] @@ -28,8 +28,8 @@ def test_decode_py_editor(): def test_decode_py_app(): app = url_decode(LINKS["py"]["app"]) assert app._language == "py" - assert app._mode == "app" - assert app._header + assert app.mode == "app" + assert app.header assert app._bundle[0]["name"] == "app.py" assert "from shiny import" in app._bundle[0]["content"] assert "type" not in app._bundle[0] @@ -38,8 +38,8 @@ def test_decode_py_app(): def test_decode_py_app_no_header(): app = url_decode(LINKS["py"]["app_no_header"]) assert app._language == "py" - assert app._mode == "app" - assert not app._header + assert app.mode == "app" + assert not app.header assert app._bundle[0]["name"] == "app.py" assert "from shiny import" in app._bundle[0]["content"] assert "type" not in app._bundle[0] @@ -48,7 +48,7 @@ def test_decode_py_app_no_header(): def test_decode_r_editor(): app = url_decode(LINKS["r"]["editor"]) assert app._language == "r" - assert app._mode == "editor" + assert app.mode == "editor" assert app._bundle[0]["name"] == "app.R" assert "library(shiny)" in app._bundle[0]["content"] assert "type" not in app._bundle[0] @@ -57,8 +57,8 @@ def test_decode_r_editor(): def test_decode_r_app(): app = url_decode(LINKS["r"]["app"]) assert app._language == "r" - assert app._mode == "app" - assert app._header + assert app.mode == "app" + assert app.header assert app._bundle[0]["name"] == "app.R" assert "library(shiny)" in app._bundle[0]["content"] assert "type" not in app._bundle[0] @@ -67,8 +67,8 @@ def test_decode_r_app(): def test_decode_r_app_no_header(): app = url_decode(LINKS["r"]["app_no_header"]) assert app._language == "r" - assert app._mode == "app" - assert not app._header + assert app.mode == "app" + assert not app.header assert app._bundle[0]["name"] == "app.R" assert "library(shiny)" in app._bundle[0]["content"] assert "type" not in app._bundle[0] @@ -76,7 +76,7 @@ def test_decode_r_app_no_header(): def test_encode_py_app_content(): app_code = "from shiny.express import ui\nui.div()" - app = url_encode(app_code) + app = ShinyliveApp.from_text(app_code) assert app._language == "py" assert str(app) == app.url() @@ -92,7 +92,7 @@ def test_encode_py_app_content(): def test_encode_r_app_content(): app_code = "library(shiny)\n\nshinyApp(pageFluid(), function(...) { })" - app = url_encode(app_code) + app = ShinyliveApp.from_text(app_code) assert app._language == "r" assert str(app) == app.url() From 23d15f1f641a8b61357b54f18da82a1047763e4c Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Tue, 23 Jan 2024 12:15:39 -0500 Subject: [PATCH 23/30] fix setting header in constructor --- shinylive/_url.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shinylive/_url.py b/shinylive/_url.py index c33ebfc..a8702ba 100644 --- a/shinylive/_url.py +++ b/shinylive/_url.py @@ -89,7 +89,7 @@ def __init__( ) self.mode: Literal["editor", "app"] = mode - self.header: bool = True + self.header: bool = header self.host: str = host self._language: Literal["py", "r"] = lang self._app_path: Optional[Path] = None From 72e959633a9deb763fb955b73061af10dc78891b Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 24 Jan 2024 08:09:43 -0500 Subject: [PATCH 24/30] Add `.remove_file()` method and call in `__sub__` --- shinylive/_url.py | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/shinylive/_url.py b/shinylive/_url.py index a8702ba..5f3c09a 100644 --- a/shinylive/_url.py +++ b/shinylive/_url.py @@ -506,35 +506,36 @@ def add_file( self._bundle.append(file_new) - def __add__(self, other: str | Path) -> ShinyliveApp: - other = Path(other) - new: ShinyliveApp = copy.deepcopy(self) - if other.is_dir(): - new.add_dir(other) - else: - new.add_file(other) - return new - - def __sub__(self, other: str | Path) -> ShinyliveApp: + def remove_file(self, file: str | Path) -> None: file_names = [file["name"] for file in self._bundle] index = None - if other in file_names: + if file in file_names: # find the index of the file to remove - index = file_names.index(other) + index = file_names.index(file) if self._root_dir is not None: root_dir = self._root_dir.absolute() - other_path = str(Path(other).absolute().relative_to(root_dir)) + other_path = str(Path(file).absolute().relative_to(root_dir)) if other_path in file_names: index = file_names.index(other_path) if index is None: - raise ValueError(f"File '{other}' not found in app bundle.") + raise ValueError(f"File '{file}' not found in app bundle.") + def __add__(self, other: str | Path) -> ShinyliveApp: + other = Path(other) + new: ShinyliveApp = copy.deepcopy(self) + if other.is_dir(): + new.add_dir(other) + else: + new.add_file(other) + return new + + def __sub__(self, other: str | Path) -> ShinyliveApp: new: ShinyliveApp = copy.deepcopy(self) - new._bundle.pop(index) + new.remove_file(other) return new From 93058693ba7dfe6625b9a4f845ab1b9f893c71bf Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 24 Jan 2024 08:18:41 -0500 Subject: [PATCH 25/30] Allow method chaining in ShinyliveApp methods --- shinylive/_url.py | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/shinylive/_url.py b/shinylive/_url.py index 5f3c09a..6b18dd9 100644 --- a/shinylive/_url.py +++ b/shinylive/_url.py @@ -178,13 +178,12 @@ def from_text( ) default_app_file = f"app.{'py' if language == 'py' else 'R'}" + app_fc = {default_app_file: app_text} self = cls([], language=language, **kwargs) - self._root_dir = Path(root_dir) if root_dir is not None else None - self.add_file_contents({default_app_file: app_text}) - self.add_files(files) - return self + + return self.add_file_contents(app_fc).add_files(files) @classmethod def from_url(cls, url: str) -> ShinyliveApp: @@ -382,7 +381,7 @@ def write_files(self, dest: str | Path) -> Path: def add_files( self, files: Optional[str | Path | Sequence[str | Path]] = None, - ) -> None: + ) -> ShinyliveApp: """ Add files to the shinylive application. For more control over the file name, use the ``add_file`` method. @@ -398,7 +397,7 @@ def add_files( files paths are flattened to include only the file name. """ if files is None: - return + return self if isinstance(files, (str, Path)): files = [files] @@ -408,12 +407,14 @@ def add_files( continue self.add_file(file) + return self + def add_dir( self, dir: str | Path, flatten: bool = False, overwrite: bool = False, - ) -> None: + ) -> ShinyliveApp: """ Add all files in a directory to the shinylive application. @@ -449,7 +450,9 @@ def add_dir( ) self.add_file(file, name, overwrite=overwrite) - def add_file_contents(self, file_contents: dict[str, str]) -> None: + return self + + def add_file_contents(self, file_contents: dict[str, str]) -> ShinyliveApp: """ Directly adds a text file to the Shinylive app. @@ -466,12 +469,14 @@ def add_file_contents(self, file_contents: dict[str, str]) -> None: } ) + return self + def add_file( self, file: str | Path, name: Optional[str | Path] = None, overwrite: bool = False, - ) -> None: + ) -> ShinyliveApp: """ Add a file to the shinylive application. @@ -506,7 +511,9 @@ def add_file( self._bundle.append(file_new) - def remove_file(self, file: str | Path) -> None: + return self + + def remove_file(self, file: str | Path) -> ShinyliveApp: file_names = [file["name"] for file in self._bundle] index = None @@ -524,6 +531,8 @@ def remove_file(self, file: str | Path) -> None: if index is None: raise ValueError(f"File '{file}' not found in app bundle.") + return self + def __add__(self, other: str | Path) -> ShinyliveApp: other = Path(other) new: ShinyliveApp = copy.deepcopy(self) From 87fc46edc5a9c5492621deca23059a339afa8659 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 24 Jan 2024 08:31:07 -0500 Subject: [PATCH 26/30] rename methods `.url()` -> `.to_url()` also for json, chunk, chunk_contents --- shinylive/_main.py | 8 ++++---- shinylive/_url.py | 16 ++++++++-------- tests/test_url.py | 12 ++++++------ 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/shinylive/_main.py b/shinylive/_main.py index 6c858b4..865d85e 100644 --- a/shinylive/_main.py +++ b/shinylive/_main.py @@ -564,12 +564,12 @@ def encode( ) if json: - print(sl_app.json(indent=None)) + print(sl_app.to_json(indent=None)) if not view: return if not json: - print(sl_app.url()) + print(sl_app.to_url()) if view: sl_app.view() @@ -612,13 +612,13 @@ def decode(url: str, dir: Optional[str] = None, json: bool = False) -> None: sl_app = url_decode(url_in) if json: - print(sl_app.json(indent=None)) + print(sl_app.to_json(indent=None)) return if dir is not None: sl_app.write_files(dir) else: - print(sl_app.chunk_contents()) + print(sl_app.to_chunk_contents()) # ############################################################################# diff --git a/shinylive/_url.py b/shinylive/_url.py index 6b18dd9..f69f26d 100644 --- a/shinylive/_url.py +++ b/shinylive/_url.py @@ -212,9 +212,9 @@ def from_url(cls, url: str) -> ShinyliveApp: ) def __str__(self) -> str: - return self.url() + return self.to_url() - def url( + def to_url( self, mode: Optional[Literal["editor", "app"]] = None, header: Optional[bool] = None, @@ -262,9 +262,9 @@ def view(self) -> None: """ import webbrowser - webbrowser.open(self.url()) + webbrowser.open(self.to_url()) - def chunk_contents(self) -> str: + def to_chunk_contents(self) -> str: """ Create the contents of a shinylive chunk based on the files in the app. This output does not include the shinylive chunk header or options. @@ -286,7 +286,7 @@ def chunk_contents(self) -> str: return "\n".join(lines) - def chunk( + def to_chunk( self, components: Sequence[Literal["editor", "viewer"]] = ("editor", "viewer"), layout: Literal["horizontal", "vertical"] = "horizontal", @@ -329,10 +329,10 @@ def chunk( components=", ".join(components), layout=layout, viewer_height=viewer_height, - contents=self.chunk_contents(), + contents=self.to_chunk_contents(), ) - def json(self, **kwargs: Any) -> str: + def to_json(self, **kwargs: Any) -> str: """ Get the JSON representation of the shinylive application. @@ -597,7 +597,7 @@ def url_encode( app, files, lang, mode=mode, header=header, host=host ) - return sl_app.url() + return sl_app.to_url() def url_decode(url: str) -> ShinyliveApp: diff --git a/tests/test_url.py b/tests/test_url.py index 33b3b1d..4fea3f1 100644 --- a/tests/test_url.py +++ b/tests/test_url.py @@ -79,15 +79,15 @@ def test_encode_py_app_content(): app = ShinyliveApp.from_text(app_code) assert app._language == "py" - assert str(app) == app.url() + assert str(app) == app.to_url() assert app._bundle == [ { "name": "app.py", "content": app_code, } ] - assert "## file: app.py" in app.chunk_contents() - assert app_code in app.chunk_contents() + assert "## file: app.py" in app.to_chunk_contents() + assert app_code in app.to_chunk_contents() def test_encode_r_app_content(): @@ -95,12 +95,12 @@ def test_encode_r_app_content(): app = ShinyliveApp.from_text(app_code) assert app._language == "r" - assert str(app) == app.url() + assert str(app) == app.to_url() assert app._bundle == [ { "name": "app.R", "content": app_code, } ] - assert "## file: app.R" in app.chunk_contents() - assert app_code in app.chunk_contents() + assert "## file: app.R" in app.to_chunk_contents() + assert app_code in app.to_chunk_contents() From 6907ec204f165e1d82817a03c9cab0892f74a0a6 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 24 Jan 2024 08:33:47 -0500 Subject: [PATCH 27/30] docs: update changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 74892df..11b8ca0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [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 `url_encode()` and `url_decode()` functions for programmatic use, returning a `ShinyliveIoApp` instance with helpful methods to get the app URL, save the app locally, or create a shinylive quarto chunk from the app's files. (#20, #23) +* Added `shinylive url encode` and `shinylive url decode` commands to encode local apps into a [shinylive.io](https://shinylive.io) URL or decode a [shinylive.io](https://shinylive.io) URL into local files. These commands are accompanied by `url_encode()` and `url_decode()` functions for programmatic use. They are supported by the new `ShinyliveIoApp` class which provides methods to get the app URL, save the app locally, or create a [Shinylive quarto chunk](https://quarto-ext.github.io/shinylive/) from the app's files. (#20, #23) ## [0.1.3] - 2024-12-19 From dbc60bc752d27a600b8980f8ec1482dd6dc52cc4 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 24 Jan 2024 08:36:04 -0500 Subject: [PATCH 28/30] docs: shinylive -> Shinylive This seems to be the preferred capitalization from other docs --- shinylive/_url.py | 56 +++++++++++++++++++++++------------------------ 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/shinylive/_url.py b/shinylive/_url.py index f69f26d..959c7c6 100644 --- a/shinylive/_url.py +++ b/shinylive/_url.py @@ -44,7 +44,7 @@ class ShinyliveApp: Parameters ---------- bundle - The file bundle of the shinylive application. This should be a list of files + The file bundle of the Shinylive application. This should be a list of files as a dictionary of "name", "content" and optionally `"type": "binary"` for binary file types. (`"type": "text"` is the default and can be omitted.) language @@ -57,7 +57,7 @@ class ShinyliveApp: Whether to include a header bar in the UI when creating a shinylive.io URL. This is used only if ``mode`` is "app". Defaults to True. host - The host URL of the shinylive application. Defaults to "https://shinylive.io". + The host URL of the Shinylive application. Defaults to "https://shinylive.io". """ def __init__( @@ -109,7 +109,7 @@ def from_local( Parameters ---------- app - The main app file of the shinylive application. This file should be a Python + 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`. @@ -157,7 +157,7 @@ def from_text( Parameters ---------- app_code - The text contents of the main app file for the shinylive application. This file + The text contents of the main app file for the Shinylive application. This file will be renamed `app.py` or `app.R` for shinylive. files File(s) or directory path(s) to include in the application. On shinylive, @@ -221,7 +221,7 @@ def to_url( host: Optional[str] = None, ) -> str: """ - Get the URL of the shinylive application. + Get the URL of the Shinylive application. Parameters ---------- @@ -232,13 +232,13 @@ def to_url( Whether to include a header bar in the UI. This is used only if ``mode`` is "app". Defaults to the current header value. host - The host URL of the shinylive application. Defaults to the current host URL, + The host URL of the Shinylive application. Defaults to the current host URL, which is typically ``"https://shinylive.io"``. Returns ------- str - The URL of the shinylive application. + The URL of the Shinylive application. """ mode = mode or self.mode header = header if header is not None else self.header @@ -258,7 +258,7 @@ def to_url( def view(self) -> None: """ - Open the shinylive application in a browser. + Open the Shinylive application in a browser. """ import webbrowser @@ -266,13 +266,13 @@ def view(self) -> None: def to_chunk_contents(self) -> str: """ - Create the contents of a shinylive chunk based on the files in the app. This - output does not include the shinylive chunk header or options. + Create the contents of a Shinylive chunk based on the files in the app. This + output does not include the Shinylive chunk header or options. Returns ------- str - The contents of the shinylive chunk. + The contents of the Shinylive chunk. """ lines: list[str] = [] for file in self._bundle: @@ -293,7 +293,7 @@ def to_chunk( viewer_height: int = 500, ) -> str: """ - Create a shinylive chunk based on the files in the app for use in a Quarto + Create a Shinylive chunk based on the files in the app for use in a Quarto web document. Parameters @@ -310,7 +310,7 @@ def to_chunk( Returns ------- str - The full shinylive chunk, including the chunk header and options. + The full Shinylive chunk, including the chunk header and options. """ if layout not in ["horizontal", "vertical"]: raise ValueError( @@ -334,7 +334,7 @@ def to_chunk( def to_json(self, **kwargs: Any) -> str: """ - Get the JSON representation of the shinylive application. + Get the JSON representation of the Shinylive application. Parameters ---------- @@ -344,13 +344,13 @@ def to_json(self, **kwargs: Any) -> str: Returns ------- str - The JSON representation of the shinylive application. + The JSON representation of the Shinylive application. """ return json.dumps(self._bundle, **kwargs) def write_files(self, dest: str | Path) -> Path: """ - Write the files in the shinylive application to a directory. + Write the files in the Shinylive application to a directory. Parameters ---------- @@ -383,7 +383,7 @@ def add_files( files: Optional[str | Path | Sequence[str | Path]] = None, ) -> ShinyliveApp: """ - Add files to the shinylive application. For more control over the file name, + Add files to the Shinylive application. For more control over the file name, use the ``add_file`` method. Parameters @@ -416,7 +416,7 @@ def add_dir( overwrite: bool = False, ) -> ShinyliveApp: """ - Add all files in a directory to the shinylive application. + Add all files in a directory to the Shinylive application. Parameters ---------- @@ -478,7 +478,7 @@ def add_file( overwrite: bool = False, ) -> ShinyliveApp: """ - Add a file to the shinylive application. + Add a file to the Shinylive application. Parameters ---------- @@ -557,12 +557,12 @@ def url_encode( host: str = "https://shinylive.io", ) -> str: """ - Generate a URL for a [shinylive application](https://shinylive.io). + 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 + 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 @@ -630,30 +630,30 @@ def bundle_from_url(url: str) -> list[FileContentJson]: ) bundle = json.loads(bundle_json) except Exception: - raise ValueError("Could not parse and decode the shinylive URL code payload.") + raise ValueError("Could not parse and decode the Shinylive URL code payload.") ret: list[FileContentJson] = [] # bundle should be an array of FileContentJson objects, otherwise raise an error if not isinstance(bundle, list): raise ValueError( - "The shinylive URL was not formatted correctly: `code` did not decode to a list." + "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." + "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`." + "Invalid Shinylive URL: `code` included an object that was missing required fields `name` or `content`." ) for key in ["name", "content"]: if not isinstance(file[key], str): raise ValueError( - f"Invalid shinylive URL: encoded file bundle contains an file where `{key}` was not a string." + f"Invalid Shinylive URL: encoded file bundle contains an file where `{key}` was not a string." ) fc: FileContentJson = { @@ -668,12 +668,12 @@ def bundle_from_url(url: str) -> list[FileContentJson]: pass else: raise ValueError( - f"Invalid shinylive URL: unexpected file type '{file['type']}' in '{file['name']}'." + 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." + f"Invalid Shinylive URL: not all items in '{file['name']}' were strings." ) ret.append(fc) From 95ab2657745d34e5a5229f42ed6e610f2a9e4d79 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 24 Jan 2024 08:53:45 -0500 Subject: [PATCH 29/30] remove trailing slashes from host --- shinylive/_url.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shinylive/_url.py b/shinylive/_url.py index 959c7c6..8afb5fd 100644 --- a/shinylive/_url.py +++ b/shinylive/_url.py @@ -250,7 +250,7 @@ def to_url( file_lz = lzstring_file_bundle(self._bundle) - base = host or self.host + base = (host or self.host).rstrip("/") h = "h=0&" if not header and mode == "app" else "" From 21a51fe4b860d8e7361bd50848a7f75809a6ce4f Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 24 Jan 2024 14:02:22 -0500 Subject: [PATCH 30/30] Update reference to ShinyliveIoApp Co-authored-by: Winston Chang --- shinylive/_url.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shinylive/_url.py b/shinylive/_url.py index 8afb5fd..96ceec3 100644 --- a/shinylive/_url.py +++ b/shinylive/_url.py @@ -580,7 +580,7 @@ def url_encode( Returns ------- - A ShinyliveIoApp object. Use the `.url()` method to retrieve the Shinylive URL. + A ShinyliveApp object. Use the `.url()` method to retrieve the Shinylive URL. """ if language is not None and language not in ["py", "r"]: