Skip to content
Open
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ This project adheres to [Semantic Versioning](https://semver.org/).
- [#3929](https://github.com/plotly/dash/issues/3929) Fix components that set their own initial state on mount (eg. `dash-bootstrap-components` `Tabs`) not applying it on first render, because descendant layout hashes were reset on the first fresh render (regression introduced in 4.2.0 by [#3570](https://github.com/plotly/dash/pull/3570)).
- [#3948](https://github.com/plotly/dash/issues/3948) Fix page getting progressively slower as callbacks append children
- [#3002](https://github.com/plotly/dash/issues/3002) Fix page loads failing with a 500 error on Windows when Dash is imported from an extended-length (`\\?\`) path, as in JupyterLab Desktop. Package resource paths are now joined with the OS separator instead of `/`.
- [#4011](https://github.com/plotly/dash/issues/4011) Fix extra callback registrations when an app runs from the parent directory: `python app/app.py`, where the script shares its folder's name, registered its callbacks twice, and `python app/main.py` imported a sibling `app/app.py` as the parent package and registered its callbacks too (regression introduced in 4.4.1 by [#3883](https://github.com/plotly/dash/pull/3883)).

## [4.4.1] - 2026-07-21

Expand Down
9 changes: 9 additions & 0 deletions dash/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,15 @@ def alias_main_module(caller_name: str) -> None:
if import_name is None or import_name in sys.modules:
return
try:
# find_spec imports the parent package of a dotted name. Skip the alias
# when that parent is not a package: the import can only fail, and for
# ``python app/app.py`` the parent ``app`` is the running script itself,
# which would execute a second time. See issue #4011.
top_level = import_name.partition(".")[0]
if top_level != import_name and top_level not in sys.modules:
top_spec = importlib.util.find_spec(top_level)
if top_spec is None or top_spec.submodule_search_locations is None:
return
spec = importlib.util.find_spec(import_name)
if (
spec is not None
Expand Down
90 changes: 90 additions & 0 deletions tests/unit/test_main_module_alias.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,3 +166,93 @@ def test_no_alias_when_names_collide(tmp_path, monkeypatch):
for spec in _callback.GLOBAL_CALLBACK_LIST
if spec["output"] != "alias-out.children"
]


SAME_NAME = "dash_test_alias_same"


def test_no_reexecution_when_script_dir_shares_script_name(tmp_path, monkeypatch):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add a case for python app/main.py where app/app.py sits next to it? Before this change, find_spec("app.main") imported that other app.py as the parent package, so any Dash app or callbacks in it got registered too. The fix handles it, but nothing guards it. It's worth a line in the CHANGELOG entry too, since that entry only describes the same-name case.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added.

"""``python app/app.py`` run from the parent directory gives the dotted
name ``app.app``, while only the script's own directory is on sys.path, so
the parent ``app`` resolves to the running script. Resolving the alias
must not execute the script a second time. See issue #4011."""
from dash import _callback

script_dir = tmp_path / SAME_NAME
script_dir.mkdir()
app_file = script_dir / f"{SAME_NAME}.py"
app_file.write_text(APP_SOURCE)

monkeypatch.syspath_prepend(str(script_dir))
monkeypatch.chdir(tmp_path)

try:
_run_as(app_file, "__mp_main__")

assert SAME_NAME not in sys.modules
specs = [
spec
for spec in _callback.GLOBAL_CALLBACK_LIST
if spec["output"] == "alias-out.children"
]
assert len(specs) == 1
finally:
sys.modules.pop("__mp_main__", None)
sys.modules.pop(SAME_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"
]


SIBLING_SOURCE = """
from dash import callback, Output, Input


@callback(Output("sibling-out", "children"), Input("sibling-in", "value"))
def sibling_update(value):
return value
"""

SIBLING_DIR = "dash_test_alias_sibling"


def test_no_sibling_import_when_script_dir_has_same_name_module(tmp_path, monkeypatch):
"""``python app/main.py`` run from the parent directory gives the dotted
name ``app.main``, while only the script's own directory is on sys.path, so
the parent ``app`` resolves to a separate ``app/app.py`` next to the
script. Resolving the alias must not import that file and register its
callbacks. See issue #4011."""
from dash import _callback

script_dir = tmp_path / SIBLING_DIR
script_dir.mkdir()
app_file = script_dir / "main.py"
app_file.write_text(APP_SOURCE)
(script_dir / f"{SIBLING_DIR}.py").write_text(SIBLING_SOURCE)

monkeypatch.syspath_prepend(str(script_dir))
monkeypatch.chdir(tmp_path)

try:
_run_as(app_file, "__mp_main__")

assert SIBLING_DIR not in sys.modules
assert f"{SIBLING_DIR}.main" not in sys.modules
assert not [
spec
for spec in _callback.GLOBAL_CALLBACK_LIST
if spec["output"] == "sibling-out.children"
]
finally:
sys.modules.pop("__mp_main__", None)
sys.modules.pop(SIBLING_DIR, None)
for output in ("alias-out.children", "sibling-out.children"):
_callback.GLOBAL_CALLBACK_MAP.pop(output, None)
_callback.GLOBAL_CALLBACK_LIST[:] = [
spec
for spec in _callback.GLOBAL_CALLBACK_LIST
if spec["output"] not in ("alias-out.children", "sibling-out.children")
]
Loading