Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
40 changes: 17 additions & 23 deletions dash/testing/application_runners.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,20 @@
logger = logging.getLogger(__name__)


def _server_type(app):
return getattr(getattr(app, "backend", None), "server_type", "flask")


def _run_app(app, options):
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)


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.
Expand Down Expand Up @@ -173,16 +187,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:
Expand Down Expand Up @@ -219,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]
Expand Down Expand Up @@ -264,16 +267,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
Expand Down
59 changes: 59 additions & 0 deletions tests/unit/test_app_runners.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -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"
)
Expand All @@ -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)