from __future__ import annotations import copy import importlib import importlib.util import inspect import os import platform import re import sys import types from pathlib import Path from typing import Any, Optional import click import uvicorn import uvicorn.config import shiny from . import __version__, _autoreload, _hostenv, _static, _utils from ._docstring import no_example from ._typing_extensions import NotRequired, TypedDict from .express import is_express_app from .express._utils import escape_to_var_name @click.group("main") @click.version_option(__version__) def main() -> None: pass stop_shortcut = "Ctrl+C" RELOAD_INCLUDES_DEFAULT = ( "*.py", "*.css", "*.scss", "*.js", "*.htm", "*.html", "*.png", "*.yml", "*.yaml", ) RELOAD_EXCLUDES_DEFAULT = (".*", "*.py[cod]", "__pycache__", "env", "venv") @main.command( help=f"""Run a Shiny app (press {stop_shortcut} to stop). The APP argument indicates the Python file (or module) and attribute where the shiny.App object can be found. For example, if the current directory contains a file called app.py, which contains an `app` variable that is the Shiny app, any of the following will work: \b * shiny run # app:app is assumed * shiny run app # :app is assumed * shiny run app.py # :app is assumed * shiny run app:app * shiny run app.py:app """ ) @click.argument("app", default="app.py:app") @click.option( "-h", "--host", type=str, default="127.0.0.1", help="Bind socket to this host.", show_default=True, ) @click.option( "-p", "--port", type=int, default=8000, help="Bind socket to this port. If 0, a random port will be used.", show_default=True, ) @click.option( "--autoreload-port", type=int, default=0, help="Bind autoreload socket to this port. If 0, a random port will be used. Ignored if --reload is not used.", show_default=True, ) @click.option( "-r", "--reload", is_flag=True, default=False, help="Enable auto-reload. See --reload-includes for types of files that are monitored for changes.", ) @click.option( "--reload-dir", "reload_dirs", multiple=True, help="Indicate a directory `--reload` should (recursively) monitor for changes, in " "addition to the app's parent directory. Can be used more than once.", type=click.Path(exists=True), ) @click.option( "--reload-includes", "reload_includes", default=",".join(RELOAD_INCLUDES_DEFAULT), help="File glob(s) to indicate which files should be monitored for changes. Defaults" f' to "{",".join(RELOAD_INCLUDES_DEFAULT)}".', ) @click.option( "--reload-excludes", "reload_excludes", default=",".join(RELOAD_EXCLUDES_DEFAULT), help="File glob(s) to indicate which files should be excluded from file monitoring. Defaults" f' to "{",".join(RELOAD_EXCLUDES_DEFAULT)}".', ) @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( "-d", "--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. If APP is a file path, this argument" " is ignored.", ) @click.option( "--factory", is_flag=True, default=False, help="Treat APP as an application factory, i.e. a () -> callable.", show_default=True, ) @click.option( "-b", "--launch-browser", is_flag=True, default=False, help="Launch app browser after app starts, using the Python webbrowser module.", show_default=True, ) @click.option( "--dev-mode/--no-dev-mode", is_flag=True, default=True, help="Dev mode", show_default=True, ) @no_example() def run( app: str | shiny.App, host: str, port: int, *, autoreload_port: int, reload: bool, reload_dirs: tuple[str, ...], reload_includes: str, reload_excludes: str, ws_max_size: int, log_level: str, app_dir: str, factory: bool, launch_browser: bool, dev_mode: bool, **kwargs: object, ) -> None: reload_includes_list = reload_includes.split(",") reload_excludes_list = reload_excludes.split(",") return run_app( app, host=host, port=port, autoreload_port=autoreload_port, reload=reload, reload_dirs=list(reload_dirs), reload_includes=reload_includes_list, reload_excludes=reload_excludes_list, ws_max_size=ws_max_size, log_level=log_level, app_dir=app_dir, factory=factory, launch_browser=launch_browser, dev_mode=dev_mode, **kwargs, ) def run_app( app: str | shiny.App = "app:app", host: str = "127.0.0.1", port: int = 8000, *, autoreload_port: int = 0, reload: bool = False, reload_dirs: Optional[list[str]] = None, reload_includes: list[str] | tuple[str, ...] = RELOAD_INCLUDES_DEFAULT, reload_excludes: list[str] | tuple[str, ...] = RELOAD_EXCLUDES_DEFAULT, ws_max_size: int = 16777216, log_level: Optional[str] = None, app_dir: Optional[str] = ".", factory: bool = False, launch_browser: bool = False, dev_mode: bool = True, **kwargs: object, ) -> None: """ Starts a Shiny app. Press ``Ctrl+C`` (or ``Ctrl+Break`` on Windows) to stop the app. Parameters ---------- app The app to run. The default, ``app:app``, represents the "usual" case where the application is named ``app`` inside a ``app.py`` file within the current working directory. In other cases, the app location can be specified as a ``:`` string where the ``:`` is only necessary if the application is named something other than ``app``. Note that ```` can be a relative path to a ``.py`` file or a directory (with an ``app.py`` file inside of it); and in this case, the relative path is resolved relative to the ``app_dir`` directory. host The address that the app should listen on. port The port that the app should listen on. Set to 0 to use a random port. autoreload_port The port that should be used for an additional websocket that is used to support hot-reload. Set to 0 to use a random port. reload Enable auto-reload. reload_dirs A list of directories (in addition to the app directory) to watch for changes that will trigger an app reload. reload_includes List or tuple of file globs to indicate which files should be monitored for changes. Can be combined with `reload_excludes`. reload_excludes List or tuple of file globs to indicate which files should be excluded from reload monitoring. Can be combined with `reload_includes` ws_max_size WebSocket max size message in bytes. log_level Log level. app_dir The directory to look for ``app`` under (by adding this to the ``PYTHONPATH``). factory Treat ``app`` as an application factory, i.e. a () -> callable. launch_browser Launch app browser after app starts, using the Python webbrowser module. **kwargs Additional keyword arguments which are passed to ``uvicorn.run``. For more information see [Uvicorn documentation](https://www.uvicorn.org/). Tip --- The ``shiny run`` command-line interface (which comes installed with Shiny) provides the same functionality as `shiny.run_app()`. Examples -------- ```{python} #|eval: false from shiny import run_app # Run ``app`` inside ``./app.py`` run_app() # Run ``app`` inside ``./myapp.py`` (or ``./myapp/app.py``) run_app("myapp") # Run ``my_app`` inside ``./myapp.py`` (or ``./myapp/app.py``) run_app("myapp:my_app") # Run ``my_app`` inside ``../myapp.py`` (or ``../myapp/app.py``) run_app("myapp:my_app", app_dir="..") ``` """ # If port is 0, randomize if port == 0: port = _utils.random_port(host=host) os.environ["SHINY_HOST"] = host os.environ["SHINY_PORT"] = str(port) if dev_mode: os.environ["SHINY_DEV_MODE"] = "1" if isinstance(app, str): # Remove ":app" suffix if present. Normally users would just pass in the # filename without the trailing ":app", as in `shiny run app.py`, but the # default value for `shiny run` is "app.py:app", so we need to handle it. app_no_suffix = re.sub(r":app$", "", app) if is_express_app(app_no_suffix, app_dir): app_path = Path(app_no_suffix).resolve() # If the file is "/path/to/app.py", our entrypoint with the escaped filename # is "shiny.express.app:_2f_path_2f_to_2f_app_2e_py". app = "shiny.express.app:" + escape_to_var_name(str(app_path)) app_dir = str(app_path.parent) # Express apps need min version of rsconnect-python to deploy correctly. _verify_rsconnect_version() else: app, app_dir = resolve_app(app, app_dir) if app_dir: app_dir = os.path.realpath(app_dir) log_config: dict[str, Any] = copy.deepcopy(uvicorn.config.LOGGING_CONFIG) if reload_dirs is None: reload_dirs = [] if app_dir is not None: reload_dirs = [app_dir] if reload: # Always watch the app_dir if app_dir and app_dir not in reload_dirs: reload_dirs.append(app_dir) # For developers of Shiny itself; autoreload the app when Shiny package changes if os.getenv("SHINY_PKG_AUTORELOAD"): shinypath = Path(inspect.getfile(shiny)).parent reload_dirs.append(str(shinypath)) if autoreload_port == 0: autoreload_port = _utils.random_port(host=host) if autoreload_port == port: print( "Autoreload port is already being used by the app; disabling autoreload\n", file=sys.stderr, ) reload = False else: setup_hot_reload(log_config, autoreload_port, port, launch_browser) reload_args: ReloadArgs = {} if reload: reload_args = { "reload": reload, # Adding `reload_includes` param while `reload=False` produces an warning # https://github.com/encode/uvicorn/blob/d43afed1cfa018a85c83094da8a2dd29f656d676/uvicorn/config.py#L298-L304 "reload_includes": list(reload_includes), "reload_excludes": list(reload_excludes), "reload_dirs": reload_dirs, } if launch_browser and not reload: setup_launch_browser(log_config) maybe_setup_rsw_proxying(log_config) uvicorn.run( # pyright: ignore[reportUnknownMemberType] app, host=host, port=port, ws_max_size=ws_max_size, log_level=log_level, log_config=log_config, app_dir=app_dir, factory=factory, lifespan="on", # Don't allow shiny to use uvloop! # https://github.com/posit-dev/py-shiny/issues/1373 loop="asyncio", **reload_args, # pyright: ignore[reportArgumentType] **kwargs, ) def setup_hot_reload( log_config: dict[str, Any], autoreload_port: int, app_port: int, launch_browser: bool, ) -> None: # The only way I've found to get notified when uvicorn decides to reload, is by # inserting a custom log handler. log_config["handlers"]["shiny_hot_reload"] = { "class": "shiny._autoreload.HotReloadHandler", "level": "INFO", } if "handlers" not in log_config["loggers"]["uvicorn.error"]: log_config["loggers"]["uvicorn.error"]["handlers"] = [] log_config["loggers"]["uvicorn.error"]["handlers"].append("shiny_hot_reload") _autoreload.start_server(autoreload_port, app_port, launch_browser) def setup_launch_browser(log_config: dict[str, Any]): log_config["handlers"]["shiny_launch_browser"] = { "class": "shiny._launchbrowser.LaunchBrowserHandler", "level": "INFO", } if "handlers" not in log_config["loggers"]["uvicorn.error"]: log_config["loggers"]["uvicorn.error"]["handlers"] = [] log_config["loggers"]["uvicorn.error"]["handlers"].append("shiny_launch_browser") def maybe_setup_rsw_proxying(log_config: dict[str, Any]) -> None: # Replace localhost URLs emitted to the log, with proxied URLs if _hostenv.is_workbench(): if "filters" not in log_config: log_config["filters"] = {} log_config["filters"]["rsw_proxy"] = {"()": "shiny._hostenv.ProxyUrlFilter"} if "filters" not in log_config["handlers"]["default"]: log_config["handlers"]["default"]["filters"] = [] log_config["handlers"]["default"]["filters"].append("rsw_proxy") def is_file(app: str) -> bool: return "/" in app or app.endswith(".py") def resolve_app(app: str, app_dir: str | None) -> tuple[str, str | None]: # 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 if platform.system() == "Windows" and re.match("^[a-zA-Z]:[/\\\\]", app): # On Windows, need special handling of ':' in some cases, like these: # shiny run c:/Users/username/Documents/myapp/app.py # shiny run c:\Users\username\Documents\myapp\app.py module, attr = app, "" else: module, _, attr = app.partition(":") if not module: raise ImportError("The APP parameter cannot start with ':'.") if not attr: attr = "app" if is_file(module): # Before checking module path, resolve it relative to app_dir if provided module_path = module if app_dir is None else os.path.join(app_dir, module) # TODO: We should probably be using some kind of loader # TODO: I don't like that we exit here, if we ever export this it would be bad; # but also printing a massive stack trace for a `shiny run badpath` is way # unfriendly. We should probably throw a custom error that the shiny run # entrypoint knows not to print the stack trace for. if not os.path.exists(module_path): print(f"Error: {module_path} not found\n", file=sys.stderr) sys.exit(1) if not os.path.isfile(module_path): print(f"Error: {module_path} is not a file\n", file=sys.stderr) sys.exit(1) dirname, filename = os.path.split(module_path) module = filename[:-3] if filename.endswith(".py") else filename app_dir = dirname return f"{module}:{attr}", app_dir def try_import_module(module: str) -> Optional[types.ModuleType]: try: if not importlib.util.find_spec(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 # It's important for ModuleNotFoundError and ImportError (and any other error) NOT # to be caught here, as we want to report the true error to the user. Otherwise, # missing dependencies can be misreported to the user as the app module itself not # being found. return importlib.import_module(module) @main.group(help="""Add files to enhance your Shiny app.""") def add() -> None: pass @add.command( help="""Add a test file for a specified Shiny app. Add an empty test file for a specified app. You will be prompted with a destination folder. If you don't provide a destination folder, it will be added in the current working directory based on the app name. After creating the shiny app file, you can use `pytest` to run the tests: pytest TEST_FILE """ ) @click.option( "--app", "-a", type=str, help="Please provide the path to the app file for which you want to create a test file.", ) @click.option( "--test-file", "-t", type=str, help="Please provide the name of the test file you want to create. The basename of the test file should start with `test_` and be unique across all test files.", ) # Param for app.py, param for test_name def test( app: Path | None, test_file: Path | None, ) -> None: from ._main_add_test import add_test_file add_test_file(app_file=app, test_file=test_file) @main.command( help="""Create a Shiny application from a template. Create an app based on a template. You will be prompted with a number of application types, as well as the destination folder. If you don't provide a destination folder, it will be created in the current working directory based on the template name. After creating the application, you use `shiny run`: shiny run APPDIR/app.py --reload """ ) @click.option( "--template", "-t", type=click.STRING, help="Choose a template for your new application.", ) @click.option( "--mode", "-m", type=click.Choice( ["core", "express"], case_sensitive=False, ), help="Do you want to use a Shiny Express template or a Shiny Core template?", ) @click.option( "--github", "-g", help=""" The GitHub repo containing the template, e.g. 'posit-dev/py-shiny-templates'. Can be in the format '{repo_owner}/{repo_name}', '{repo_owner}/{repo_name}@{ref}', or '{repo_owner}/{repo_name}:{path}@{ref}'. Alternatively, a GitHub URL of the template sub-directory, e.g 'https://github.com/posit-dev/py-shiny-templates/tree/main/dashboard'. """, ) @click.option( "--dir", "-d", type=str, help="The destination directory, you will be prompted if this is not provided.", ) @click.option( "--package-name", help=""" If you are using one of the JavaScript component templates, you can use this flag to specify the name of the resulting package without being prompted. """, ) def create( template: Optional[str] = None, mode: Optional[str] = None, github: Optional[str] = None, dir: Optional[Path | str] = None, package_name: Optional[str] = None, ) -> None: from ._main_create import use_github_template, use_internal_template if dir is not None: dir = Path(dir) if github is not None: use_github_template( github, template_name=template, mode=mode, dest_dir=dir, package_name=package_name, ) else: use_internal_template(template, mode, dir, package_name) @main.command( help="""The functionality from `shiny static` has been moved to the shinylive package. Please install shinylive and use `shinylive export` instead of `shiny static`: \b shiny static-assets remove pip install shinylive shinylive export APPDIR DESTDIR """, context_settings=dict( ignore_unknown_options=True, allow_extra_args=True, ), ) def static() -> None: print( """The functionality from `shiny static` has been moved to the shinylive package. Please install shinylive and use `shinylive export` instead of `shiny static`: shiny static-assets remove pip install shinylive shinylive export APPDIR DESTDIR """ ) sys.exit(1) @main.command( no_args_is_help=True, help="""Manage local copy of assets for static app deployment. (Deprecated) \b Commands: remove: Remove local copies of assets. info: Print information about the local assets. """, ) @click.argument("command", type=str) def static_assets(command: str) -> None: dir = _static.get_default_shinylive_dir() if command == "remove": print(f"Removing {dir}") _static.remove_shinylive_local(shinylive_dir=dir) elif command == "info": _static.print_shinylive_local_info() else: raise click.UsageError(f"Unknown command: {command}") @main.command(help="""Convert a JSON file with code cells to a py file.""") @click.argument( "json_file", type=str, ) @click.argument( "py_file", type=str, ) def cells_to_app(json_file: str, py_file: str) -> None: shiny.quarto.convert_code_cells_to_app_py(json_file, py_file) @main.command(help="""Get Shiny's HTML dependencies as JSON.""") def get_shiny_deps() -> None: print(shiny.quarto.get_shiny_deps()) class ReloadArgs(TypedDict): reload: NotRequired[bool] reload_includes: NotRequired[list[str]] reload_excludes: NotRequired[list[str]] reload_dirs: NotRequired[list[str]] # Check that the version of rsconnect supports Shiny Express; can be removed in the # future once this version of rsconnect is widely used. The dependency on "packaging" # can also be removed then, because it is only used here. (Added 2024-03) def _verify_rsconnect_version() -> None: PACKAGE_NAME = "rsconnect-python" MIN_VERSION = "1.22.0" from importlib.metadata import PackageNotFoundError, version from packaging.version import parse try: installed_version = parse(version(PACKAGE_NAME)) required_version = parse(MIN_VERSION) if installed_version < required_version: print( f"Warning: rsconnect-python {installed_version} is installed, but it does not support deploying Shiny Express applications. " f"Please upgrade to at least version {MIN_VERSION}. " "If you are using pip, you can run `pip install --upgrade rsconnect-python`", file=sys.stderr, ) except PackageNotFoundError: pass