Skip to content

Commit

Permalink
Merge pull request #2479 from plotly/fix-duplicate-outputs
Browse files Browse the repository at this point in the history
Deterministic duplicate outputs
  • Loading branch information
T4rk1n committed Mar 28, 2023
2 parents 6828bb5 + 1ab7aba commit fde5033
Show file tree
Hide file tree
Showing 6 changed files with 70 additions and 8 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion dash/_callback.py
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
11 changes: 9 additions & 2 deletions dash/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)):
Expand Down
7 changes: 4 additions & 3 deletions dash/testing/composite.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
48 changes: 48 additions & 0 deletions tests/integration/test_patch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
4 changes: 2 additions & 2 deletions tests/unit/library/test_grouped_callbacks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down

0 comments on commit fde5033

Please sign in to comment.