diff --git a/CHANGELOG.md b/CHANGELOG.md index f94d6fb1e3..b9ca3b800c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,8 @@ This project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] -### Fixed +## Fixed +- [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). - [3885](https://github.com/plotly/dash/pull/3885) Fix Flask-WTF `CSRFProtect` (and Quart-WTF) exemptions breaking after the backend refactor. The callback dispatch view is now exposed with the fully-qualified name `dash.dash.dispatch` again, so `csrf._exempt_views.add("dash.dash.dispatch")` works as it did in Dash 4.1.0. Fixes [#3827](https://github.com/plotly/dash/issues/3827). - [#3882](https://github.com/plotly/dash/pull/3882) Fix `dcc.Graph` user interactions (pan, zoom, edited shapes & annotations, ...) being reverted by a subsequent `Patch` update, by syncing all relayout changes back to the `figure` prop instead of only shapes. Fixes [#3810](https://github.com/plotly/dash/issues/3810). 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 36a12c6d73..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 @@ -506,6 +507,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..522e344761 --- /dev/null +++ b/tests/unit/test_main_module_alias.py @@ -0,0 +1,168 @@ +"""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" + ] + + +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).""" + 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" + ]