Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
49 changes: 43 additions & 6 deletions rsconnect/shiny_express.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",)

Expand Down Expand Up @@ -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)
Expand All @@ -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:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be worth adding a test for this function?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've added tests for Shiny Express app detection in general in cc3766e.

"""
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
Expand Down
52 changes: 52 additions & 0 deletions tests/test_shiny_express.py
Original file line number Diff line number Diff line change
@@ -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)