diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 68965f2118..b7a286d921 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -17,6 +17,7 @@ jobs: # This output will be 'true' if files in the 'table_related_paths' list changed, 'false' otherwise. table_paths_changed: ${{ steps.filter.outputs.table_related_paths }} background_cb_changed: ${{ steps.filter.outputs.background_paths }} + cli_changed: ${{ steps.filter.outputs.cli_paths }} steps: - name: Checkout repository uses: actions/checkout@v4 @@ -35,6 +36,11 @@ jobs: - 'dash/_callback.py' - 'dash/_callback_context.py' - 'requirements/**' + cli_paths: + - 'dash/_cli.py' + - 'dash/__main__.py' + - 'dash/dash.py' + - 'tests/tooling/test_cli.py' build: name: Build Dash Package @@ -135,6 +141,44 @@ jobs: run: | cd tests pytest compliance/test_typing.py + + test-run-cli: + name: Test plotly run CLI + runs-on: ubuntu-latest + needs: [build, changes_filter] + if: | + (github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/dev')) || + needs.changes_filter.outputs.cli_changed == 'true' + timeout-minutes: 30 + strategy: + fail-fast: false + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: '3.12' + cache: 'pip' + cache-dependency-path: requirements/*.txt + + - name: Download built Dash packages + uses: actions/download-artifact@v4 + with: + name: dash-packages + path: packages/ + + - name: Install Dash packages + run: | + python -m pip install --upgrade pip wheel + python -m pip install "setuptools<80.0.0" + find packages -name dash-*.whl -print -exec sh -c 'pip install "{}[ci,testing]"' \; + + - name: Run CLI tests + run: | + pytest tests/tooling/test_cli.py background-callbacks: name: Run Background Callback Tests (Python ${{ matrix.python-version }}) diff --git a/dash/__main__.py b/dash/__main__.py new file mode 100644 index 0000000000..37e8353139 --- /dev/null +++ b/dash/__main__.py @@ -0,0 +1,3 @@ +from ._cli import cli + +cli() diff --git a/dash/_cli.py b/dash/_cli.py new file mode 100644 index 0000000000..c8b4eee92e --- /dev/null +++ b/dash/_cli.py @@ -0,0 +1,172 @@ +import argparse +import importlib +import sys +from typing import Any, Dict + +from dash import Dash + + +def load_app(app_path: str) -> Dash: + """ + Load a Dash app instance from a string like "module:variable". + + :param app_path: The import path to the Dash app instance. + :return: The loaded Dash app instance. + """ + app_split = app_path.split(":") + module_str = app_split[0] + + if not module_str: + raise ValueError(f"Invalid app path: '{app_path}'. ") + + try: + module = importlib.import_module(module_str) + except ImportError as e: + raise ImportError(f"Could not import module '{module_str}'.") from e + + app_instance = None + if len(app_split) == 2: + app_str = app_split[1] + try: + app_instance = getattr(module, app_str) + except AttributeError as e: + raise AttributeError( + f"Could not find variable '{app_str}' in module '{module_str}'." + ) from e + else: + for module_var in vars(module).values(): + if isinstance(module_var, Dash): + app_instance = module_var + break + + if not isinstance(app_instance, Dash): + raise TypeError(f"'{app_path}' did not resolve to a Dash app instance.") + + return app_instance + + +def create_parser() -> argparse.ArgumentParser: + """Create the argument parser for the Plotly CLI.""" + parser = argparse.ArgumentParser( + description="A command line interface for Plotly Dash." + ) + subparsers = parser.add_subparsers(dest="command", required=True) + + # --- `run` command --- + run_parser = subparsers.add_parser( + "run", + help="Run a Dash app.", + description="Run a local development server for a Dash app.", + ) + + run_parser.add_argument( + "app", + help='The Dash app to run, in the format "module:variable" ' + 'or just "module" to find the app instance automatically. (eg: plotly run app)', + ) + + # Server options + run_parser.add_argument( + "--host", + type=str, + help='Host IP used to serve the application (Default: "127.0.0.1").', + ) + run_parser.add_argument( + "--port", + "-p", + type=int, + help='Port used to serve the application (Default: "8050").', + ) + run_parser.add_argument( + "--proxy", + type=str, + help='Proxy configuration string, e.g., "http://0.0.0.0:8050::https://my.domain.com".', + ) + + run_parser.add_argument( + "--debug", + "-d", + action="store_true", + help="Enable/disable Flask debug mode and dev tools.", + ) + + # Dev Tools options + dev_tools_group = run_parser.add_argument_group("dev tools options") + dev_tools_group.add_argument( + "--dev-tools-ui", + action="store_true", + help="Enable/disable the dev tools UI.", + ) + dev_tools_group.add_argument( + "--dev-tools-props-check", + action="store_true", + help="Enable/disable component prop validation.", + ) + dev_tools_group.add_argument( + "--dev-tools-serve-dev-bundles", + action="store_true", + help="Enable/disable serving of dev bundles.", + ) + dev_tools_group.add_argument( + "--dev-tools-hot-reload", + action="store_true", + help="Enable/disable hot reloading.", + ) + dev_tools_group.add_argument( + "--dev-tools-hot-reload-interval", + type=float, + help="Interval in seconds for hot reload polling (Default: 3).", + ) + dev_tools_group.add_argument( + "--dev-tools-hot-reload-watch-interval", + type=float, + help="Interval in seconds for server-side file watch polling (Default: 0.5).", + ) + dev_tools_group.add_argument( + "--dev-tools-hot-reload-max-retry", + type=int, + help="Max number of failed hot reload requests before failing (Default: 8).", + ) + dev_tools_group.add_argument( + "--dev-tools-silence-routes-logging", + action="store_true", + help="Enable/disable silencing of Werkzeug's route logging.", + ) + dev_tools_group.add_argument( + "--dev-tools-disable-version-check", + action="store_true", + help="Enable/disable the Dash version upgrade check.", + ) + dev_tools_group.add_argument( + "--dev-tools-prune-errors", + action="store_true", + help="Enable/disable pruning of tracebacks to user code only.", + ) + + return parser + + +def cli(): + """The main entry point for the Plotly CLI.""" + sys.path.insert(0, ".") + parser = create_parser() + args = parser.parse_args() + + try: + if args.command == "run": + app = load_app(args.app) + + # Collect arguments to pass to the app.run() method. + # Only include arguments that were actually provided on the CLI + # or have a default value in the parser. + run_options: Dict[str, Any] = { + key: value + for key, value in vars(args).items() + if value is not None and key not in ["command", "app"] + } + + app.run(**run_options) + + except (ValueError, ImportError, AttributeError, TypeError) as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) diff --git a/setup.py b/setup.py index ea616e2a18..68ca711c0d 100644 --- a/setup.py +++ b/setup.py @@ -3,7 +3,8 @@ from setuptools import setup, find_packages main_ns = {} -exec(open("dash/version.py", encoding="utf-8").read(), main_ns) # pylint: disable=exec-used, consider-using-with +# pylint: disable=exec-used, consider-using-with +exec(open("dash/version.py", encoding="utf-8").read(), main_ns) def read_req_file(req_type): @@ -21,10 +22,10 @@ def read_req_file(req_type): include_package_data=True, license="MIT", description=( - "A Python framework for building reactive web-apps. " - "Developed by Plotly." + "A Python framework for building reactive web-apps. " "Developed by Plotly." ), - long_description=io.open("README.md", encoding="utf-8").read(), # pylint: disable=consider-using-with + # pylint: disable=consider-using-with + long_description=io.open("README.md", encoding="utf-8").read(), long_description_content_type="text/markdown", install_requires=read_req_file("install"), python_requires=">=3.8", @@ -34,14 +35,14 @@ def read_req_file(req_type): "testing": read_req_file("testing"), "celery": read_req_file("celery"), "diskcache": read_req_file("diskcache"), - "compress": read_req_file("compress") + "compress": read_req_file("compress"), }, entry_points={ "console_scripts": [ - "dash-generate-components = " - "dash.development.component_generator:cli", + "dash-generate-components = dash.development.component_generator:cli", "renderer = dash.development.build_process:renderer", - "dash-update-components = dash.development.update_components:cli" + "dash-update-components = dash.development.update_components:cli", + "plotly = dash._cli:cli", ], "pytest11": ["dash = dash.testing.plugin"], }, @@ -78,16 +79,18 @@ def read_req_file(req_type): ], data_files=[ # like `jupyter nbextension install --sys-prefix` - ("share/jupyter/nbextensions/dash", [ - "dash/nbextension/main.js", - ]), + ( + "share/jupyter/nbextensions/dash", + [ + "dash/nbextension/main.js", + ], + ), # like `jupyter nbextension enable --sys-prefix` - ("etc/jupyter/nbconfig/notebook.d", [ - "dash/nbextension/dash.json" - ]), + ("etc/jupyter/nbconfig/notebook.d", ["dash/nbextension/dash.json"]), # Place jupyterlab extension in extension directory - ("share/jupyter/lab/extensions", [ - "dash/labextension/dist/dash-jupyterlab.tgz" - ]), + ( + "share/jupyter/lab/extensions", + ["dash/labextension/dist/dash-jupyterlab.tgz"], + ), ], ) diff --git a/tests/tooling/test_cli.py b/tests/tooling/test_cli.py new file mode 100644 index 0000000000..8883e3b05b --- /dev/null +++ b/tests/tooling/test_cli.py @@ -0,0 +1,124 @@ +import subprocess +import sys +import time +from typing import List +import socket +from pathlib import Path + +import requests +import pytest + + +# This is the content of the dummy Dash app we'll create for the test. +APP_CONTENT = """ +from dash import Dash, html + +# The unique string we will check for in the test +CUSTOM_INDEX_STRING = "Hello Dash CLI Test World" + +app = Dash(__name__) + +# Override the default index HTML to include our custom string +app.index_string = f''' + + +
+ {{%metas%}} +