From db1dc8d71ab4efd64e7700b730771fae7fddf9cf Mon Sep 17 00:00:00 2001 From: philippe Date: Mon, 9 Jun 2025 15:06:50 -0400 Subject: [PATCH 1/6] Add plotly run command --- dash/__main__.py | 3 + dash/_cli.py | 172 +++++++++++++++++++++++++++++++++++++++++++++++ setup.py | 37 +++++----- 3 files changed, 195 insertions(+), 17 deletions(-) create mode 100644 dash/__main__.py create mode 100644 dash/_cli.py 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..9c00cc8b57 --- /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. + """ + if ":" not in app_path: + raise ValueError( + f"Invalid app path: '{app_path}'. " + 'The path must be in the format "module:variable".' + ) + + module_str, app_str = app_path.split(":", 1) + + if not module_str or not app_str: + raise ValueError( + f"Invalid app path: '{app_path}'. " + 'Both module and variable names are required in "module:variable".' + ) + + try: + module = importlib.import_module(module_str) + except ImportError as e: + raise ImportError(f"Could not import module '{module_str}'.") from e + + 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 + + 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".' + ) + + # 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".', + ) + + # Debug flag (supports --debug and --no-debug) + # Note: Requires Python 3.9+ + run_parser.add_argument( + "--debug", + "-d", + action=argparse.BooleanOptionalAction, + 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=argparse.BooleanOptionalAction, + help="Enable/disable the dev tools UI.", + ) + dev_tools_group.add_argument( + "--dev-tools-props-check", + action=argparse.BooleanOptionalAction, + help="Enable/disable component prop validation.", + ) + dev_tools_group.add_argument( + "--dev-tools-serve-dev-bundles", + action=argparse.BooleanOptionalAction, + help="Enable/disable serving of dev bundles.", + ) + dev_tools_group.add_argument( + "--dev-tools-hot-reload", + action=argparse.BooleanOptionalAction, + 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=argparse.BooleanOptionalAction, + help="Enable/disable silencing of Werkzeug's route logging.", + ) + dev_tools_group.add_argument( + "--dev-tools-disable-version-check", + action=argparse.BooleanOptionalAction, + help="Enable/disable the Dash version upgrade check.", + ) + dev_tools_group.add_argument( + "--dev-tools-prune-errors", + action=argparse.BooleanOptionalAction, + 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"], + ), ], ) From 2fed18b6bc1c2e73740a858eedb1127d206ec5f3 Mon Sep 17 00:00:00 2001 From: philippe Date: Mon, 9 Jun 2025 16:01:18 -0400 Subject: [PATCH 2/6] infer app instance from type --- dash/_cli.py | 39 ++++++++++++++++++++------------------- 1 file changed, 20 insertions(+), 19 deletions(-) diff --git a/dash/_cli.py b/dash/_cli.py index 9c00cc8b57..8e566c2e68 100644 --- a/dash/_cli.py +++ b/dash/_cli.py @@ -13,31 +13,30 @@ def load_app(app_path: str) -> Dash: :param app_path: The import path to the Dash app instance. :return: The loaded Dash app instance. """ - if ":" not in app_path: - raise ValueError( - f"Invalid app path: '{app_path}'. " - 'The path must be in the format "module:variable".' - ) + app_split = app_path.split(":") + module_str = app_split[0] - module_str, app_str = app_path.split(":", 1) - - if not module_str or not app_str: - raise ValueError( - f"Invalid app path: '{app_path}'. " - 'Both module and variable names are required in "module:variable".' - ) + 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 - 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 + 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.") @@ -60,7 +59,9 @@ def create_parser() -> argparse.ArgumentParser: ) run_parser.add_argument( - "app", help='The Dash app to run, in the format "module:variable".' + "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 From 9ea24186b0c04dd59b1896d35d0db62203f312e4 Mon Sep 17 00:00:00 2001 From: philippe Date: Mon, 9 Jun 2025 16:11:05 -0400 Subject: [PATCH 3/6] store_true instead of BooleanOptionalAction --- dash/_cli.py | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/dash/_cli.py b/dash/_cli.py index 8e566c2e68..7b15d07d29 100644 --- a/dash/_cli.py +++ b/dash/_cli.py @@ -82,12 +82,10 @@ def create_parser() -> argparse.ArgumentParser: help='Proxy configuration string, e.g., "http://0.0.0.0:8050::https://my.domain.com".', ) - # Debug flag (supports --debug and --no-debug) - # Note: Requires Python 3.9+ run_parser.add_argument( "--debug", "-d", - action=argparse.BooleanOptionalAction, + action="store_true", help="Enable/disable Flask debug mode and dev tools.", ) @@ -95,22 +93,22 @@ def create_parser() -> argparse.ArgumentParser: dev_tools_group = run_parser.add_argument_group("dev tools options") dev_tools_group.add_argument( "--dev-tools-ui", - action=argparse.BooleanOptionalAction, + action="store_true", help="Enable/disable the dev tools UI.", ) dev_tools_group.add_argument( "--dev-tools-props-check", - action=argparse.BooleanOptionalAction, + action="store_true", help="Enable/disable component prop validation.", ) dev_tools_group.add_argument( "--dev-tools-serve-dev-bundles", - action=argparse.BooleanOptionalAction, + action="store_true", help="Enable/disable serving of dev bundles.", ) dev_tools_group.add_argument( "--dev-tools-hot-reload", - action=argparse.BooleanOptionalAction, + action="store_true", help="Enable/disable hot reloading.", ) dev_tools_group.add_argument( @@ -130,17 +128,17 @@ def create_parser() -> argparse.ArgumentParser: ) dev_tools_group.add_argument( "--dev-tools-silence-routes-logging", - action=argparse.BooleanOptionalAction, + action="store_true", help="Enable/disable silencing of Werkzeug's route logging.", ) dev_tools_group.add_argument( "--dev-tools-disable-version-check", - action=argparse.BooleanOptionalAction, + action="store_true", help="Enable/disable the Dash version upgrade check.", ) dev_tools_group.add_argument( "--dev-tools-prune-errors", - action=argparse.BooleanOptionalAction, + action="store_true", help="Enable/disable pruning of tracebacks to user code only.", ) From 0a6cfd1cae2c4f23f4e22830baa673fcf860e19e Mon Sep 17 00:00:00 2001 From: philippe Date: Tue, 10 Jun 2025 10:26:37 -0400 Subject: [PATCH 4/6] initialize app_instance for check --- dash/_cli.py | 1 + 1 file changed, 1 insertion(+) diff --git a/dash/_cli.py b/dash/_cli.py index 7b15d07d29..c8b4eee92e 100644 --- a/dash/_cli.py +++ b/dash/_cli.py @@ -24,6 +24,7 @@ def load_app(app_path: str) -> Dash: 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: From 9fccb1de4d399c1aa1658e62a247fecd7b1db7dc Mon Sep 17 00:00:00 2001 From: philippe Date: Thu, 12 Jun 2025 09:48:37 -0400 Subject: [PATCH 5/6] Add test for plotly run cli --- .github/workflows/testing.yml | 43 ++++++++++++ tests/tooling/test_cli.py | 124 ++++++++++++++++++++++++++++++++++ 2 files changed, 167 insertions(+) create mode 100644 tests/tooling/test_cli.py diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 68965f2118..7a9356c5c9 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,43 @@ 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' + + - 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/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%}} + {{%title%}} + {{%css%}} + + +

