From 3a9c5abbf41313a9f68194590246c39e8304deab Mon Sep 17 00:00:00 2001 From: Winston Chang Date: Mon, 13 Nov 2023 12:00:59 -0600 Subject: [PATCH 1/5] Add support for shiny express apps --- rsconnect/main.py | 34 +++++++++++++++-- rsconnect/shiny_express.py | 76 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 4 deletions(-) create mode 100644 rsconnect/shiny_express.py diff --git a/rsconnect/main.py b/rsconnect/main.py index e5bb0ed9..e308d17a 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -80,6 +80,7 @@ produce_bootstrap_output, parse_client_response, ) +from .shiny_express import is_express_app server_store = ServerStore() future_enabled = False @@ -1366,16 +1367,41 @@ def deploy_app( no_verify: bool = False, ): set_verbosity(verbose) - kwargs = locals() - kwargs["entrypoint"] = entrypoint = validate_entry_point(entrypoint, directory) - kwargs["extra_files"] = extra_files = validate_extra_files(directory, extra_files) + entrypoint = validate_entry_point(entrypoint, directory) + extra_files = validate_extra_files(directory, extra_files) environment = create_python_environment( directory, force_generate, python, ) - ce = RSConnectExecutor(**kwargs) + if is_express_app(entrypoint + ".py", directory): + env_vars["SHINY_EXPRESS_APP_FILE"] = entrypoint + ".py" + entrypoint = "shiny.express.app" + + extra_args = dict( + directory=directory, + server=server, + exclude=exclude, + new=new, + app_id=app_id, + title=title, + visibility=visibility, + disable_env_management=disable_env_management, + env_vars=env_vars, + ) + + ce = RSConnectExecutor( + name=name, + api_key=api_key, + insecure=insecure, + cacert=cacert, + account=account, + token=token, + secret=secret, + **extra_args, + ) + ( ce.validate_server() .validate_app_mode(app_mode=app_mode) diff --git a/rsconnect/shiny_express.py b/rsconnect/shiny_express.py new file mode 100644 index 00000000..7061be7b --- /dev/null +++ b/rsconnect/shiny_express.py @@ -0,0 +1,76 @@ +# The contents of this file are copied from: +# https://github.com/posit-dev/py-shiny/blob/feb4cb7f872922717c39753514ae2d7fa32f10a1/shiny/express/_is_express.py + +from __future__ import annotations + +import ast +from pathlib import Path + +__all__ = ("is_express_app",) + + +def is_express_app(app: str, app_dir: str | None) -> bool: + """Detect whether an app file is a Shiny express app + + Parameters + ---------- + app + App filename, like "app.py". It may be a relative path or absolute path. + app_dir + Directory containing the app file. If this is `None`, then `app` must be an + absolute path. + + Returns + ------- + : + `True` if it is a Shiny express app, `False` otherwise. + """ + if not app.lower().endswith(".py"): + return False + + if app_dir is not None: + app_path = Path(app_dir) / app + else: + app_path = Path(app) + + if not app_path.exists(): + return False + + try: + # Read the file, parse it, and look for any imports of shiny.express. + with open(app_path) as f: + content = f.read() + tree = ast.parse(content, app_path) + detector = DetectShinyExpressVisitor() + detector.visit(tree) + + except Exception: + return False + + return detector.found_shiny_express_import + + +class DetectShinyExpressVisitor(ast.NodeVisitor): + def __init__(self): + super().__init__() + self.found_shiny_express_import = False + + def visit_Import(self, node: ast.Import): + if any(alias.name == "shiny.express" for alias in node.names): + self.found_shiny_express_import = True + + def visit_ImportFrom(self, node: ast.ImportFrom): + if node.module == "shiny.express": + self.found_shiny_express_import = True + elif node.module == "shiny" and any( + alias.name == "express" for alias in node.names + ): + self.found_shiny_express_import = True + + # Visit top-level nodes. + def visit_Module(self, node: ast.Module): + super().generic_visit(node) + + # Don't recurse into any nodes, so the we'll only ever look at top-level nodes. + def generic_visit(self, node: ast.AST): + pass From 661781271530de7cecd9f0578bf6619793c14fb4 Mon Sep 17 00:00:00 2001 From: Winston Chang Date: Tue, 14 Nov 2023 09:06:01 -0600 Subject: [PATCH 2/5] Update changelog --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a92bb384..a554abe7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Fixed + +### Added +- Added support for deploying Shiny Express applications. + ## [1.21.0] - 2023-10-26 ### Fixed From 06ba9afef8da107e2d958fdc3494920548acb3f6 Mon Sep 17 00:00:00 2001 From: Michael Marchetti Date: Thu, 16 Nov 2023 09:41:06 -0500 Subject: [PATCH 3/5] fix typing for cacert --- rsconnect/main.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/rsconnect/main.py b/rsconnect/main.py index e308d17a..ff2e2ccb 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -840,7 +840,7 @@ def deploy_notebook( server: str, api_key: str, insecure: bool, - cacert: typing.IO, + cacert: str, static: bool, new: bool, app_id: str, @@ -977,7 +977,7 @@ def deploy_voila( server: str = None, api_key: str = None, insecure: bool = False, - cacert: typing.IO = None, + cacert: str = None, connect_server: api.RSConnectServer = None, multi_notebook: bool = False, no_verify: bool = False, @@ -1030,7 +1030,7 @@ def deploy_manifest( server: str, api_key: str, insecure: bool, - cacert: typing.IO, + cacert: str, account: str, token: str, secret: str, @@ -1125,7 +1125,7 @@ def deploy_quarto( server: str, api_key: str, insecure: bool, - cacert: typing.IO, + cacert: str, new: bool, app_id: str, title: str, @@ -1243,7 +1243,7 @@ def deploy_html( server: str = None, api_key: str = None, insecure: bool = False, - cacert: typing.IO = None, + cacert: str = None, account: str = None, token: str = None, secret: str = None, @@ -1278,7 +1278,6 @@ def deploy_html( def generate_deploy_python(app_mode: AppMode, alias: str, min_version: str, desc: Optional[str] = None): - if desc is None: desc = app_mode.desc() @@ -1344,7 +1343,7 @@ def deploy_app( server: str, api_key: str, insecure: bool, - cacert: typing.IO, + cacert: str, entrypoint, exclude, new: bool, From 58a6af51bb0d1c2a08bab69b417bac45d4c3ef84 Mon Sep 17 00:00:00 2001 From: Michael Marchetti Date: Thu, 16 Nov 2023 09:41:27 -0500 Subject: [PATCH 4/5] formatting --- rsconnect/shiny_express.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/rsconnect/shiny_express.py b/rsconnect/shiny_express.py index 7061be7b..2bc64f62 100644 --- a/rsconnect/shiny_express.py +++ b/rsconnect/shiny_express.py @@ -62,9 +62,7 @@ def visit_Import(self, node: ast.Import): def visit_ImportFrom(self, node: ast.ImportFrom): if node.module == "shiny.express": self.found_shiny_express_import = True - elif node.module == "shiny" and any( - alias.name == "express" for alias in node.names - ): + elif node.module == "shiny" and any(alias.name == "express" for alias in node.names): self.found_shiny_express_import = True # Visit top-level nodes. From d9e230ae7276d499cdc5e7e0b20defc4cce6a14b Mon Sep 17 00:00:00 2001 From: Winston Chang Date: Fri, 17 Nov 2023 22:53:10 -0600 Subject: [PATCH 5/5] Stop using env var for shiny express filename --- rsconnect/main.py | 5 ++--- rsconnect/shiny_express.py | 25 +++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/rsconnect/main.py b/rsconnect/main.py index 76ab4eb3..44242c13 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -80,7 +80,7 @@ produce_bootstrap_output, parse_client_response, ) -from .shiny_express import is_express_app +from .shiny_express import escape_to_var_name, is_express_app server_store = ServerStore() future_enabled = False @@ -1424,8 +1424,7 @@ def deploy_app( ) if is_express_app(entrypoint + ".py", directory): - env_vars["SHINY_EXPRESS_APP_FILE"] = entrypoint + ".py" - entrypoint = "shiny.express.app" + entrypoint = "shiny.express.app:" + escape_to_var_name(entrypoint + ".py") extra_args = dict( directory=directory, diff --git a/rsconnect/shiny_express.py b/rsconnect/shiny_express.py index 2bc64f62..0d228cea 100644 --- a/rsconnect/shiny_express.py +++ b/rsconnect/shiny_express.py @@ -5,6 +5,7 @@ import ast from pathlib import Path +import re __all__ = ("is_express_app",) @@ -72,3 +73,27 @@ def visit_Module(self, node: ast.Module): # Don't recurse into any nodes, so the we'll only ever look at top-level nodes. def generic_visit(self, node: ast.AST): pass + + +def escape_to_var_name(x: str) -> str: + """ + Given a string, escape it to a valid Python variable name which contains + [a-zA-Z0-9_]. All other characters will be escaped to __. Also, if the first + character is a digit, it will be escaped to __, because Python variable names + can't begin with a digit. + """ + encoded = "" + is_first = True + + for char in x: + if is_first and re.match("[0-9]", char): + encoded += f"_{ord(char):x}_" + elif re.match("[a-zA-Z0-9]", char): + encoded += char + else: + encoded += f"_{ord(char):x}_" + + if is_first: + is_first = False + + return encoded