Skip to content

Commit c5ba38f

Browse files
authored
Merge pull request #1525 from chadaeschliman/input_output_callback
Input output callback
2 parents 3402cb8 + 1cee100 commit c5ba38f

File tree

7 files changed

+233
-65
lines changed

7 files changed

+233
-65
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ This project adheres to [Semantic Versioning](https://semver.org/).
66
### Added
77
- [#1508](https://github.com/plotly/dash/pull/1508) Fix [#1403](https://github.com/plotly/dash/issues/1403): Adds an x button
88
to close the error messages box.
9+
- [#1525](https://github.com/plotly/dash/pull/1525) Adds support for callbacks which have overlapping inputs and outputs. Combined with `dash.callback_context` this addresses many use cases which require circular callbacks.
910

1011
### Changed
1112
- [#1503](https://github.com/plotly/dash/pull/1506) Fix [#1466](https://github.com/plotly/dash/issues/1466): loosen `dash[testing]` requirements for easier integration in external projects. This PR also bumps many `dash[dev]` requirements.

dash-renderer/src/actions/dependencies.js

+93-34
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,6 @@ function validateDependencies(parsedDependencies, dispatchError) {
239239
});
240240

241241
findDuplicateOutputs(outputs, head, dispatchError, outStrs, outObjs);
242-
findInOutOverlap(outputs, inputs, head, dispatchError);
243242
findMismatchedWildcards(outputs, inputs, state, head, dispatchError);
244243
});
245244
}
@@ -364,31 +363,21 @@ function findDuplicateOutputs(outputs, head, dispatchError, outStrs, outObjs) {
364363
});
365364
}
366365

