diff --git a/CHANGELOG.md b/CHANGELOG.md index 1462d29d..5d859cb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Pending Next Release +### Added +- Added support for deploying interactive Quarto dashboards that use Shiny Express syntax. + ### Changed - When deploying Shiny for Python applications on servers using a version of diff --git a/pyproject.toml b/pyproject.toml index 1839b4e8..8ef64bf1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,7 @@ readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.8" dependencies = [ + "typing-extensions>=4.10.0", "six>=1.14.0", "pip>=10.0.0", "semver>=2.0.0,<3.0.0", diff --git a/rsconnect/shiny_express.py b/rsconnect/shiny_express.py index 0d228cea..5144156c 100644 --- a/rsconnect/shiny_express.py +++ b/rsconnect/shiny_express.py @@ -4,8 +4,10 @@ from __future__ import annotations import ast -from pathlib import Path import re +import sys +from pathlib import Path +from typing import Literal, cast __all__ = ("is_express_app",) @@ -39,8 +41,16 @@ def is_express_app(app: str, app_dir: str | None) -> bool: try: # Read the file, parse it, and look for any imports of shiny.express. - with open(app_path) as f: + with open(app_path, encoding="utf-8") as f: content = f.read() + + # Check for magic comment in the first 1000 characters + forced_mode = find_magic_comment_mode(content[:1000]) + if forced_mode == "express": + return True + elif forced_mode == "core": + return False + tree = ast.parse(content, app_path) detector = DetectShinyExpressVisitor() detector.visit(tree) @@ -56,25 +66,52 @@ def __init__(self): super().__init__() self.found_shiny_express_import = False - def visit_Import(self, node: ast.Import): + def visit_Import(self, node: ast.Import) -> None: if any(alias.name == "shiny.express" for alias in node.names): self.found_shiny_express_import = True - def visit_ImportFrom(self, node: ast.ImportFrom): + def visit_ImportFrom(self, node: ast.ImportFrom) -> None: 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): + def visit_Module(self, node: ast.Module) -> None: 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): + def generic_visit(self, node: ast.AST) -> None: pass +def find_magic_comment_mode(content: str) -> Literal["core", "express"] | None: + """ + Look for a magic comment of the form "# shiny_mode: express" or "# shiny_mode: + core". + + If a line of the form "# shiny_mode: x" is found, where "x" is not "express" or + "core", then a message will be printed to stderr. + + Returns + ------- + : + `"express"` if Shiny Express comment is found, `"core"` if Shiny Core comment is + found, and `None` if no magic comment is found. + """ + m = re.search(r"^#[ \t]*shiny_mode:[ \t]*(\S*)[ \t]*$", content, re.MULTILINE) + if m is not None: + shiny_mode = cast(str, m.group(1)) + if shiny_mode in ("express", "core"): + # The "type: ignore" is needed for mypy, which is used on some projects that + # use duplicates of this code. + return shiny_mode # type: ignore + else: + print(f'Invalid shiny_mode: "{shiny_mode}"', file=sys.stderr) + + return None + + def escape_to_var_name(x: str) -> str: """ Given a string, escape it to a valid Python variable name which contains diff --git a/tests/test_shiny_express.py b/tests/test_shiny_express.py new file mode 100644 index 00000000..7f7f85d2 --- /dev/null +++ b/tests/test_shiny_express.py @@ -0,0 +1,52 @@ +from pathlib import Path + +from rsconnect import shiny_express as express + +def test_is_express_app(tmp_path: Path): + tmp_file = str(tmp_path / "app.py") + + def write_tmp_file(s: str): + with open(tmp_file, "w") as f: + f.write(s) + + write_tmp_file("import shiny.express") + assert express.is_express_app(tmp_file, None) + # Check that it works when passing in app_path + assert express.is_express_app("app.py", str(tmp_path)) + + write_tmp_file("# comment\nimport sys\n\nimport shiny.express") + assert express.is_express_app(tmp_file, None) + + write_tmp_file("import sys\n\nfrom shiny import App, express") + assert express.is_express_app(tmp_file, None) + + write_tmp_file("import sys\n\nfrom shiny.express import layout, input") + assert express.is_express_app(tmp_file, None) + + # Shouldn't find in comment + write_tmp_file("# import shiny.express") + assert not express.is_express_app(tmp_file, None) + + # Shouldn't find in a string, even if it looks like an import + write_tmp_file('"""\nimport shiny.express\n"""') + assert not express.is_express_app(tmp_file, None) + + # Shouldn't recurse into with, if, for, def, etc. + write_tmp_file("with f:\n from shiny import express") + assert not express.is_express_app(tmp_file, None) + + write_tmp_file("if True:\n import shiny.express") + assert not express.is_express_app(tmp_file, None) + + write_tmp_file("for i in range(2):\n import shiny.express") + assert not express.is_express_app(tmp_file, None) + + write_tmp_file("def f():\n import shiny.express") + assert not express.is_express_app(tmp_file, None) + + # Look for magic comment - should override import detection + write_tmp_file("\n#shiny_mode: core\nfrom shiny.express import ui") + assert not express.is_express_app(tmp_file, None) + + write_tmp_file("#shiny_mode: express\nfrom shiny import ui") + assert express.is_express_app(tmp_file, None)