{CUSTOM_INDEX_STRING}

+ {{%app_entry%}} +
+ {{%config%}} + {{%scripts%}} + {{%renderer%}} +
+ + +''' + +app.layout = html.Div("This is the app layout.") +""" + + +# Helper function to find an available network port +def find_free_port(): + """Finds a free port on the local machine.""" + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("", 0)) + return s.getsockname()[1] + + +@pytest.mark.parametrize("app_path", ["app:app", "app"]) +@pytest.mark.parametrize("cmd", [[sys.executable, "-m", "dash"], ["plotly"]]) +def test_run_command_serves_app(tmp_path: Path, app_path: str, cmd: List[str]): + """ + Tests that the `run` command successfully serves a Dash app. + """ + # 1. Setup: Create the app in a temporary directory + app_dir = tmp_path / "my_test_app" + app_dir.mkdir() + (app_dir / "app.py").write_text(APP_CONTENT) + + port = find_free_port() + url = f"http://127.0.0.1:{port}" + + # Command to execute. We run the cli.py script directly with the python + # interpreter that is running pytest. This is more robust than assuming + # an entry point is on the PATH. + command = [ + *cmd, + "run", + str(app_path), + "--port", + str(port), + ] + + process = None + try: + # 2. Execution: Start the CLI command as a background process + # The working directory `cwd` is crucial so that "import app" works. + process = subprocess.Popen( # pylint: disable=consider-using-with + command, + cwd=str(app_dir), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + # Give the server a moment to start up. + time.sleep(3) + + # Check if the process terminated unexpectedly + if process.poll() is not None: + stdout, stderr = process.communicate() + pytest.fail( + f"The CLI process terminated prematurely.\n" + f"Exit Code: {process.returncode}\n" + f"STDOUT:\n{stdout}\n" + f"STDERR:\n{stderr}" + ) + + # 3. Verification: Make a request to the running server + response = requests.get(url, timeout=10) + response.raise_for_status() # Check for HTTP errors like 404 or 500 + + # 4. Assertion: Check for the custom content from the app + assert "Hello Dash CLI Test World" in response.text + print(f"\nSuccessfully fetched app from {url}") + + finally: + # 5. Teardown: Ensure the server process is always terminated + if process: + print(f"\nTerminating server process (PID: {process.pid})") + process.terminate() + # Use communicate() to wait for process to die and get output + try: + stdout, stderr = process.communicate(timeout=5) + print(f"Server process STDOUT:\n{stdout}") + print(f"Server process STDERR:\n{stderr}") + except subprocess.TimeoutExpired: + print("Process did not terminate gracefully, killing.") + process.kill() From 70e9187db6eda5af1aee58f3d3c9a0ad64400bec Mon Sep 17 00:00:00 2001 From: philippe Date: Thu, 12 Jun 2025 10:20:42 -0400 Subject: [PATCH 6/6] Use cache-dependency-path --- .github/workflows/testing.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 7a9356c5c9..b7a286d921 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -162,6 +162,7 @@ jobs: with: python-version: '3.12' cache: 'pip' + cache-dependency-path: requirements/*.txt - name: Download built Dash packages uses: actions/download-artifact@v4