From 81b5e6614c17c0736162a2ff351975381d797ee4 Mon Sep 17 00:00:00 2001 From: Joe Cheng Date: Thu, 3 Feb 2022 16:17:10 -0800 Subject: [PATCH 1/4] Implement `shiny run` You can also do: python3 -m shiny [OPTIONS] [APP] --- setup.cfg | 4 ++ shiny/__main__.py | 4 ++ shiny/main.py | 172 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 180 insertions(+) create mode 100644 shiny/__main__.py create mode 100644 shiny/main.py diff --git a/setup.cfg b/setup.cfg index d5ebf0475..ef8e83b9c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -17,3 +17,7 @@ universal = 1 [flake8] ignore = E203, E302, E402, E501, F403, F405, W503 exclude = docs + +[options.entry_points] +console_scripts = + shiny = shiny.main:main diff --git a/shiny/__main__.py b/shiny/__main__.py new file mode 100644 index 000000000..40e2b013f --- /dev/null +++ b/shiny/__main__.py @@ -0,0 +1,4 @@ +from .main import main + +if __name__ == "__main__": + main() diff --git a/shiny/main.py b/shiny/main.py new file mode 100644 index 000000000..5af948720 --- /dev/null +++ b/shiny/main.py @@ -0,0 +1,172 @@ +import importlib +import importlib.util +import os +import sys +import types +import click +import typing + +import uvicorn +import uvicorn.config + +import shiny + +__all__ = ["main", "run"] + + +@click.group() +def main() -> None: + pass + + +@main.command() +@click.argument("app", default="app:app") +@click.option( + "--host", + type=str, + default="127.0.0.1", + help="Bind socket to this host.", + show_default=True, +) +@click.option( + "--port", + type=int, + default=8000, + help="Bind socket to this port.", + show_default=True, +) +@click.option( + "--debug", is_flag=True, default=False, help="Enable debug mode.", hidden=True +) +@click.option("--reload", is_flag=True, default=False, help="Enable auto-reload.") +@click.option( + "--ws-max-size", + type=int, + default=16777216, + help="WebSocket max size message in bytes", + show_default=True, +) +@click.option( + "--log-level", + type=click.Choice(list(uvicorn.config.LOG_LEVELS.keys())), + default=None, + help="Log level. [default: info]", + show_default=True, +) +@click.option( + "--app-dir", + default=".", + show_default=True, + help="Look for APP in the specified directory, by adding this to the PYTHONPATH." + " Defaults to the current working directory.", +) +def run( + app: typing.Union[str, shiny.ShinyApp], + host: str, + port: int, + debug: bool, + reload: bool, + ws_max_size: int, + log_level: str, + app_dir: str, +) -> None: + """Starts a Shiny app. Press Ctrl+C (or Ctrl+Break on Windows) to stop. + + The APP argument indicates where the Shiny app should be loaded from. You have + several options for specifying this: + + \b + - No APP argument; `shiny run` will look for app.py in the current directory. + - A module name to load. It should have an `app` attribute. + - A ":" string. Useful when you named your Shiny app + something other than `app`, or if there are multiple apps in a single + module. + - A relative path to a Python file. + - A relative path to a Python directory (it must contain an app.py file). + - A ":" string. + + \b + Examples + ======== + shiny run + shiny run mypackage.mymodule + shiny run mypackage.mymodule:app + shiny run mydir + shiny run mydir/myapp.py + shiny run mydir/myapp.py:app + """ + + if isinstance(app, str): + app = resolve_app(app, app_dir) + + uvicorn.run( + app, # type: ignore + host=host, + port=port, + debug=debug, + reload=reload, + ws_max_size=ws_max_size, + log_level=log_level, + # DON'T pass app_dir, as uvicorn.run didn't support it until recently + # app_dir=app_dir, + ) + + +def resolve_app(app: str, app_dir: typing.Optional[str]) -> typing.Any: + # The `app` parameter can be: + # + # - A module:attribute name + # - An absolute or relative path to a: + # - .py file (look for app inside of it) + # - directory (look for app:app inside of it) + # - A module name (look for :app) inside of it + + module, _, attr = app.partition(":") + if not module: + raise ImportError("The APP parameter cannot start with ':'.") + if not attr: + attr = "app" + + if app_dir is not None: + sys.path.insert(0, app_dir) + + instance = try_import_module(module) + if not instance: + # It must be a path + path = os.path.normpath(module) + if path.startswith("../") or path.startswith("..\\"): + raise ImportError( + "The APP parameter cannot refer to a parent directory ('..'). " + "Either change the working directory to a parent of the app, " + "or use the --app-dir option to specify a different starting " + "directory to search from." + ) + fullpath = os.path.normpath(os.path.join(app_dir or ".", module)) + if not os.path.exists(fullpath): + raise ImportError(f"Could not find the module or path '{module}'") + if os.path.isdir(fullpath): + path = os.path.join(path, "app.py") + fullpath = os.path.join(fullpath, "app.py") + if not os.path.exists(fullpath): + raise ImportError( + f"The directory '{fullpath}' did not include an app.py file" + ) + module = path.removesuffix(".py").replace("/", ".").replace("\\", ".") + instance = try_import_module(module) + if not instance: + raise ImportError(f"Could not find the module '{module}'") + + return getattr(instance, attr) + + +def try_import_module(module: str) -> typing.Optional[types.ModuleType]: + try: + if importlib.util.find_spec(module): + return importlib.import_module(module) + return None + except ModuleNotFoundError: + # find_spec throws this when the module contains both '/' and '.' characters + return None + except ImportError: + # find_spec throws this when the module starts with "." + return None From 2b4408007aa15186c7401c2820829d69830d9d3b Mon Sep 17 00:00:00 2001 From: Joe Cheng Date: Thu, 3 Feb 2022 16:18:35 -0800 Subject: [PATCH 2/4] Add click to requirements --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index 8cb8dde03..52337dfd7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,3 +6,4 @@ websockets==10.0 typing_extensions==4.0.1 python-multipart htmltools +click==8.0.3 From d515a9855f2350e9fd3b715617a903f6e5b0160a Mon Sep 17 00:00:00 2001 From: Joe Cheng Date: Fri, 4 Feb 2022 08:54:20 -0800 Subject: [PATCH 3/4] Fix error reported by @cpsievert You must pass the application as an import string to enable 'reload' or 'workers'. --- shiny/main.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/shiny/main.py b/shiny/main.py index 5af948720..381e5ee4b 100644 --- a/shiny/main.py +++ b/shiny/main.py @@ -61,7 +61,7 @@ def main() -> None: " Defaults to the current working directory.", ) def run( - app: typing.Union[str, shiny.ShinyApp], + app: typing.Union[str, shiny.App], host: str, port: int, debug: bool, @@ -112,7 +112,7 @@ def run( ) -def resolve_app(app: str, app_dir: typing.Optional[str]) -> typing.Any: +def resolve_app(app: str, app_dir: typing.Optional[str]) -> str: # The `app` parameter can be: # # - A module:attribute name @@ -156,7 +156,7 @@ def resolve_app(app: str, app_dir: typing.Optional[str]) -> typing.Any: if not instance: raise ImportError(f"Could not find the module '{module}'") - return getattr(instance, attr) + return f"{module}:{attr}" def try_import_module(module: str) -> typing.Optional[types.ModuleType]: From 4db20f678e606cd38e1cecc95a05f25eb46052cf Mon Sep 17 00:00:00 2001 From: Joe Cheng Date: Fri, 4 Feb 2022 10:46:58 -0800 Subject: [PATCH 4/4] Update comment --- shiny/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shiny/main.py b/shiny/main.py index 381e5ee4b..49d5f96f2 100644 --- a/shiny/main.py +++ b/shiny/main.py @@ -107,7 +107,7 @@ def run( reload=reload, ws_max_size=ws_max_size, log_level=log_level, - # DON'T pass app_dir, as uvicorn.run didn't support it until recently + # DON'T pass app_dir, we've already handled it ourselves # app_dir=app_dir, )