367-
function findInOutOverlap(outputs, inputs, head, dispatchError) {
368-
outputs.forEach((out, outi) => {
369-
const {id: outId, property: outProp} = out;
370-
inputs.forEach((in_, ini) => {
371-
const {id: inId, property: inProp} = in_;
372-
if (outProp !== inProp || typeof outId !== typeof inId) {
373-
return;
374-
}
375-
if (typeof outId === 'string') {
376-
if (outId === inId) {
377-
dispatchError('Same `Input` and `Output`', [
378-
head,
379-
`Input ${ini} (${combineIdAndProp(in_)})`,
380-
`matches Output ${outi} (${combineIdAndProp(out)})`
381-
]);
382-
}
383-
} else if (wildcardOverlap(in_, [out])) {
384-
dispatchError('Same `Input` and `Output`', [
385-
head,
386-
`Input ${ini} (${combineIdAndProp(in_)})`,
387-
'can match the same component(s) as',
388-
`Output ${outi} (${combineIdAndProp(out)})`
389-
]);
366+
function checkInOutOverlap(out, inputs) {
367+
const {id: outId, property: outProp} = out;
368+
return inputs.some(in_ => {
369+
const {id: inId, property: inProp} = in_;
370+
if (outProp !== inProp || typeof outId !== typeof inId) {
371+
return false;
372+
}
373+
if (typeof outId === 'string') {
374+
if (outId === inId) {
375+
return true;
390376
}
391-
});
377+
} else if (wildcardOverlap(in_, [out])) {
378+
return true;
379+
}
380+
return false;
392381
});
393382
}
394383

@@ -749,15 +738,52 @@ export function computeGraphs(dependencies, dispatchError) {
749738
return idList;
750739
}
751740

741+
/* multiGraph is used only for testing circularity
742+
*
743+
* Each component+property that is used as an input or output is added as a node
744+
* to a directed graph with a dependency from each input to each output. The
745+
* function triggerDefaultState in index.js then checks this graph for circularity.
746+
*
747+
* In order to allow the same component+property to be both an input and output
748+
* of the same callback, a two pass approach is used.
749+
*
750+
* In the first pass, the graph is built up normally with the exception that
751+
* in cases where an output is also an input to the same callback a special
752+
* "output" node is added and the dependencies target this output node instead.
753+
* For example, if `slider.value` is both an input and an output, then the a new
754+
* node `slider.value__output` will be added with a dependency from `slider.value`
755+
* to `slider.value__output`. Splitting the input and output into separate nodes
756+
* removes the circularity.
757+
*
758+
* In order to still detect other forms of circularity, it is necessary to do a
759+
* second pass and add the new output nodes as a dependency in any *other* callbacks
760+
* where the original node was an input. Continuing the example, any other callback
761+
* that had `slider.value` as an input dependency also needs to have
762+
* `slider.value__output` as a dependency. To make this efficient, all the inputs
763+
* and outputs for each callback are stored during the first pass.
764+
*/
765+
766+
const outputTag = '__output';
767+
const duplicateOutputs = [];
768+
const cbIn = [];
769+
const cbOut = [];
770+
771+
function addInputToMulti(inIdProp, outIdProp, firstPass = true) {
772+
multiGraph.addNode(inIdProp);
773+
multiGraph.addDependency(inIdProp, outIdProp);
774+
// only store callback inputs and outputs during the first pass
775+
if (firstPass) {
776+
cbIn[cbIn.length - 1].push(inIdProp);
777+
cbOut[cbOut.length - 1].push(outIdProp);
778+
}
779+
}
780+
752781
parsedDependencies.forEach(function registerDependency(dependency) {
753782
const {outputs, inputs} = dependency;
754783

755-
// multiGraph - just for testing circularity
756-
757-
function addInputToMulti(inIdProp, outIdProp) {
758-
multiGraph.addNode(inIdProp);
759-
multiGraph.addDependency(inIdProp, outIdProp);
760-
}
784+
// new callback, add an empty array for its inputs and outputs
785+
cbIn.push([]);
786+
cbOut.push([]);
761787

762788
function addOutputToMulti(outIdFinal, outIdProp) {
763789
multiGraph.addNode(outIdProp);
@@ -791,15 +817,29 @@ export function computeGraphs(dependencies, dispatchError) {
791817

792818
outputs.forEach(outIdProp => {
793819
const {id: outId, property} = outIdProp;
820+
// check if this output is also an input to the same callback
821+
const alsoInput = checkInOutOverlap(outIdProp, inputs);
794822
if (typeof outId === 'object') {
795823
const outIdList = makeAllIds(outId, {});
796824
outIdList.forEach(id => {
797-
addOutputToMulti(id, combineIdAndProp({id, property}));
825+
const tempOutIdProp = {id, property};
826+
let outIdName = combineIdAndProp(tempOutIdProp);
827+
// if this output is also an input, add `outputTag` to the name
828+
if (alsoInput) {
829+
duplicateOutputs.push(tempOutIdProp);
830+
outIdName += outputTag;
831+
}
832+
addOutputToMulti(id, outIdName);
798833
});
799-
800834
addPattern(outputPatterns, outId, property, finalDependency);
801835
} else {
802-
addOutputToMulti({}, combineIdAndProp(outIdProp));
836+
let outIdName = combineIdAndProp(outIdProp);
837+
// if this output is also an input, add `outputTag` to the name
838+
if (alsoInput) {
839+
duplicateOutputs.push(outIdProp);
840+
outIdName += outputTag;
841+
}
842+
addOutputToMulti({}, outIdName);
803843
addMap(outputMap, outId, property, finalDependency);
804844
}
805845
});
@@ -814,6 +854,25 @@ export function computeGraphs(dependencies, dispatchError) {
814854
});
815855
});
816856

857+
// second pass for adding new output nodes as dependencies where needed
858+
duplicateOutputs.forEach(dupeOutIdProp => {
859+
const originalName = combineIdAndProp(dupeOutIdProp);
860+
const newName = originalName.concat(outputTag);
861+
for (var cnt = 0; cnt < cbIn.length; cnt++) {
862+
// check if input to the callback
863+
if (cbIn[cnt].some(inName => inName === originalName)) {
864+
/* make sure it's not also an output of the callback
865+
* (this will be the original callback)
866+
*/
867+
if (!cbOut[cnt].some(outName => outName === newName)) {
868+
cbOut[cnt].forEach(outName => {
869+
addInputToMulti(newName, outName, false);
870+
});
871+
}
872+
}
873+
}
874+
});
875+
817876
return finalGraphs;
818877
}
819878

dash-renderer/src/actions/dependencies_ts.ts

+6-2
Original file line numberDiff line numberDiff line change
@@ -167,11 +167,15 @@ export const getReadyCallbacks = (
167167
forEach(output => (outputsMap[output] = true), outputs);
168168

169169
// Find `requested` callbacks that do not depend on a outstanding output (as either input or state)
170+
// Outputs which overlap an input do not count as an outstanding output
170171
return filter(
171172
cb =>
172-
all(
173+
all<ILayoutCallbackProperty>(
173174
cbp => !outputsMap[combineIdAndProp(cbp)],
174-
flatten(cb.getInputs(paths))
175+
difference(
176+
flatten(cb.getInputs(paths)),
177+
flatten(cb.getOutputs(paths))
178+
)
175179
),
176180
candidates
177181
);

tests/integration/callbacks/test_basic_callback.py

+45
Original file line numberDiff line numberDiff line change
@@ -651,3 +651,48 @@ def update_output(n1, t1, n2, t2):
651651
assert timestamp_2.value > timestamp_1.value
652652
assert call_count.value == 4
653653
dash_duo.percy_snapshot("button-2 click again")
654+
655+
656+
def test_cbsc015_input_output_callback(dash_duo):
657+
lock = Lock()
658+
659+
app = dash.Dash(__name__)
660+
app.layout = html.Div(
661+
[html.Div("0", id="input-text"), dcc.Input(id="input", type="number", value=0)]
662+
)
663+
664+
@app.callback(
665+
Output("input", "value"), Input("input", "value"),
666+
)
667+
def circular_output(v):
668+
ctx = dash.callback_context
669+
if not ctx.triggered:
670+
value = v
671+
else:
672+
value = v + 1
673+
return value
674+
675+
call_count = Value("i", 0)
676+
677+
@app.callback(
678+
Output("input-text", "children"), Input("input", "value"),
679+
)
680+
def follower_output(v):
681+
with lock:
682+
call_count.value = call_count.value + 1
683+
return str(v)
684+
685+
dash_duo.start_server(app)
686+
687+
input_ = dash_duo.find_element("#input")
688+
for key in "2":
689+
with lock:
690+
input_.send_keys(key)
691+
692+
wait.until(lambda: dash_duo.find_element("#input-text").text == "3", 2)
693+
694+
assert call_count.value == 2, "initial + changed once"
695+
696+
assert not dash_duo.redux_state_is_loading
697+
698+
assert dash_duo.get_logs() == []

tests/integration/clientside/assets/clientside.js

+17
Original file line numberDiff line numberDiff line change
@@ -81,5 +81,22 @@ window.dash_clientside.clientside = {
8181

8282
states_list_to_str: function(val0, val1, st0, st1) {
8383
return JSON.stringify(dash_clientside.callback_context.states_list);
84+
},
85+
86+
input_output_callback: function(inputValue) {
87+
const triggered = dash_clientside.callback_context.triggered;
88+
if (triggered.length==0){
89+
return inputValue;
90+
} else {
91+
return inputValue + 1;
92+
}
93+
},
94+
95+
input_output_follower: function(inputValue) {
96+
if (!window.callCount) {
97+
window.callCount = 0
98+
}
99+
window.callCount += 1;
100+
return inputValue.toString();
84101
}
85102
};

tests/integration/clientside/test_clientside.py

+34
Original file line numberDiff line numberDiff line change
@@ -683,3 +683,37 @@ def test_clsd013_clientside_callback_context_states_list(dash_duo):
683683
'{"id":{"in1":2},"property":"value","value":"test 2"}]]'
684684
),
685685
)
686+
687+
688+
def test_clsd014_input_output_callback(dash_duo):
689+
app = Dash(__name__, assets_folder="assets")
690+
691+
app.layout = html.Div(
692+
[html.Div(id="input-text"), dcc.Input(id="input", type="number", value=0)]
693+
)
694+
695+
app.clientside_callback(
696+
ClientsideFunction(
697+
namespace="clientside", function_name="input_output_callback"
698+
),
699+
Output("input", "value"),
700+
Input("input", "value"),
701+
)
702+
703+
app.clientside_callback(
704+
ClientsideFunction(
705+
namespace="clientside", function_name="input_output_follower"
706+
),
707+
Output("input-text", "children"),
708+
Input("input", "value"),
709+
)
710+
711+
dash_duo.start_server(app)
712+
713+
dash_duo.find_element("#input").send_keys("2")
714+
dash_duo.wait_for_text_to_equal("#input-text", "3")
715+
call_count = dash_duo.driver.execute_script("return window.callCount;")
716+
717+
assert call_count == 2, "initial + changed once"
718+
719+
assert dash_duo.get_logs() == []

tests/integration/devtools/test_callback_validation.py

+37-29
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import dash_html_components as html
66
from dash import Dash
77
from dash.dependencies import Input, Output, State, MATCH, ALL, ALLSMALLER
8+
from dash.testing import wait
89

910
debugging = dict(
1011
debug=True, use_reloader=False, use_debugger=True, dev_tools_hot_reload=False
@@ -91,9 +92,7 @@ def x(a):
9192

9293
dash_duo.start_server(app, **debugging)
9394

94-
# the first one is just an artifact... the other 4 we care about
9595
specs = [
96-
["Same `Input` and `Output`", []],
9796
[
9897
"Callback item missing ID",
9998
['Input[0].id = ""', "Every item linked to a callback needs an ID"],
@@ -252,33 +251,9 @@ def y2(c):
252251

253252
dash_duo.start_server(app, **debugging)
254253

255-
specs = [
256-
[
257-
"Same `Input` and `Output`",
258-
[
259-
'Input 0 ({"b":MATCH,"c":1}.children)',
260-
"can match the same component(s) as",
261-
'Output 1 ({"b":MATCH,"c":1}.children)',
262-
],
263-
],
264-
[
265-
"Same `Input` and `Output`",
266-
[
267-
'Input 0 ({"a":1}.children)',
268-
"can match the same component(s) as",
269-
'Output 0 ({"a":ALL}.children)',
270-
],
271-
],
272-
[
273-
"Same `Input` and `Output`",
274-
["Input 0 (c.children)", "matches Output 1 (c.children)"],
275-
],
276-
[
277-
"Same `Input` and `Output`",
278-
["Input 0 (a.children)", "matches Output 0 (a.children)"],
279-
],
280-
]
281-
check_errors(dash_duo, specs)
254+
# input/output overlap is now legal, shouldn't throw any errors
255+
wait.until(lambda: ~dash_duo.redux_state_is_loading, 2)
256+
assert dash_duo.get_logs() == []
282257

283258

284259
def test_dvcv006_inconsistent_wildcards(dash_duo):
@@ -814,3 +789,36 @@ def test_dvcv015_multipage_validation_layout(validation, dash_duo):
814789
dash_duo.wait_for_text_to_equal("#page-2-display-value", 'You have selected "LA"')
815790

816791
assert not dash_duo.get_logs()
792+
793+
794+
def test_dvcv016_circular_with_input_output(dash_duo):
795+
app = Dash(__name__)
796+
797+
app.layout = html.Div(
798+
[html.Div([], id="a"), html.Div(["Bye"], id="b"), html.Div(["Hello"], id="c")]
799+
)
800+
801+
@app.callback(
802+
[Output("a", "children"), Output("b", "children")],
803+
[Input("a", "children"), Input("b", "children"), Input("c", "children")],
804+
)
805+
def c1(a, b, c):
806+
return a, b
807+
808+
@app.callback(Output("c", "children"), [Input("a", "children")])
809+
def c2(children):
810+
return children
811+
812+
dash_duo.start_server(app, **debugging)
813+
814+
specs = [
815+
[
816+
"Circular Dependencies",
817+
[
818+
"Dependency Cycle Found:",
819+
"a.children__output -> c.children",
820+
"c.children -> a.children__output",
821+
],
822+
]
823+
]
824+
check_errors(dash_duo, specs)

0 commit comments

Comments
 (0)