From a0e38209dc54c10b7c8e762e67060ff860cca875 Mon Sep 17 00:00:00 2001 From: philippe Date: Tue, 7 Jul 2026 15:53:26 -0400 Subject: [PATCH 1/3] Fix callbacks double-registered when app file is loaded by import string When the app module runs as a script, a server that loads it by import string (e.g. uvicorn.run("app:server", reload=True)) executes the same file twice in the worker process: multiprocessing spawn re-runs it as __mp_main__, then the import string imports it again under its real name. Both passes run the @callback decorators, duplicating every spec 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 (only when that name resolves to the same file and isn't already imported), so the second import reuses the already-executed module instead of re-executing the file. Fixes #3818 Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 5 ++ dash/dash.py | 33 +++++++++ tests/unit/test_main_module_alias.py | 102 +++++++++++++++++++++++++++ 3 files changed, 140 insertions(+) create mode 100644 tests/unit/test_main_module_alias.py 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" + ] From a49104a9b382a6cae7b04deaf1057236df7b8590 Mon Sep 17 00:00:00 2001 From: Philippe Duval Date: Tue, 14 Jul 2026 12:39:45 -0400 Subject: [PATCH 2/3] Update CHANGELOG.md Co-authored-by: Cameron DeCoster --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 77f34fd6e1..1a904375bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ 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). +- [3883](https://github.com/plotly/dash/pull/3883) 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 From a102d22cc599faf2eb6d8369c631a93653d9a05a Mon Sep 17 00:00:00 2001 From: philippe Date: Wed, 15 Jul 2026 09:47:05 -0400 Subject: [PATCH 3/3] canonical import for uvicorn double register on hot reload --- dash/_utils.py | 69 +++++++++++++++++++++++++++- dash/dash.py | 34 +------------- tests/unit/test_main_module_alias.py | 66 ++++++++++++++++++++++++++ 3 files changed, 136 insertions(+), 33 deletions(-) diff --git a/dash/_utils.py b/dash/_utils.py index 1ab2036820..b97bd6f03f 100644 --- a/dash/_utils.py +++ b/dash/_utils.py @@ -4,6 +4,7 @@ import uuid import hashlib import importlib +import importlib.util from collections import abc import subprocess import logging @@ -17,7 +18,7 @@ from html import escape from functools import wraps -from typing import Union +from typing import Optional, Union from .types import RendererHooks logger = logging.getLogger() @@ -395,3 +396,69 @@ def get_root_path(import_name: str) -> str: # filepath is import_name.py for a module, or __init__.py for a package. return os.path.dirname(os.path.abspath(filepath)) # type: ignore[no-any-return] + + +def canonical_import_name(module_file: str) -> Optional[str]: + """Best-effort dotted import name for a running main module. + + Mirrors how the FastAPI backend derives the uvicorn import string + (see ``dash/backends/_fastapi.py``): the path relative to the current + working directory with the separator replaced by dots, so a nested + ``package/app.py`` is aliased under ``package.app`` rather than just + ``app``. Falls back to the bare basename when the file lives outside the + cwd (e.g. ``python some/dir/app.py`` where only the script's own directory + is on ``sys.path``). + + :meta private: + """ + try: + rel_path = os.path.relpath(os.path.abspath(module_file), os.getcwd()) + except (ValueError, OSError): + rel_path = os.path.basename(module_file) + if rel_path == os.pardir or rel_path.startswith(os.pardir + os.sep): + rel_path = os.path.basename(module_file) + parts = os.path.splitext(rel_path)[0].split(os.sep) + if not all(part.isidentifier() for part in parts): + return None + return ".".join(parts) + + +def alias_main_module(caller_name: str) -> None: + """Pre-register the running main module under its canonical import name. + + 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-registering the running module under + its canonical import name makes that import resolve to this module instead + of re-executing the file. See issue #3818. + + :meta private: + """ + 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 = canonical_import_name(module_file) + if import_name is None or import_name in sys.modules: + return + try: + spec = importlib.util.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): + # Aliasing is a best-effort optimization to avoid re-executing the + # module: find_spec may raise ImportError/ValueError if the name isn't + # importable (e.g. a namespace package or a parent __init__ that errors) + # and samefile may raise OSError if spec.origin no longer exists. In any + # of these cases we simply skip the alias and let the normal import run. + pass diff --git a/dash/dash.py b/dash/dash.py index ff284bfbeb..d114197fd2 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -53,6 +53,7 @@ hooks_to_js_object, get_caller_name, get_root_path, + alias_main_module, ) from . import _callback from . import _get_paths @@ -154,37 +155,6 @@ 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 @@ -537,7 +507,7 @@ 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) + alias_main_module(caller_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 3d9358daa9..522e344761 100644 --- a/tests/unit/test_main_module_alias.py +++ b/tests/unit/test_main_module_alias.py @@ -79,6 +79,72 @@ def test_main_module_alias_prevents_double_registration(tmp_path, monkeypatch): ] +PKG_APP_SOURCE = """ +from dash import Dash, html, dcc, callback, Output, Input + +app = Dash(__name__) +app.layout = html.Div([ + dcc.Input(id="pkg-alias-in", value="hello"), + html.Div(id="pkg-alias-out"), +]) + + +@callback(Output("pkg-alias-out", "children"), Input("pkg-alias-in", "value")) +def update(value): + return value + + +server = app.server +""" + +PKG_NAME = "dash_test_alias_pkg" + + +def test_main_module_alias_prevents_double_registration_nested(tmp_path, monkeypatch): + """A main module nested in a package (``package/app.py``) is re-imported by + its dotted import string (``package.app:server``), not its basename. The + alias must be registered under the dotted name so that import resolves to + the already-executed module instead of re-running the file.""" + from dash import _callback + + pkg_dir = tmp_path / PKG_NAME + pkg_dir.mkdir() + (pkg_dir / "__init__.py").write_text("") + app_file = pkg_dir / "app.py" + app_file.write_text(PKG_APP_SOURCE) + + monkeypatch.syspath_prepend(str(tmp_path)) + monkeypatch.chdir(tmp_path) + + import_string = f"{PKG_NAME}.app" + + try: + main_module = _run_as(app_file, "__mp_main__") + + # The deployment import string ("dash_test_alias_pkg.app:server") must + # resolve to the module that already executed, not re-execute the file. + imported = importlib.import_module(import_string) + assert imported is main_module + + specs = [ + spec + for spec in _callback.GLOBAL_CALLBACK_LIST + if spec["output"] == "pkg-alias-out.children" + ] + assert len(specs) == 1 + assert "pkg-alias-out.children" in _callback.GLOBAL_CALLBACK_MAP + finally: + sys.modules.pop("__mp_main__", None) + sys.modules.pop(import_string, None) + sys.modules.pop(PKG_NAME, None) + _callback.GLOBAL_CALLBACK_MAP.pop("pkg-alias-out.children", None) + _callback.GLOBAL_CALLBACK_LIST[:] = [ + spec + for spec in _callback.GLOBAL_CALLBACK_LIST + if spec["output"] != "pkg-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)."""