diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ab4b458f5..5764c26582 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ This project adheres to [Semantic Versioning](https://semver.org/). - [#3986](https://github.com/plotly/dash/pull/3986) Adjust `_run_before_hooks` in the `fastapi` backend to honor a response returned by a `before_request` function, matching the `flask` backend's behavior. ### Fixed +- [#4053](https://github.com/plotly/dash/pull/4053) Fix callbacks being registered twice under a reloader (e.g. `uvicorn --reload`) when `Dash()` is given an explicit `name` or is built by an app factory called from the main module. Fixes [#3912](https://github.com/plotly/dash/issues/3912). - [#3980](https://github.com/plotly/dash/pull/3980) Fix the three `before_request` hooks (`Dash._setup_server` and the pages `router_sync` / `router_async`) publishing their "already done" guard flag before the setup work behind it had run. Under a multi-threaded WSGI worker such as `gunicorn -k gthread` (or under an ASGI worker for the async router), a second request arriving mid-setup could observe the flag already set, skip setup, then read `registered_paths` / `callback_map` / the pages router callback while they were still being registered - causing the first burst of component bundle requests after a restart to 500 with `Error loading dependency. "" is not a registered library`, or the pages router to hit `DuplicateCallback` when two workers raced past the guard. Each hook body now runs under a lock (`threading.Lock` for the two sync hooks, an `asyncio.Lock` bound to the running loop for the async router) and only publishes the flag after all work completes. Fixes [#3971](https://github.com/plotly/dash/issues/3971). - [#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. - [#3955](https://github.com/plotly/dash/pull/3955) Unpin `selenium` in the testing requirements (was capped at `<=4.2.0` from 2022) and require `>=4.11.0`, so it can drive current stable Chrome via Selenium Manager and stop the widespread CI flakiness. diff --git a/dash/dash.py b/dash/dash.py index 24849a2f6e..58621bead4 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -549,9 +549,12 @@ def __init__( # pylint: disable=too-many-statements, too-many-branches if not csrf_header_name or not csrf_header_name.strip(): raise ValueError("csrf_header_name must be a non-empty string") - caller_name: str = name if name is not None else get_caller_name() + module_name = get_caller_name() + caller_name: str = name if name is not None else module_name - alias_main_module(caller_name) + # Alias based on the module that is running, not the app name: an + # explicit ``name`` or an app factory still runs from the main module. + alias_main_module(module_name) # Determine backend if backend is None: diff --git a/tests/unit/test_main_module_alias.py b/tests/unit/test_main_module_alias.py index 806fdd07dd..4f166a0575 100644 --- a/tests/unit/test_main_module_alias.py +++ b/tests/unit/test_main_module_alias.py @@ -256,3 +256,122 @@ def test_no_sibling_import_when_script_dir_has_same_name_module(tmp_path, monkey for spec in _callback.GLOBAL_CALLBACK_LIST if spec["output"] not in ("alias-out.children", "sibling-out.children") ] + + +NAMED_APP_SOURCE = """ +from dash import Dash, html, dcc, callback, Output, Input + +app = Dash("myapp") +app.layout = html.Div([ + dcc.Input(id="named-alias-in", value="hello"), + html.Div(id="named-alias-out"), +]) + + +@callback(Output("named-alias-out", "children"), Input("named-alias-in", "value")) +def update(value): + return value + + +server = app.server +""" + +NAMED_MODULE_NAME = "dash_test_alias_named" + + +def test_main_module_alias_with_explicit_name(tmp_path, monkeypatch): + """Passing an explicit ``name`` to ``Dash()`` must not disable the alias: + the running main module is still re-imported by its import string. See + issue #3912.""" + from dash import _callback + + app_file = tmp_path / f"{NAMED_MODULE_NAME}.py" + app_file.write_text(NAMED_APP_SOURCE) + monkeypatch.syspath_prepend(str(tmp_path)) + + try: + main_module = _run_as(app_file, "__mp_main__") + + imported = importlib.import_module(NAMED_MODULE_NAME) + assert imported is main_module + + specs = [ + spec + for spec in _callback.GLOBAL_CALLBACK_LIST + if spec["output"] == "named-alias-out.children" + ] + assert len(specs) == 1 + finally: + sys.modules.pop("__mp_main__", None) + sys.modules.pop(NAMED_MODULE_NAME, None) + _callback.GLOBAL_CALLBACK_MAP.pop("named-alias-out.children", None) + _callback.GLOBAL_CALLBACK_LIST[:] = [ + spec + for spec in _callback.GLOBAL_CALLBACK_LIST + if spec["output"] != "named-alias-out.children" + ] + + +FACTORY_SOURCE = """ +from dash import Dash, html, dcc, callback, Output, Input + + +def build(): + app = Dash(__name__) + app.layout = html.Div([ + dcc.Input(id="factory-alias-in", value="hello"), + html.Div(id="factory-alias-out"), + ]) + + @callback( + Output("factory-alias-out", "children"), Input("factory-alias-in", "value") + ) + def update(value): + return value + + return app.server +""" + +FACTORY_MAIN_SOURCE = """ +from dash_test_alias_factory import build + +server = build() +""" + +FACTORY_MAIN_NAME = "dash_test_alias_factory_main" + + +def test_main_module_alias_with_app_factory(tmp_path, monkeypatch): + """An app built by a factory in another module, called from the top level + of the main module, gets that module's ``__name__``. The running main + module must still be aliased so the import string does not build the app + a second time. See issue #3912.""" + from dash import _callback + + (tmp_path / "dash_test_alias_factory.py").write_text(FACTORY_SOURCE) + app_file = tmp_path / f"{FACTORY_MAIN_NAME}.py" + app_file.write_text(FACTORY_MAIN_SOURCE) + monkeypatch.syspath_prepend(str(tmp_path)) + + try: + main_module = _run_as(app_file, "__mp_main__") + + imported = importlib.import_module(FACTORY_MAIN_NAME) + assert imported is main_module + + specs = [ + spec + for spec in _callback.GLOBAL_CALLBACK_LIST + if spec["output"] == "factory-alias-out.children" + ] + assert len(specs) == 1 + finally: + sys.modules.pop("__mp_main__", None) + sys.modules.pop(FACTORY_MAIN_NAME, None) + sys.modules.pop("dash_test_alias_factory", None) + _callback.GLOBAL_CALLBACK_MAP.pop("factory-alias-out.children", None) + _callback.GLOBAL_CALLBACK_LIST[:] = [ + spec + for spec in _callback.GLOBAL_CALLBACK_LIST + if spec["output"] != "factory-alias-out.children" + ]