From 36d7cd012db749244e70824d4373149c6c95889e Mon Sep 17 00:00:00 2001 From: philippe Date: Tue, 28 Mar 2023 10:50:59 -0400 Subject: [PATCH 1/5] Add test_duplicate_output_restart --- tests/integration/test_patch.py | 48 +++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) 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") From 1cae4041dcc711f48b4abe5c33ecdf317eadbf47 Mon Sep 17 00:00:00 2001 From: philippe Date: Tue, 28 Mar 2023 11:00:53 -0400 Subject: [PATCH 2/5] Deterministic duplicate outputs. --- dash/_callback.py | 2 +- dash/_utils.py | 8 ++++++-- dash/testing/composite.py | 7 ++++--- tests/unit/library/test_grouped_callbacks.py | 4 ++-- 4 files changed, 13 insertions(+), 8 deletions(-) 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..1b67aca1b8 100644 --- a/dash/_utils.py +++ b/dash/_utils.py @@ -123,15 +123,19 @@ 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 = hashlib.md5( + ".".join(str(x) for x in inputs).encode("utf-8") + ).hexdigest() + def _concat(x): _id = x.component_id_str().replace(".", "\\.") + "." + x.component_property if x.allow_duplicate: # 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/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, From dcc0c31b83cdbcd56722a18d56c7802507e959c3 Mon Sep 17 00:00:00 2001 From: philippe Date: Tue, 28 Mar 2023 11:12:58 -0400 Subject: [PATCH 3/5] Update changelog. --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 12ac2d9fd5..6afc64a364 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 when duplicate outputs id being different on restarts. + ## [2.9.1] - 2023-03-17 ## Fixed From cac60a5f3120ffee92b56e4a82ef0e93006cae01 Mon Sep 17 00:00:00 2001 From: Philippe Duval Date: Tue, 28 Mar 2023 13:41:39 -0400 Subject: [PATCH 4/5] Update CHANGELOG.md Co-authored-by: Chris Parmer --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6afc64a364..e9903cff3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ This project adheres to [Semantic Versioning](https://semver.org/). ## Fixed -- [#2479](https://github.com/plotly/dash/pull/2479) Fix KeyError when duplicate outputs id being different on restarts. +- [#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 From 1ab7aba837c42459c470b0b7857fcc01f0a18a1f Mon Sep 17 00:00:00 2001 From: philippe Date: Tue, 28 Mar 2023 13:57:39 -0400 Subject: [PATCH 5/5] Only hash inputs if necessary --- dash/_utils.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/dash/_utils.py b/dash/_utils.py index 1b67aca1b8..c43933008c 100644 --- a/dash/_utils.py +++ b/dash/_utils.py @@ -127,13 +127,16 @@ 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 = hashlib.md5( - ".".join(str(x) for x in inputs).encode("utf-8") - ).hexdigest() + 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"@{hashed_inputs}" return _id