Skip to content

Commit 164ec4c

Browse files
authored
Merge pull request #549 from rstudio/shiny-is-express
Add support for shiny mode magic comment
2 parents 1892c80 + 3126880 commit 164ec4c

File tree

4 files changed

+99
-6
lines changed

4 files changed

+99
-6
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
66

77
## Pending Next Release
88

9+
### Added
10+
- Added support for deploying interactive Quarto dashboards that use Shiny Express syntax.
11+
912
### Changed
1013

1114
- When deploying Shiny for Python applications on servers using a version of

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ readme = { file = "README.md", content-type = "text/markdown" }
88
requires-python = ">=3.8"
99

1010
dependencies = [
11+
"typing-extensions>=4.10.0",
1112
"six>=1.14.0",
1213
"pip>=10.0.0",
1314
"semver>=2.0.0,<3.0.0",

rsconnect/shiny_express.py

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@
44
from __future__ import annotations
55

66
import ast
7-
from pathlib import Path
87
import re
8+
import sys
9+
from pathlib import Path
10+
from typing import Literal, cast
911

1012
__all__ = ("is_express_app",)
1113

@@ -39,8 +41,16 @@ def is_express_app(app: str, app_dir: str | None) -> bool:
3941

4042
try:
4143
# Read the file, parse it, and look for any imports of shiny.express.
42-
with open(app_path) as f:
44+
with open(app_path, encoding="utf-8") as f:
4345
content = f.read()
46+
47+
# Check for magic comment in the first 1000 characters
48+
forced_mode = find_magic_comment_mode(content[:1000])
49+
if forced_mode == "express":
50+
return True
51+
elif forced_mode == "core":
52+
return False
53+
4454
tree = ast.parse(content, app_path)
4555
detector = DetectShinyExpressVisitor()
4656
detector.visit(tree)
@@ -56,25 +66,52 @@ def __init__(self):
5666
super().__init__()
5767
self.found_shiny_express_import = False
5868

59-
def visit_Import(self, node: ast.Import):
69+
def visit_Import(self, node: ast.Import) -> None:
6070
if any(alias.name == "shiny.express" for alias in node.names):
6171
self.found_shiny_express_import = True
6272

63-
def visit_ImportFrom(self, node: ast.ImportFrom):
73+
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
6474
if node.module == "shiny.express":
6575
self.found_shiny_express_import = True
6676
elif node.module == "shiny" and any(alias.name == "express" for alias in node.names):
6777
self.found_shiny_express_import = True
6878

6979
# Visit top-level nodes.
70-
def visit_Module(self, node: ast.Module):
80+
def visit_Module(self, node: ast.Module) -> None:
7181
super().generic_visit(node)
7282

7383
# Don't recurse into any nodes, so the we'll only ever look at top-level nodes.
74-
def generic_visit(self, node: ast.AST):
84+
def generic_visit(self, node: ast.AST) -> None:
7585
pass
7686

7787

88+
def find_magic_comment_mode(content: str) -> Literal["core", "express"] | None:
89+
"""
90+
Look for a magic comment of the form "# shiny_mode: express" or "# shiny_mode:
91+
core".
92+
93+
If a line of the form "# shiny_mode: x" is found, where "x" is not "express" or
94+
"core", then a message will be printed to stderr.
95+
96+
Returns
97+
-------
98+
:
99+
`"express"` if Shiny Express comment is found, `"core"` if Shiny Core comment is
100+
found, and `None` if no magic comment is found.
101+
"""
102+
m = re.search(r"^#[ \t]*shiny_mode:[ \t]*(\S*)[ \t]*$", content, re.MULTILINE)
103+
if m is not None:
104+
shiny_mode = cast(str, m.group(1))
105+
if shiny_mode in ("express", "core"):
106+
# The "type: ignore" is needed for mypy, which is used on some projects that
107+
# use duplicates of this code.
108+
return shiny_mode # type: ignore
109+
else:
110+
print(f'Invalid shiny_mode: "{shiny_mode}"', file=sys.stderr)
111+
112+
return None
113+
114+
78115
def escape_to_var_name(x: str) -> str:
79116
"""
80117
Given a string, escape it to a valid Python variable name which contains

tests/test_shiny_express.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
from pathlib import Path
2+
3+
from rsconnect import shiny_express as express
4+
5+
def test_is_express_app(tmp_path: Path):
6+
tmp_file = str(tmp_path / "app.py")
7+
8+
def write_tmp_file(s: str):
9+
with open(tmp_file, "w") as f:
10+
f.write(s)
11+
12+
write_tmp_file("import shiny.express")
13+
assert express.is_express_app(tmp_file, None)
14+
# Check that it works when passing in app_path
15+
assert express.is_express_app("app.py", str(tmp_path))
16+
17+
write_tmp_file("# comment\nimport sys\n\nimport shiny.express")
18+
assert express.is_express_app(tmp_file, None)
19+
20+
write_tmp_file("import sys\n\nfrom shiny import App, express")
21+
assert express.is_express_app(tmp_file, None)
22+
23+
write_tmp_file("import sys\n\nfrom shiny.express import layout, input")
24+
assert express.is_express_app(tmp_file, None)
25+
26+
# Shouldn't find in comment
27+
write_tmp_file("# import shiny.express")
28+
assert not express.is_express_app(tmp_file, None)
29+
30+
# Shouldn't find in a string, even if it looks like an import
31+
write_tmp_file('"""\nimport shiny.express\n"""')
32+
assert not express.is_express_app(tmp_file, None)
33+
34+
# Shouldn't recurse into with, if, for, def, etc.
35+
write_tmp_file("with f:\n from shiny import express")
36+
assert not express.is_express_app(tmp_file, None)
37+
38+
write_tmp_file("if True:\n import shiny.express")
39+
assert not express.is_express_app(tmp_file, None)
40+
41+
write_tmp_file("for i in range(2):\n import shiny.express")
42+
assert not express.is_express_app(tmp_file, None)
43+
44+
write_tmp_file("def f():\n import shiny.express")
45+
assert not express.is_express_app(tmp_file, None)
46+
47+
# Look for magic comment - should override import detection
48+
write_tmp_file("\n#shiny_mode: core\nfrom shiny.express import ui")
49+
assert not express.is_express_app(tmp_file, None)
50+
51+
write_tmp_file("#shiny_mode: express\nfrom shiny import ui")
52+
assert express.is_express_app(tmp_file, None)

0 commit comments

Comments
 (0)