Summary
dash.dash.alias_main_module() (added in #3883, to fix uvicorn.run("app:server", reload=True) double-import, #3818) causes a different double-import when an app's entrypoint file has a directory-name-equals-basename shape (e.g. app/app.py) and is launched with python app/app.py from the parent directory — the layout used by every "run the script directly" tutorial and by many real apps' READMEs. The self-reimport causes Dash's pages loader to execute every page module a second time, double-registering every page-level @callback, which breaks the client with "Duplicate callback outputs" errors.
Confirmed present in 4.4.1 and unchanged in 4.5.0rc0. Not present in 4.4.0 (no alias_main_module in that wheel) or 4.3.0.
Minimal repro
mkdir -p repro/app/pages
cat > repro/app/app.py <<'EOF'
from dash import Dash, html, dcc, callback, Output, Input
app = Dash(__name__, use_pages=True, pages_folder="pages")
app.layout = html.Div([dcc.Location(id="url"), dash.page_container])
if __name__ == "__main__":
print("callbacks registered:", len(dash._callback.GLOBAL_CALLBACK_LIST))
app.run(debug=False)
EOF
cat > repro/app/pages/home.py <<'EOF'
import dash
from dash import html, callback, Output, Input, dcc
dash.register_page(__name__, path="/")
layout = html.Div([html.Button("go", id="btn"), html.Div(id="out")])
@callback(Output("out", "children"), Input("btn", "n_clicks"))
def show(n):
return f"clicked {n}"
EOF
cd repro
python app/app.py
Then, in a second terminal:
curl -s http://127.0.0.1:8050/_dash-dependencies | python3 -m json.tool
Expected
One Output("out", "children") callback registered — a single entry in /_dash-dependencies for that output.
Actual
The Output("out", "children") callback (and every other page-level callback) is registered twice, byte-identical, because pages/home.py is exec_module'd twice by Dash's pages loader (dash/_pages.py, unconditional spec.loader.exec_module(), no sys.modules check). In a full app this manifests client-side as "Duplicate callback outputs".
Root cause
alias_main_module() (dash/_utils.py, called from dash/dash.py:511, first statement in Dash.__init__) does, roughly:
import_name = canonical_import_name(module_file) # relpath(app/app.py, cwd) -> "app.app"
if import_name is None or import_name in sys.modules:
return
spec = importlib.util.find_spec(import_name) # side effect: imports parent package "app" first
When the script is run as python app/app.py from the parent directory, sys.path[0] is set to app/ (the script's own directory), not the parent. importlib.util.find_spec("app.app") must first import the parent package app to resolve the dotted name — and because app/ is on sys.path, Python resolves app to app/app.py itself (there's no app/__init__.py; a top-level module named app shadows the script). That reimport executes app/app.py again, top-to-bottom, under module name app (not __main__), which calls Dash(use_pages=True) a second time and — because Dash's pages loader doesn't consult sys.modules before exec_module-ing page files — every page-level @callback is appended to dash._callback.GLOBAL_CALLBACK_LIST a second time. find_spec then raises (app "is not a package"), which alias_main_module swallows (except (ImportError, ValueError, OSError): pass), so the bug is silent until the client renders and finds duplicate outputs.
This is the mirror-image of #3818: that fix special-cased uvicorn --reload's dual-process import; this path is a single-process python <subdir>/<same-name>.py launch, which is common enough (app/app.py, src/app.py, any Flask/Dash tutorial's "python app.py" instruction run from one directory up) that it's likely to recur for other users bumping past 4.4.0.
Suggested fix
alias_main_module could avoid the side-effectful find_spec on the dotted name entirely by resolving only the top-level package with importlib.machinery.PathFinder.find_spec(top_level, path=[the parent of module_file's directory]) and comparing paths, without ever importing anything — or by checking top_level not in sys.modules and, if the resulting module's __file__ matches module_file, skip re-registration instead of relying on the find_spec exception path. Alternatively, guard early: skip aliasing entirely when sys.modules[import_name.split(".")[0]] would resolve to the currently-executing __main__ file (i.e., directory name equals script basename).
Versions
- dash 4.4.1 (also reproduced on 4.5.0rc0 wheel inspection —
alias_main_module unchanged)
- Python 3.12
- macOS / Linux (path-shadowing mechanism is OS-independent; not tested on Windows)
Workaround
In the entrypoint file, before constructing Dash(...):
import sys
if __name__ == "__main__":
sys.modules.setdefault("app", sys.modules[__name__])
This pre-seeds sys.modules["app"] so alias_main_module's find_spec returns immediately (first line: if import_name in sys.modules: return — wait, that guard checks import_name i.e. "app.app", not "app"; the workaround instead makes the nested find_spec("app.app") short-circuit because Python's import machinery sees app already in sys.modules and does not re-execute it). No effect under the production gunicorn app:server launch shape (there, caller_name is app, not __main__, so alias_main_module returns at its very first line).
Summary
dash.dash.alias_main_module()(added in #3883, to fixuvicorn.run("app:server", reload=True)double-import, #3818) causes a different double-import when an app's entrypoint file has a directory-name-equals-basename shape (e.g.app/app.py) and is launched withpython app/app.pyfrom the parent directory — the layout used by every "run the script directly" tutorial and by many real apps'READMEs. The self-reimport causes Dash's pages loader to execute every page module a second time, double-registering every page-level@callback, which breaks the client with "Duplicate callback outputs" errors.Confirmed present in 4.4.1 and unchanged in 4.5.0rc0. Not present in 4.4.0 (no
alias_main_modulein that wheel) or 4.3.0.Minimal repro
Then, in a second terminal:
Expected
One
Output("out", "children")callback registered — a single entry in/_dash-dependenciesfor that output.Actual
The
Output("out", "children")callback (and every other page-level callback) is registered twice, byte-identical, becausepages/home.pyisexec_module'd twice by Dash's pages loader (dash/_pages.py, unconditionalspec.loader.exec_module(), nosys.modulescheck). In a full app this manifests client-side as "Duplicate callback outputs".Root cause
alias_main_module()(dash/_utils.py, called fromdash/dash.py:511, first statement inDash.__init__) does, roughly:When the script is run as
python app/app.pyfrom the parent directory,sys.path[0]is set toapp/(the script's own directory), not the parent.importlib.util.find_spec("app.app")must firstimportthe parent packageappto resolve the dotted name — and becauseapp/is onsys.path, Python resolvesapptoapp/app.pyitself (there's noapp/__init__.py; a top-level module namedappshadows the script). That reimport executesapp/app.pyagain, top-to-bottom, under module nameapp(not__main__), which callsDash(use_pages=True)a second time and — because Dash's pages loader doesn't consultsys.modulesbeforeexec_module-ing page files — every page-level@callbackis appended todash._callback.GLOBAL_CALLBACK_LISTa second time.find_specthen raises (app"is not a package"), whichalias_main_moduleswallows (except (ImportError, ValueError, OSError): pass), so the bug is silent until the client renders and finds duplicate outputs.This is the mirror-image of #3818: that fix special-cased
uvicorn --reload's dual-process import; this path is a single-processpython <subdir>/<same-name>.pylaunch, which is common enough (app/app.py,src/app.py, any Flask/Dash tutorial's "python app.py" instruction run from one directory up) that it's likely to recur for other users bumping past 4.4.0.Suggested fix
alias_main_modulecould avoid the side-effectfulfind_specon the dotted name entirely by resolving only the top-level package withimportlib.machinery.PathFinder.find_spec(top_level, path=[the parent of module_file's directory])and comparing paths, without ever importing anything — or by checkingtop_level not in sys.modulesand, if the resulting module's__file__matchesmodule_file, skip re-registration instead of relying on thefind_specexception path. Alternatively, guard early: skip aliasing entirely whensys.modules[import_name.split(".")[0]]would resolve to the currently-executing__main__file (i.e., directory name equals script basename).Versions
alias_main_moduleunchanged)Workaround
In the entrypoint file, before constructing
Dash(...):This pre-seeds
sys.modules["app"]soalias_main_module'sfind_specreturns immediately (first line:if import_name in sys.modules: return— wait, that guard checksimport_namei.e."app.app", not"app"; the workaround instead makes the nestedfind_spec("app.app")short-circuit because Python's import machinery seesappalready insys.modulesand does not re-execute it). No effect under the productiongunicorn app:serverlaunch shape (there,caller_nameisapp, not__main__, soalias_main_modulereturns at its very first line).