Skip to content
Merged
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
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
69 changes: 68 additions & 1 deletion dash/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import uuid
import hashlib
import importlib
import importlib.util
from collections import abc
import subprocess
import logging
Expand All @@ -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()
Expand Down Expand Up @@ -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
3 changes: 3 additions & 0 deletions dash/dash.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
hooks_to_js_object,
get_caller_name,
get_root_path,
alias_main_module,
)
from . import _callback
from . import _get_paths
Expand Down Expand Up @@ -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")
Expand Down
168 changes: 168 additions & 0 deletions tests/unit/test_main_module_alias.py
Original file line number Diff line number Diff line change
@@ -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"
]
Loading