diff --git a/CHANGELOG.md b/CHANGELOG.md index 12ac2d9fd5..e9903cff3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to `dash` will be documented in this file. This project adheres to [Semantic Versioning](https://semver.org/). +## [UNRELEASED + +## Fixed + +- [#2479](https://github.com/plotly/dash/pull/2479) Fix `KeyError` "Callback function not found for output [...], , perhaps you forgot to prepend the '@'?" issue when using duplicate callbacks targeting the same output. This issue would occur when the app is restarted or when running with multiple `gunicorn` workers. + ## [2.9.1] - 2023-03-17 ## Fixed diff --git a/dash/_callback.py b/dash/_callback.py index 7e099e0de1..7a21729fc2 100644 --- a/dash/_callback.py +++ b/dash/_callback.py @@ -245,7 +245,7 @@ def insert_callback( output, prevent_initial_call, config_prevent_initial_callbacks ) - callback_id = create_callback_id(output) + callback_id = create_callback_id(output, inputs) callback_spec = { "output": callback_id, "inputs": [c.to_dict() for c in inputs], diff --git a/dash/_utils.py b/dash/_utils.py index fab6219bcd..c43933008c 100644 --- a/dash/_utils.py +++ b/dash/_utils.py @@ -123,15 +123,22 @@ def first(self, *names): return next(iter(self), {}) -def create_callback_id(output): +def create_callback_id(output, inputs): # A single dot within a dict id key or value is OK # but in case of multiple dots together escape each dot # with `\` so we don't mistake it for multi-outputs + hashed_inputs = None + def _concat(x): + nonlocal hashed_inputs _id = x.component_id_str().replace(".", "\\.") + "." + x.component_property if x.allow_duplicate: + if not hashed_inputs: + hashed_inputs = hashlib.md5( + ".".join(str(x) for x in inputs).encode("utf-8") + ).hexdigest() # Actually adds on the property part. - _id += f"@{uuid.uuid4().hex}" + _id += f"@{hashed_inputs}" return _id if isinstance(output, (list, tuple)): diff --git a/dash/testing/composite.py b/dash/testing/composite.py index fb2905b920..8a3abeeaa2 100644 --- a/dash/testing/composite.py +++ b/dash/testing/composite.py @@ -6,14 +6,15 @@ def __init__(self, server, **kwargs): super().__init__(**kwargs) self.server = server - def start_server(self, app, **kwargs): + def start_server(self, app, navigate=True, **kwargs): """Start the local server with app.""" # start server with app and pass Dash arguments self.server(app, **kwargs) - # set the default server_url, it implicitly call wait_for_page - self.server_url = self.server.url + if navigate: + # set the default server_url, it implicitly call wait_for_page + self.server_url = self.server.url class DashRComposite(Browser): diff --git a/tests/integration/test_patch.py b/tests/integration/test_patch.py index 7beb867215..485aaea5fe 100644 --- a/tests/integration/test_patch.py +++ b/tests/integration/test_patch.py @@ -372,3 +372,51 @@ def on_click(_, value): dash_duo.wait_for_text_to_equal("#new-child", "new-child") dash_duo.wait_for_text_to_equal("#initial", "init") + + +def test_pch004_duplicate_output_restart(dash_duo_mp): + # Duplicate output ids should be the same between restarts for the same ids + def create_app(): + app = Dash(__name__) + app.layout = html.Div( + [ + html.Button("Click 1", id="click1"), + html.Button("Click 2", id="click2"), + html.Div(id="output"), + ] + ) + + @app.callback( + Output("output", "children", allow_duplicate=True), + Input("click1", "n_clicks"), + prevent_initial_call=True, + ) + def on_click(_): + return "click 1" + + @app.callback( + Output("output", "children", allow_duplicate=True), + Input("click2", "n_clicks"), + prevent_initial_call=True, + ) + def on_click(_): + return "click 2" + + return app + + dash_duo_mp.start_server(create_app()) + + dash_duo_mp.wait_for_element("#click1").click() + dash_duo_mp.wait_for_text_to_equal("#output", "click 1") + + dash_duo_mp.wait_for_element("#click2").click() + dash_duo_mp.wait_for_text_to_equal("#output", "click 2") + + dash_duo_mp.server.stop() + + dash_duo_mp.start_server(create_app(), navigate=False) + dash_duo_mp.wait_for_element("#click1").click() + dash_duo_mp.wait_for_text_to_equal("#output", "click 1") + + dash_duo_mp.wait_for_element("#click2").click() + dash_duo_mp.wait_for_text_to_equal("#output", "click 2") diff --git a/tests/unit/library/test_grouped_callbacks.py b/tests/unit/library/test_grouped_callbacks.py index cdbe9dc124..d332ea0561 100644 --- a/tests/unit/library/test_grouped_callbacks.py +++ b/tests/unit/library/test_grouped_callbacks.py @@ -41,9 +41,9 @@ def check_output_for_grouping(grouping): mock_fn = mock.Mock() mock_fn.return_value = grouping if multi: - callback_id = create_callback_id(flatten_grouping(outputs)) + callback_id = create_callback_id(flatten_grouping(outputs), []) else: - callback_id = create_callback_id(outputs) + callback_id = create_callback_id(outputs, []) app.callback( outputs,