Dash 4.4.1: alias_main_module() causes double callback registration when app entrypoint is run as `python <subdir>/<script>.py` from a parent directory
維護者通常 2 天內回覆
還沒有人認領這個 Issue。
評估
- 難度
- 4/5
- 預估耗時
- 3-5 天
- 新手友好度
- 68/100
- Issue 類型
- 缺陷
- 描述清晰度
- 描述清楚
- 活躍度
- 活躍
- 技術堆疊
- python
研究方向
Reproduce the duplicate registration with the app/app.py and pages/home.py layout, then inspect dash/_utils.py (alias_main_module), its call in dash/dash.py, and the loader in dash/_pages.py. Verify the change against the shown /_dash-dependencies request: one page callback should be registered when the entrypoint is run from its parent directory, without regressing the reload-import case.
由索引模型根據 Issue 內容生成。
描述
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_moduleunchanged) - 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).
- 主要語言
- Python
- 星號
- 24.4k
- 分支
- 2.3k
- 平均合併
- 3 天 10 小時
- 30 天內合併 PR
- 24
環境準備
從這裡開始
- 先讀完整個 Issue,再讀專案的貢獻指南。
- 在 Issue 下留言說明你要接手 —— 這能避免兩個人做同樣的事。
- Fork 儲存庫,在一個分支上完成修改。
- 送出 Pull Request,並在描述裡引用這個 Issue 編號。
plotly/dash 的其他 Issue
-
good first issue P3 size: 1 task
難度 2/5 1-3 小時 新手友好度 68/100
維護者通常 2 天內回覆
-
bug P2 plotly-internal size: 3
難度 4/5 3-5 天 新手友好度 48/100
維護者通常 2 天內回覆
-
Shared-storage topics are never freed: streaming leaks ~32 frames per page load可能已有人在做 @T4rk1n 於 5 天前認領。 未關閉bug P1 size: 3
維護者通常 2 天內回覆
-
[BUG] Plotly Cloud devtool route crashes with FastAPI and Quart backends可能已有人在做 @T4rk1n 於 5 天前認領。 未關閉bug P1 size: 1
難度 3/5 1-2 天 新手友好度 65/100
plotly/dash#4008 · 1 則留言 · 已指派 1 人 ·
維護者通常 2 天內回覆
-
enhancement P2 size: 5
難度 5/5 一週以上 新手友好度 45/100
維護者通常 2 天內回覆
相似的 Issue
-
namespace operations
難度 1/5 1 小時以內 新手友好度 82/100
EclipseFdn/open-vsx.org#13573 ·
維護者通常 1 天內回覆
-
難度 2/5 1-3 小時 新手友好度 72/100
collective/icalendar#1854 ·
維護者通常 1 天內回覆
-
難度 2/5 1-3 小時 新手友好度 72/100
rancher/rancher-ai-agent#412 ·
維護者通常 6 天內回覆
-
難度 2/5 1-3 小時 新手友好度 84/100
TUDelftGeodesy/DePSI#134 ·
-
難度 2/5 1-3 小時 新手友好度 88/100
HenriquesLab/rxiv-maker#335 ·