From fc4ca459830bf98465018edfb6932bff02c0a0c5 Mon Sep 17 00:00:00 2001 From: cyphercodes Date: Thu, 6 Aug 2026 08:12:50 +0300 Subject: [PATCH 1/2] Fix testing runner backend detection --- dash/testing/application_runners.py | 30 +++++---------- tests/unit/test_app_runners.py | 59 +++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 20 deletions(-) diff --git a/dash/testing/application_runners.py b/dash/testing/application_runners.py index b318d2419d..45edd6ba13 100644 --- a/dash/testing/application_runners.py +++ b/dash/testing/application_runners.py @@ -28,6 +28,14 @@ logger = logging.getLogger(__name__) +def _run_app(app, options): + server_type = getattr(getattr(app, "backend", None), "server_type", "flask") + if server_type in ("fastapi", "quart"): + app.run(**options) + else: + app.run(threaded=True, **options) + + def import_app(app_file, application_name="app"): """Import a dash application from a module. The import path is in dot notation to the module. The variable named app will be returned. @@ -173,16 +181,7 @@ def run(): self.port = options["port"] try: - module = app.server.__class__.__module__ - # FastAPI support - if module.startswith("fastapi"): - app.run(**options) - # Quart support (ASGI - runs its own async event loop) - elif module.startswith("quart"): - app.run(**options) - # Flask fallback (WSGI - needs threaded mode) - else: - app.run(threaded=True, **options) + _run_app(app, options) except SystemExit: logger.info("Server stopped") except Exception as error: @@ -264,16 +263,7 @@ def target(): options = kwargs.copy() try: - module = app.server.__class__.__module__ - # FastAPI support - if module.startswith("fastapi"): - app.run(**options) - # Quart support (ASGI - runs its own async event loop) - elif module.startswith("quart"): - app.run(**options) - # Flask fallback (WSGI - needs threaded mode) - else: - app.run(threaded=True, **options) + _run_app(app, options) except SystemExit: logger.info("Server stopped") raise diff --git a/tests/unit/test_app_runners.py b/tests/unit/test_app_runners.py index 366a17f251..76cf4e0ac2 100644 --- a/tests/unit/test_app_runners.py +++ b/tests/unit/test_app_runners.py @@ -1,10 +1,15 @@ import os import sys +import time +from types import SimpleNamespace +from unittest.mock import Mock + import requests import pytest import dash from dash import html +from dash.testing.application_runners import ThreadedRunner, _run_app def test_threaded_server_smoke(dash_thread_server): @@ -22,6 +27,37 @@ def test_threaded_server_smoke(dash_thread_server): assert 'id="react-entry-point"' in r.text, "the entrypoint is present" +def test_threaded_server_wrapped_fastapi(monkeypatch): + wrapped_server = type( + "WrappedFastAPI", (), {"__module__": "instrumentation.wrapper"} + )() + uvicorn_server = SimpleNamespace(should_exit=False) + run_options = {} + + def run(**options): + run_options.update(options) + while not uvicorn_server.should_exit: + time.sleep(0.01) + + app = SimpleNamespace( + server=wrapped_server, + backend=SimpleNamespace(server_type="fastapi"), + scripts=SimpleNamespace(config=SimpleNamespace(serve_locally=False)), + css=SimpleNamespace(config=SimpleNamespace(serve_locally=False)), + run=run, + _uvicorn_server=uvicorn_server, + ) + runner = ThreadedRunner() + monkeypatch.setattr(runner, "accessible", lambda _url: True) + + try: + runner.start(app) + assert "threaded" not in run_options + finally: + if runner.started: + runner.stop() + + @pytest.mark.skipif( sys.version_info < (3,), reason="requires python3 for process testing" ) @@ -37,3 +73,26 @@ def test_process_server_smoke(dash_process_server): assert 'id="react-entry-point"' in r.text, "the entrypoint is present" finally: os.chdir(cwd) + + +@pytest.mark.parametrize( + ("server_type", "expected_options"), + [ + ("fastapi", {"port": 8050}), + ("quart", {"port": 8050}), + ("flask", {"port": 8050, "threaded": True}), + ], +) +def test_run_app_uses_backend_type(server_type, expected_options): + wrapped_server = type( + "WrappedServer", (), {"__module__": "instrumentation.wrapper"} + )() + app = SimpleNamespace( + server=wrapped_server, + backend=SimpleNamespace(server_type=server_type), + run=Mock(), + ) + + _run_app(app, {"port": 8050}) + + app.run.assert_called_once_with(**expected_options) From 982f2a3970950e3d9d295c425b5a0bfc61af0361 Mon Sep 17 00:00:00 2001 From: cyphercodes Date: Wed, 19 Aug 2026 12:03:07 +0300 Subject: [PATCH 2/2] Address runner backend review feedback --- CHANGELOG.md | 1 + dash/testing/application_runners.py | 12 ++++++++---- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 85a455204e..c214af19ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ This project adheres to [Semantic Versioning](https://semver.org/). - [#3646](https://github.com/plotly/dash/pull/3646) Remove React 16 support (`16.14.0` is no longer an accepted value for `REACT_VERSION` / `_set_react_version`). ### Fixed +- [#3944](https://github.com/plotly/dash/pull/3944) Fix `dash.testing` runner backend detection for wrapped FastAPI/Quart servers so threaded Flask-only options are not passed to ASGI runners. - [#3941](https://github.com/plotly/dash/pull/3941) Fix the FastAPI and Quart backends opening a WebSocket connection on every page load, even for apps with no WebSocket callbacks. The renderer keyed the connection on the mere presence of WebSocket infrastructure (always advertised by these backends) rather than on whether it was needed. The socket now opens eagerly only when `websocket_callbacks=True`; with just per-callback `websocket=True` it opens lazily on the first such callback dispatch, and an app with no WebSocket callbacks never opens one. Fixes [#3939](https://github.com/plotly/dash/issues/3939). - [#3916](https://github.com/plotly/dash/pull/3916) Fixed a regression where dragging multiple files into `dcc.Upload` would upload only the first file when `multiple=True` - [#3922](https://github.com/plotly/dash/pull/3922) Fix `dcc.Input(type="number")` stepper behavior when only `min` is set. diff --git a/dash/testing/application_runners.py b/dash/testing/application_runners.py index 45edd6ba13..c6882f662e 100644 --- a/dash/testing/application_runners.py +++ b/dash/testing/application_runners.py @@ -28,11 +28,17 @@ logger = logging.getLogger(__name__) +def _server_type(app): + return getattr(getattr(app, "backend", None), "server_type", "flask") + + def _run_app(app, options): - server_type = getattr(getattr(app, "backend", None), "server_type", "flask") + server_type = _server_type(app) if server_type in ("fastapi", "quart"): app.run(**options) else: + # Flask test servers need threaded=True so shutdown requests can be + # handled while the server is processing another request. app.run(threaded=True, **options) @@ -218,9 +224,7 @@ def run(): def stop(self): # pylint: disable=protected-access - server_type = getattr( - getattr(self._app, "backend", None), "server_type", "flask" - ) + server_type = _server_type(self._app) # For FastAPI apps with uvicorn, use graceful shutdown if server_type == "fastapi": server = self._app._uvicorn_server # type: ignore[reportOptionalMemberAccess]