diff --git a/CHANGELOG.md b/CHANGELOG.md index f3e7b816d7..77f34fd6e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to `dash` will be documented in this file. This project adheres to [Semantic Versioning](https://semver.org/). +## [Unreleased] + +## Fixed +- [](https://github.com/plotly/dash/pull/) Fix callbacks being registered twice when running the app file as a script with a server that loads it by import string, e.g. `uvicorn.run("app:server", reload=True)` with `backend="fastapi"`. The spawned worker re-executes the main module as `__mp_main__` and the import string then executed the same file a second time, duplicating every callback in `_dash-dependencies` and triggering `Duplicate callback outputs` errors in the renderer. `Dash()` now pre-registers the running main module in `sys.modules` under its canonical import name so the second import reuses it instead of re-executing the file. Fixes [#3818](https://github.com/plotly/dash/issues/3818). + ## [4.4.0] - 2026-07-03 ### Added diff --git a/dash/dash.py b/dash/dash.py index 36a12c6d73..ff284bfbeb 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -154,6 +154,37 @@ page_container = None +def _alias_main_module(caller_name: str) -> None: + # When the app module runs as a script (`__main__`), or is re-executed by + # multiprocessing's spawn as `__mp_main__` (e.g. in the worker process of + # uvicorn's reloader), a later import of the same file by its real name + # ("app:server" import strings) would execute the module a second time, + # registering every callback twice. Pre-register the running module under + # its canonical import name so that import resolves to this module + # instead of re-executing the file. See issue #3818. + if caller_name not in ("__main__", "__mp_main__"): + return + module = sys.modules.get(caller_name) + if module is None: + return + module_file = getattr(module, "__file__", None) + if not module_file: + return + import_name = os.path.splitext(os.path.basename(module_file))[0] + if not import_name.isidentifier() or import_name in sys.modules: + return + try: + spec = find_spec(import_name) + if ( + spec is not None + and spec.origin is not None + and os.path.samefile(spec.origin, module_file) + ): + sys.modules[import_name] = module + except (ImportError, ValueError, OSError): + pass + + def _get_traceback(secret, error: Exception): try: # pylint: disable=import-outside-toplevel @@ -506,6 +537,8 @@ def __init__( # pylint: disable=too-many-statements, too-many-branches caller_name: str = name if name is not None else get_caller_name() + _alias_main_module(caller_name) + # Determine backend if backend is None: backend_cls = get_backend("flask") diff --git a/tests/unit/test_main_module_alias.py b/tests/unit/test_main_module_alias.py new file mode 100644 index 0000000000..3d9358daa9 --- /dev/null +++ b/tests/unit/test_main_module_alias.py @@ -0,0 +1,102 @@ +"""Regression tests for https://github.com/plotly/dash/issues/3818. + +When the app module runs as ``__main__``/``__mp_main__`` (e.g. in the worker +process of uvicorn's reloader, which multiprocessing spawn re-executes as +``__mp_main__``), the server's import string ("app:server") imports the same +file a second time under its real name. Both executions run the module-level +``@callback`` decorators, duplicating every spec in GLOBAL_CALLBACK_LIST and +producing `Duplicate callback outputs` errors in the renderer. + +``Dash.__init__`` now pre-registers the running main module in ``sys.modules`` +under its canonical import name so the second import resolves to the module +already executed instead of re-executing the file. +""" +import importlib +import sys +import types + +APP_SOURCE = """ +from dash import Dash, html, dcc, callback, Output, Input + +app = Dash(__name__) +app.layout = html.Div([ + dcc.Input(id="alias-in", value="hello"), + html.Div(id="alias-out"), +]) + + +@callback(Output("alias-out", "children"), Input("alias-in", "value")) +def update(value): + return value + + +server = app.server +""" + +MODULE_NAME = "dash_test_alias_app" + + +def _run_as(app_file, run_name): + """Execute the app file the way multiprocessing spawn runs the main module.""" + module = types.ModuleType(run_name) + module.__file__ = str(app_file) + sys.modules[run_name] = module + code = compile(app_file.read_text(), str(app_file), "exec") + exec(code, module.__dict__) # pylint: disable=exec-used + return module + + +def test_main_module_alias_prevents_double_registration(tmp_path, monkeypatch): + from dash import _callback + + app_file = tmp_path / f"{MODULE_NAME}.py" + app_file.write_text(APP_SOURCE) + monkeypatch.syspath_prepend(str(tmp_path)) + + try: + main_module = _run_as(app_file, "__mp_main__") + + # The import string import ("dash_test_alias_app:server") must resolve + # to the module that already executed, not re-execute the file. + imported = importlib.import_module(MODULE_NAME) + assert imported is main_module + + specs = [ + spec + for spec in _callback.GLOBAL_CALLBACK_LIST + if spec["output"] == "alias-out.children" + ] + assert len(specs) == 1 + assert "alias-out.children" in _callback.GLOBAL_CALLBACK_MAP + finally: + sys.modules.pop("__mp_main__", None) + sys.modules.pop(MODULE_NAME, None) + _callback.GLOBAL_CALLBACK_MAP.pop("alias-out.children", None) + _callback.GLOBAL_CALLBACK_LIST[:] = [ + spec + for spec in _callback.GLOBAL_CALLBACK_LIST + if spec["output"] != "alias-out.children" + ] + + +def test_no_alias_when_names_collide(tmp_path, monkeypatch): + """A main module whose basename matches an already-imported module must + not clobber the existing sys.modules entry (e.g. a script named dash.py).""" + app_file = tmp_path / "dash.py" + app_file.write_text(APP_SOURCE) + monkeypatch.syspath_prepend(str(tmp_path)) + + import dash as real_dash + from dash import _callback + + try: + _run_as(app_file, "__mp_main__") + assert sys.modules["dash"] is real_dash + finally: + sys.modules.pop("__mp_main__", None) + _callback.GLOBAL_CALLBACK_MAP.pop("alias-out.children", None) + _callback.GLOBAL_CALLBACK_LIST[:] = [ + spec + for spec in _callback.GLOBAL_CALLBACK_LIST + if spec["output"] != "alias-out.children" + ]