Skip to content

Commit f25d839

Browse files
authored
Merge pull request #59 from rstudio/shiny-command
Add `shiny` console script
2 parents d344306 + 4db20f6 commit f25d839

File tree

4 files changed

+181
-0
lines changed

4 files changed

+181
-0
lines changed

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@ websockets==10.0
66
typing_extensions==4.0.1
77
python-multipart
88
htmltools
9+
click==8.0.3

setup.cfg

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,7 @@ universal = 1
1717
[flake8]
1818
ignore = E203, E302, E402, E501, F403, F405, W503
1919
exclude = docs
20+
21+
[options.entry_points]
22+
console_scripts =
23+
shiny = shiny.main:main

shiny/__main__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
from .main import main
2+
3+
if __name__ == "__main__":
4+
main()

shiny/main.py

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import importlib
2+
import importlib.util
3+
import os
4+
import sys
5+
import types
6+
import click
7+
import typing
8+
9+
import uvicorn
10+
import uvicorn.config
11+
12+
import shiny
13+
14+
__all__ = ["main", "run"]
15+
16+
17+
@click.group()
18+
def main() -> None:
19+
pass
20+
21+
22+
@main.command()
23+
@click.argument("app", default="app:app")
24+
@click.option(
25+
"--host",
26+
type=str,
27+
default="127.0.0.1",
28+
help="Bind socket to this host.",
29+
show_default=True,
30+
)
31+
@click.option(
32+
"--port",
33+
type=int,
34+
default=8000,
35+
help="Bind socket to this port.",
36+
show_default=True,
37+
)
38+
@click.option(
39+
"--debug", is_flag=True, default=False, help="Enable debug mode.", hidden=True
40+
)
41+
@click.option("--reload", is_flag=True, default=False, help="Enable auto-reload.")
42+
@click.option(
43+
"--ws-max-size",
44+
type=int,
45+
default=16777216,
46+
help="WebSocket max size message in bytes",
47+
show_default=True,
48+
)
49+
@click.option(
50+
"--log-level",
51+
type=click.Choice(list(uvicorn.config.LOG_LEVELS.keys())),
52+
default=None,
53+
help="Log level. [default: info]",
54+
show_default=True,
55+
)
56+
@click.option(
57+
"--app-dir",
58+
default=".",
59+
show_default=True,
60+
help="Look for APP in the specified directory, by adding this to the PYTHONPATH."
61+
" Defaults to the current working directory.",
62+
)
63+
def run(
64+
app: typing.Union[str, shiny.App],
65+
host: str,
66+
port: int,
67+
debug: bool,
68+
reload: bool,
69+
ws_max_size: int,
70+
log_level: str,
71+
app_dir: str,
72+
) -> None:
73+
"""Starts a Shiny app. Press Ctrl+C (or Ctrl+Break on Windows) to stop.
74+
75+
The APP argument indicates where the Shiny app should be loaded from. You have
76+
several options for specifying this:
77+
78+
\b
79+
- No APP argument; `shiny run` will look for app.py in the current directory.
80+
- A module name to load. It should have an `app` attribute.
81+
- A "<module>:<attribute>" string. Useful when you named your Shiny app
82+
something other than `app`, or if there are multiple apps in a single
83+
module.
84+
- A relative path to a Python file.
85+
- A relative path to a Python directory (it must contain an app.py file).
86+
- A "<path-to-file-or-dir>:<attribute>" string.
87+
88+
\b
89+
Examples
90+
========
91+
shiny run
92+
shiny run mypackage.mymodule
93+
shiny run mypackage.mymodule:app
94+
shiny run mydir
95+
shiny run mydir/myapp.py
96+
shiny run mydir/myapp.py:app
97+
"""
98+
99+
if isinstance(app, str):
100+
app = resolve_app(app, app_dir)
101+
102+
uvicorn.run(
103+
app, # type: ignore
104+
host=host,
105+
port=port,
106+
debug=debug,
107+
reload=reload,
108+
ws_max_size=ws_max_size,
109+
log_level=log_level,
110+
# DON'T pass app_dir, we've already handled it ourselves
111+
# app_dir=app_dir,
112+
)
113+
114+
115+
def resolve_app(app: str, app_dir: typing.Optional[str]) -> str:
116+
# The `app` parameter can be:
117+
#
118+
# - A module:attribute name
119+
# - An absolute or relative path to a:
120+
# - .py file (look for app inside of it)
121+
# - directory (look for app:app inside of it)
122+
# - A module name (look for :app) inside of it
123+
124+
module, _, attr = app.partition(":")
125+
if not module:
126+
raise ImportError("The APP parameter cannot start with ':'.")
127+
if not attr:
128+
attr = "app"
129+
130+
if app_dir is not None:
131+
sys.path.insert(0, app_dir)
132+
133+
instance = try_import_module(module)
134+
if not instance:
135+
# It must be a path
136+
path = os.path.normpath(module)
137+
if path.startswith("../") or path.startswith("..\\"):
138+
raise ImportError(
139+
"The APP parameter cannot refer to a parent directory ('..'). "
140+
"Either change the working directory to a parent of the app, "
141+
"or use the --app-dir option to specify a different starting "
142+
"directory to search from."
143+
)
144+
fullpath = os.path.normpath(os.path.join(app_dir or ".", module))
145+
if not os.path.exists(fullpath):
146+
raise ImportError(f"Could not find the module or path '{module}'")
147+
if os.path.isdir(fullpath):
148+
path = os.path.join(path, "app.py")
149+
fullpath = os.path.join(fullpath, "app.py")
150+
if not os.path.exists(fullpath):
151+
raise ImportError(
152+
f"The directory '{fullpath}' did not include an app.py file"
153+
)
154+
module = path.removesuffix(".py").replace("/", ".").replace("\\", ".")
155+
instance = try_import_module(module)
156+
if not instance:
157+
raise ImportError(f"Could not find the module '{module}'")
158+
159+
return f"{module}:{attr}"
160+
161+
162+
def try_import_module(module: str) -> typing.Optional[types.ModuleType]:
163+
try:
164+
if importlib.util.find_spec(module):
165+
return importlib.import_module(module)
166+
return None
167+
except ModuleNotFoundError:
168+
# find_spec throws this when the module contains both '/' and '.' characters
169+
return None
170+
except ImportError:
171+
# find_spec throws this when the module starts with "."
172+
return None

0 commit comments

Comments
 (0)