diff --git a/CHANGELOG.md b/CHANGELOG.md index 90e553fa1b..7966e3959a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ This project adheres to [Semantic Versioning](https://semver.org/). ### Added - [#1355](https://github.com/plotly/dash/pull/1355) Removed redundant log message and consolidated logger initialization. You can now control the log level - for example suppress informational messages from Dash with `app.logger.setLevel(logging.WARNING)`. +### Changed +- [#1180](https://github.com/plotly/dash/pull/1180) `Input`, `Output`, and `State` in callback definitions don't need to be in lists. You still need to provide `Output` items first, then `Input` items, then `State`, and the list form is still supported. In particular, if you want to return a single output item wrapped in a length-1 list, you should still wrap the `Output` in a list. This can be useful for procedurally-generated callbacks. + ## [1.14.0] - 2020-07-27 ### Added - [#1343](https://github.com/plotly/dash/pull/1343) Add `title` parameter to set the diff --git a/dash/_validate.py b/dash/_validate.py index e5bd3e7bcb..76047242a6 100644 --- a/dash/_validate.py +++ b/dash/_validate.py @@ -2,73 +2,78 @@ import re from .development.base_component import Component -from .dependencies import Input, Output, State from . import exceptions from ._utils import patch_collections_abc, _strings, stringify_id -def validate_callback(output, inputs, state): +def validate_callback(output, inputs, state, extra_args, types): is_multi = isinstance(output, (list, tuple)) outputs = output if is_multi else [output] - for args, cls in [(outputs, Output), (inputs, Input), (state, State)]: - validate_callback_args(args, cls) - + Input, Output, State = types + if extra_args: + if not isinstance(extra_args[0], (Output, Input, State)): + raise exceptions.IncorrectTypeException( + """ + Callback arguments must be `Output`, `Input`, or `State` objects, + optionally wrapped in a list or tuple. We found (possibly after + unwrapping a list or tuple): + {} + """.format( + repr(extra_args[0]) + ) + ) -def validate_callback_args(args, cls): - name = cls.__name__ - if not isinstance(args, (list, tuple)): raise exceptions.IncorrectTypeException( """ - The {} argument `{}` must be a list or tuple of - `dash.dependencies.{}`s. + In a callback definition, you must provide all Outputs first, + then all Inputs, then all States. After this item: + {} + we found this item next: + {} """.format( - name.lower(), str(args), name + repr((outputs + inputs + state)[-1]), repr(extra_args[0]) ) ) - for arg in args: - if not isinstance(arg, cls): - raise exceptions.IncorrectTypeException( - """ - The {} argument `{}` must be of type `dash.dependencies.{}`. - """.format( - name.lower(), str(arg), name - ) - ) + for args in [outputs, inputs, state]: + for arg in args: + validate_callback_arg(arg) - if not isinstance(getattr(arg, "component_property", None), _strings): - raise exceptions.IncorrectTypeException( - """ - component_property must be a string, found {!r} - """.format( - arg.component_property - ) - ) - if hasattr(arg, "component_event"): - raise exceptions.NonExistentEventException( - """ - Events have been removed. - Use the associated property instead. - """ +def validate_callback_arg(arg): + if not isinstance(getattr(arg, "component_property", None), _strings): + raise exceptions.IncorrectTypeException( + """ + component_property must be a string, found {!r} + """.format( + arg.component_property ) + ) - if isinstance(arg.component_id, dict): - validate_id_dict(arg) + if hasattr(arg, "component_event"): + raise exceptions.NonExistentEventException( + """ + Events have been removed. + Use the associated property instead. + """ + ) - elif isinstance(arg.component_id, _strings): - validate_id_string(arg) + if isinstance(arg.component_id, dict): + validate_id_dict(arg) - else: - raise exceptions.IncorrectTypeException( - """ - component_id must be a string or dict, found {!r} - """.format( - arg.component_id - ) + elif isinstance(arg.component_id, _strings): + validate_id_string(arg) + + else: + raise exceptions.IncorrectTypeException( + """ + component_id must be a string or dict, found {!r} + """.format( + arg.component_id ) + ) def validate_id_dict(arg): diff --git a/dash/dash.py b/dash/dash.py index fb89417a0f..ccce7c2418 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -23,6 +23,7 @@ from .fingerprint import build_fingerprint, check_fingerprint from .resources import Scripts, Css +from .dependencies import handle_callback_args from .development.base_component import ComponentRegistry from .exceptions import PreventUpdate, InvalidResourceError, ProxyError from .version import __version__ @@ -848,7 +849,6 @@ def _insert_callback(self, output, inputs, state, prevent_initial_call): if prevent_initial_call is None: prevent_initial_call = self.config.prevent_initial_callbacks - _validate.validate_callback(output, inputs, state) callback_id = create_callback_id(output) callback_spec = { "output": callback_id, @@ -865,9 +865,7 @@ def _insert_callback(self, output, inputs, state, prevent_initial_call): return callback_id - def clientside_callback( - self, clientside_function, output, inputs, state=(), prevent_initial_call=None - ): + def clientside_callback(self, clientside_function, *args, **kwargs): """Create a callback that updates the output by calling a clientside (JavaScript) function instead of a Python function. @@ -932,6 +930,7 @@ def clientside_callback( not to fire when its outputs are first added to the page. Defaults to `False` unless `prevent_initial_callbacks=True` at the app level. """ + output, inputs, state, prevent_initial_call = handle_callback_args(args, kwargs) self._insert_callback(output, inputs, state, prevent_initial_call) # If JS source is explicitly given, create a namespace and function @@ -963,18 +962,23 @@ def clientside_callback( "function_name": function_name, } - def callback(self, output, inputs, state=(), prevent_initial_call=None): + def callback(self, *_args, **_kwargs): """ Normally used as a decorator, `@app.callback` provides a server-side - callback relating the values of one or more `output` items to one or - more `input` items which will trigger the callback when they change, - and optionally `state` items which provide additional information but + callback relating the values of one or more `Output` items to one or + more `Input` items which will trigger the callback when they change, + and optionally `State` items which provide additional information but do not trigger the callback directly. The last, optional argument `prevent_initial_call` causes the callback not to fire when its outputs are first added to the page. Defaults to `False` unless `prevent_initial_callbacks=True` at the app level. + + """ + output, inputs, state, prevent_initial_call = handle_callback_args( + _args, _kwargs + ) callback_id = self._insert_callback(output, inputs, state, prevent_initial_call) multi = isinstance(output, (list, tuple)) @@ -1046,7 +1050,7 @@ def dispatch(self): response = flask.g.dash_response = flask.Response(mimetype="application/json") - args = inputs_to_vals(inputs) + inputs_to_vals(state) + args = inputs_to_vals(inputs + state) func = self.callback_map[output]["callback"] response.set_data(func(*args, outputs_list=outputs_list)) diff --git a/dash/dependencies.py b/dash/dependencies.py index fa79b842d5..e4ec42f86b 100644 --- a/dash/dependencies.py +++ b/dash/dependencies.py @@ -1,5 +1,7 @@ import json +from ._validate import validate_callback + class _Wildcard: # pylint: disable=too-few-public-methods def __init__(self, name): @@ -133,3 +135,40 @@ def __init__(self, namespace=None, function_name=None): def __repr__(self): return "ClientsideFunction({}, {})".format(self.namespace, self.function_name) + + +def extract_callback_args(args, kwargs, name, type_): + """Extract arguments for callback from a name and type""" + parameters = kwargs.get(name, []) + if not parameters: + while args and isinstance(args[0], type_): + parameters.append(args.pop(0)) + return parameters + + +def handle_callback_args(args, kwargs): + """Split args into outputs, inputs and states""" + prevent_initial_call = kwargs.get("prevent_initial_call", None) + if prevent_initial_call is None and args and isinstance(args[-1], bool): + prevent_initial_call = args.pop() + + # flatten args, to support the older syntax where outputs, inputs, and states + # each needed to be in their own list + flat_args = [] + for arg in args: + flat_args += arg if isinstance(arg, (list, tuple)) else [arg] + + outputs = extract_callback_args(flat_args, kwargs, "output", Output) + validate_outputs = outputs + if len(outputs) == 1: + out0 = kwargs.get("output", args[0] if args else None) + if not isinstance(out0, (list, tuple)): + outputs = outputs[0] + + inputs = extract_callback_args(flat_args, kwargs, "inputs", Input) + states = extract_callback_args(flat_args, kwargs, "state", State) + + types = Input, Output, State + validate_callback(validate_outputs, inputs, states, flat_args, types) + + return outputs, inputs, states, prevent_initial_call diff --git a/package.json b/package.json index 10a412002d..3ab93fdd9e 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "private::format.black": "black dash tests --exclude metadata_test.py", "private::format.renderer": "cd dash-renderer && npm run format", "private::initialize.renderer": "cd dash-renderer && npm ci", - "private::lint.black": "if [[ $PYLINTRC != '.pylintrc' ]]; then black dash tests --exclude metadata_test.py --check; fi", + "private::lint.black": "if [ ${PYLINTRC:-x} != '.pylintrc' ]; then black dash tests --exclude metadata_test.py --check; fi", "private::lint.flake8": "flake8 --exclude=metadata_test.py dash tests", "private::lint.pylint-dash": "PYLINTRC=${PYLINTRC:=.pylintrc37} && pylint dash setup.py --rcfile=$PYLINTRC", "private::lint.pylint-tests": "PYLINTRC=${PYLINTRC:=.pylintrc37} && pylint tests/unit tests/integration -d all --rcfile=$PYLINTRC", diff --git a/tests/assets/simple_app.py b/tests/assets/simple_app.py index 3e485c0890..71a525cebd 100644 --- a/tests/assets/simple_app.py +++ b/tests/assets/simple_app.py @@ -18,7 +18,7 @@ ) -@app.callback(Output("out", "children"), [Input("value", "value")]) +@app.callback(Output("out", "children"), Input("value", "value")) def on_value(value): if value is None: raise PreventUpdate diff --git a/tests/integration/callbacks/test_basic_callback.py b/tests/integration/callbacks/test_basic_callback.py index f7f37c2f47..13276b1899 100644 --- a/tests/integration/callbacks/test_basic_callback.py +++ b/tests/integration/callbacks/test_basic_callback.py @@ -342,3 +342,53 @@ def set_path(n): if not refresh: dash_duo.find_element("#btn").click() dash_duo.wait_for_text_to_equal("#out", '[{"a": "/2:a"}] - /2') + + +def test_cbsc008_wildcard_prop_callbacks(dash_duo): + app = dash.Dash(__name__) + app.layout = html.Div( + [ + dcc.Input(id="input", value="initial value"), + html.Div( + html.Div( + [ + 1.5, + None, + "string", + html.Div( + id="output-1", + **{"data-cb": "initial value", "aria-cb": "initial value"} + ), + ] + ) + ), + ] + ) + + input_call_count = Value("i", 0) + + @app.callback(Output("output-1", "data-cb"), [Input("input", "value")]) + def update_data(value): + input_call_count.value += 1 + return value + + @app.callback(Output("output-1", "children"), [Input("output-1", "data-cb")]) + def update_text(data): + return data + + dash_duo.start_server(app) + dash_duo.wait_for_text_to_equal("#output-1", "initial value") + dash_duo.percy_snapshot(name="wildcard-callback-1") + + input1 = dash_duo.find_element("#input") + dash_duo.clear_input(input1) + input1.send_keys("hello world") + + dash_duo.wait_for_text_to_equal("#output-1", "hello world") + dash_duo.percy_snapshot(name="wildcard-callback-2") + + # an initial call, one for clearing the input + # and one for each hello world character + assert input_call_count.value == 2 + len("hello world") + + assert not dash_duo.get_logs() diff --git a/tests/integration/callbacks/test_prevent_update.py b/tests/integration/callbacks/test_prevent_update.py new file mode 100644 index 0000000000..248a3e05b3 --- /dev/null +++ b/tests/integration/callbacks/test_prevent_update.py @@ -0,0 +1,167 @@ +from multiprocessing import Value + +from copy import copy +from selenium.webdriver.common.keys import Keys + +import dash_core_components as dcc +import dash_html_components as html +from dash import Dash, no_update +from dash.dependencies import Input, Output, State +from dash.exceptions import PreventUpdate + +from dash.testing.wait import until + + +def test_cbpu001_aborted_callback(dash_duo): + """Raising PreventUpdate OR returning no_update prevents update and + triggering dependencies.""" + + initial_input = "initial input" + initial_output = "initial output" + + app = Dash(__name__) + app.layout = html.Div( + [ + dcc.Input(id="input", value=initial_input), + html.Div(initial_output, id="output1"), + html.Div(initial_output, id="output2"), + ] + ) + + callback1_count = Value("i", 0) + callback2_count = Value("i", 0) + + @app.callback(Output("output1", "children"), [Input("input", "value")]) + def callback1(value): + callback1_count.value += 1 + if callback1_count.value > 2: + return no_update + raise PreventUpdate("testing callback does not update") + return value + + @app.callback(Output("output2", "children"), [Input("output1", "children")]) + def callback2(value): + callback2_count.value += 1 + return value + + dash_duo.start_server(app) + + input_ = dash_duo.find_element("#input") + input_.send_keys("xyz") + dash_duo.wait_for_text_to_equal("#input", "initial inputxyz") + + until( + lambda: callback1_count.value == 4, + timeout=3, + msg="callback1 runs 4x (initial page load and 3x through send_keys)", + ) + + assert ( + callback2_count.value == 0 + ), "callback2 is never triggered, even on initial load" + + # double check that output1 and output2 children were not updated + assert dash_duo.find_element("#output1").text == initial_output + assert dash_duo.find_element("#output2").text == initial_output + + assert not dash_duo.get_logs() + + dash_duo.percy_snapshot(name="aborted") + + +def test_cbpu002_multi_output_no_update(dash_duo): + app = Dash(__name__) + + app.layout = html.Div( + [ + html.Button("B", "btn"), + html.P("initial1", "n1"), + html.P("initial2", "n2"), + html.P("initial3", "n3"), + ] + ) + + @app.callback( + [Output("n1", "children"), Output("n2", "children"), Output("n3", "children")], + [Input("btn", "n_clicks")], + ) + def show_clicks(n): + # partial or complete cancelation of updates via no_update + return [ + no_update if n and n > 4 else n, + no_update if n and n > 2 else n, + # make a new instance, to mock up caching and restoring no_update + copy(no_update), + ] + + dash_duo.start_server(app) + + dash_duo.multiple_click("#btn", 10) + + dash_duo.wait_for_text_to_equal("#n1", "4") + dash_duo.wait_for_text_to_equal("#n2", "2") + dash_duo.wait_for_text_to_equal("#n3", "initial3") + + +def test_cbpu003_no_update_chains(dash_duo): + app = Dash(__name__) + + app.layout = html.Div( + [ + dcc.Input(id="a_in", value="a"), + dcc.Input(id="b_in", value="b"), + html.P("", id="a_out"), + html.P("", id="a_out_short"), + html.P("", id="b_out"), + html.P("", id="ab_out"), + ] + ) + + @app.callback( + [Output("a_out", "children"), Output("a_out_short", "children")], + [Input("a_in", "value")], + ) + def a_out(a): + return a, a if len(a) < 3 else no_update + + @app.callback(Output("b_out", "children"), [Input("b_in", "value")]) + def b_out(b): + return b + + @app.callback( + Output("ab_out", "children"), + [Input("a_out_short", "children")], + [State("b_out", "children")], + ) + def ab_out(a, b): + return a + " " + b + + dash_duo.start_server(app) + + a_in = dash_duo.find_element("#a_in") + b_in = dash_duo.find_element("#b_in") + + b_in.send_keys("b") + a_in.send_keys("a") + dash_duo.wait_for_text_to_equal("#a_out", "aa") + dash_duo.wait_for_text_to_equal("#b_out", "bb") + dash_duo.wait_for_text_to_equal("#a_out_short", "aa") + dash_duo.wait_for_text_to_equal("#ab_out", "aa bb") + + b_in.send_keys("b") + a_in.send_keys("a") + dash_duo.wait_for_text_to_equal("#a_out", "aaa") + dash_duo.wait_for_text_to_equal("#b_out", "bbb") + dash_duo.wait_for_text_to_equal("#a_out_short", "aa") + # ab_out has not been triggered because a_out_short received no_update + dash_duo.wait_for_text_to_equal("#ab_out", "aa bb") + + b_in.send_keys("b") + a_in.send_keys(Keys.END) + a_in.send_keys(Keys.BACKSPACE) + dash_duo.wait_for_text_to_equal("#a_out", "aa") + dash_duo.wait_for_text_to_equal("#b_out", "bbbb") + dash_duo.wait_for_text_to_equal("#a_out_short", "aa") + # now ab_out *is* triggered - a_out_short got a new value + # even though that value is the same as the last value it got + dash_duo.wait_for_text_to_equal("#ab_out", "aa bbbb") diff --git a/tests/integration/callbacks/test_validation.py b/tests/integration/callbacks/test_validation.py new file mode 100644 index 0000000000..249eb6172c --- /dev/null +++ b/tests/integration/callbacks/test_validation.py @@ -0,0 +1,146 @@ +import pytest + +import dash_html_components as html + +from dash import Dash + +from dash.dependencies import Input, Output, State +from dash.exceptions import InvalidCallbackReturnValue, IncorrectTypeException + + +def test_cbva001_callback_dep_types(): + app = Dash(__name__) + app.layout = html.Div( + [ + html.Div("child", id="in1"), + html.Div("state", id="state1"), + html.Div(id="out1"), + html.Div("child", id="in2"), + html.Div("state", id="state2"), + html.Div(id="out2"), + html.Div("child", id="in3"), + html.Div("state", id="state3"), + html.Div(id="out3"), + ] + ) + + with pytest.raises(IncorrectTypeException) as err: + + @app.callback([[Output("out1", "children")]], [Input("in1", "children")]) + def f(i): + return i + + pytest.fail("extra output nesting") + + assert "must be `Output`, `Input`, or `State`" in err.value.args[0] + assert "[]" in err.value.args[0] + + with pytest.raises(IncorrectTypeException) as err: + + @app.callback(Input("in1", "children"), Output("out1", "children")) + def f2(i): + return i + + pytest.fail("out-of-order args") + + assert "Outputs first,\nthen all Inputs, then all States." in err.value.args[0] + assert "" in err.value.args[0] + assert "" in err.value.args[0] + + # all OK with tuples + @app.callback( + (Output("out1", "children"),), + (Input("in1", "children"),), + (State("state1", "children"),), + ) + def f1(i): + return i + + # all OK with all args in single list + @app.callback( + Output("out2", "children"), + Input("in2", "children"), + State("state2", "children"), + ) + def f2(i): + return i + + # all OK with lists + @app.callback( + [Output("out3", "children")], + [Input("in3", "children")], + [State("state3", "children")], + ) + def f3(i): + return i + + +def test_cbva002_callback_return_validation(): + app = Dash(__name__) + app.layout = html.Div( + [ + html.Div(id="a"), + html.Div(id="b"), + html.Div(id="c"), + html.Div(id="d"), + html.Div(id="e"), + html.Div(id="f"), + ] + ) + + @app.callback(Output("b", "children"), [Input("a", "children")]) + def single(a): + return set([1]) + + with pytest.raises(InvalidCallbackReturnValue): + # outputs_list (normally callback_context.outputs_list) is provided + # by the dispatcher from the request. + single("aaa", outputs_list={"id": "b", "property": "children"}) + pytest.fail("not serializable") + + @app.callback( + [Output("c", "children"), Output("d", "children")], [Input("a", "children")] + ) + def multi(a): + return [1, set([2])] + + with pytest.raises(InvalidCallbackReturnValue): + outputs_list = [ + {"id": "c", "property": "children"}, + {"id": "d", "property": "children"}, + ] + multi("aaa", outputs_list=outputs_list) + pytest.fail("nested non-serializable") + + @app.callback( + [Output("e", "children"), Output("f", "children")], [Input("a", "children")] + ) + def multi2(a): + return ["abc"] + + with pytest.raises(InvalidCallbackReturnValue): + outputs_list = [ + {"id": "e", "property": "children"}, + {"id": "f", "property": "children"}, + ] + multi2("aaa", outputs_list=outputs_list) + pytest.fail("wrong-length list") + + +def test_cbva003_list_single_output(dash_duo): + app = Dash(__name__) + app.layout = html.Div( + [html.Div("Hi", id="in"), html.Div(id="out1"), html.Div(id="out2")] + ) + + @app.callback(Output("out1", "children"), Input("in", "children")) + def o1(i): + return "1: " + i + + @app.callback([Output("out2", "children")], [Input("in", "children")]) + def o2(i): + return ("2: " + i,) + + dash_duo.start_server(app) + dash_duo.wait_for_text_to_equal("#out1", "1: Hi") + dash_duo.wait_for_text_to_equal("#out2", "2: Hi") diff --git a/tests/integration/callbacks/test_wildcards.py b/tests/integration/callbacks/test_wildcards.py index 2cd14954a2..f8916340d3 100644 --- a/tests/integration/callbacks/test_wildcards.py +++ b/tests/integration/callbacks/test_wildcards.py @@ -51,17 +51,14 @@ def display_content(_): app.total_calls = Value("i", 0) @app.callback( - [Output("list-container", "children"), Output("new-item", "value")], - [ - Input("add", "n_clicks"), - Input("new-item", "n_submit"), - Input("clear-done", "n_clicks"), - ], - [ - State("new-item", "value"), - State({"item": ALL}, "children"), - State({"item": ALL, "action": "done"}, "value"), - ], + Output("list-container", "children"), + Output("new-item", "value"), + Input("add", "n_clicks"), + Input("new-item", "n_submit"), + Input("clear-done", "n_clicks"), + State("new-item", "value"), + State({"item": ALL}, "children"), + State({"item": ALL, "action": "done"}, "value"), ) def edit_list(add, add2, clear, new_item, items, items_done): app.list_calls.value += 1 @@ -99,7 +96,7 @@ def edit_list(add, add2, clear, new_item, items, items_done): @app.callback( Output({"item": MATCH}, "style"), - [Input({"item": MATCH, "action": "done"}, "value")], + Input({"item": MATCH, "action": "done"}, "value"), ) def mark_done(done): app.style_calls.value += 1 @@ -107,10 +104,8 @@ def mark_done(done): @app.callback( Output({"item": MATCH, "preceding": True}, "children"), - [ - Input({"item": ALLSMALLER, "action": "done"}, "value"), - Input({"item": MATCH, "action": "done"}, "value"), - ], + Input({"item": ALLSMALLER, "action": "done"}, "value"), + Input({"item": MATCH, "action": "done"}, "value"), ) def show_preceding(done_before, this_done): app.preceding_calls.value += 1 @@ -124,7 +119,7 @@ def show_preceding(done_before, this_done): return out @app.callback( - Output("totals", "children"), [Input({"item": ALL, "action": "done"}, "value")] + Output("totals", "children"), Input({"item": ALL, "action": "done"}, "value") ) def show_totals(done): app.total_calls.value += 1 @@ -253,7 +248,7 @@ def fibonacci_app(clientside): ] ) - @app.callback(Output("series", "children"), [Input("n", "value")]) + @app.callback(Output("series", "children"), Input("n", "value")) def items(n): return [html.Div(id={"i": i}) for i in range(n)] @@ -266,7 +261,7 @@ def items(n): } """, Output({"i": MATCH}, "children"), - [Input({"i": ALLSMALLER}, "children")], + Input({"i": ALLSMALLER}, "children"), ) app.clientside_callback( @@ -277,13 +272,13 @@ def items(n): } """, Output("sum", "children"), - [Input({"i": ALL}, "children")], + Input({"i": ALL}, "children"), ) else: @app.callback( - Output({"i": MATCH}, "children"), [Input({"i": ALLSMALLER}, "children")] + Output({"i": MATCH}, "children"), Input({"i": ALLSMALLER}, "children") ) def sequence(prev): global fibonacci_count @@ -294,7 +289,7 @@ def sequence(prev): return len(prev) return int(prev[-1] or 0) + int(prev[-2] or 0) - @app.callback(Output("sum", "children"), [Input({"i": ALL}, "children")]) + @app.callback(Output("sum", "children"), Input({"i": ALL}, "children")) def show_sum(seq): global fibonacci_sum_count fibonacci_sum_count = fibonacci_sum_count + 1 diff --git a/tests/integration/test_integration.py b/tests/integration/test_integration.py index 8476f9fb90..c9dbe1a657 100644 --- a/tests/integration/test_integration.py +++ b/tests/integration/test_integration.py @@ -1,8 +1,5 @@ import datetime -from copy import copy -from multiprocessing import Value -from selenium.webdriver.common.keys import Keys import flask import pytest @@ -15,157 +12,10 @@ import dash_html_components as html import dash_core_components as dcc -from dash import Dash, no_update +from dash import Dash -from dash.dependencies import Input, Output, State -from dash.exceptions import ( - PreventUpdate, - InvalidCallbackReturnValue, - IncorrectTypeException, -) -from dash.testing.wait import until - - -def test_inin001_simple_callback(dash_duo): - app = Dash(__name__) - app.layout = html.Div( - [ - dcc.Input(id="input", value="initial value"), - html.Div(html.Div([1.5, None, "string", html.Div(id="output-1")])), - ] - ) - - call_count = Value("i", 0) - - @app.callback(Output("output-1", "children"), [Input("input", "value")]) - def update_output(value): - call_count.value += 1 - return value - - dash_duo.start_server(app) - - dash_duo.wait_for_text_to_equal("#output-1", "initial value") - dash_duo.percy_snapshot(name="simple-callback-1") - - input1 = dash_duo.find_element("#input") - dash_duo.clear_input(input1) - input1.send_keys("hello world") - - dash_duo.wait_for_text_to_equal("#output-1", "hello world") - dash_duo.percy_snapshot(name="simple-callback-2") - - # an initial call, one for clearing the input - # and one for each hello world character - assert call_count.value == 2 + len("hello world") - - assert not dash_duo.get_logs() - - -def test_inin002_wildcard_callback(dash_duo): - app = Dash(__name__) - app.layout = html.Div( - [ - dcc.Input(id="input", value="initial value"), - html.Div( - html.Div( - [ - 1.5, - None, - "string", - html.Div( - id="output-1", - **{"data-cb": "initial value", "aria-cb": "initial value"} - ), - ] - ) - ), - ] - ) - - input_call_count = Value("i", 0) - - @app.callback(Output("output-1", "data-cb"), [Input("input", "value")]) - def update_data(value): - input_call_count.value += 1 - return value - - @app.callback(Output("output-1", "children"), [Input("output-1", "data-cb")]) - def update_text(data): - return data - - dash_duo.start_server(app) - dash_duo.wait_for_text_to_equal("#output-1", "initial value") - dash_duo.percy_snapshot(name="wildcard-callback-1") - - input1 = dash_duo.find_element("#input") - dash_duo.clear_input(input1) - input1.send_keys("hello world") - - dash_duo.wait_for_text_to_equal("#output-1", "hello world") - dash_duo.percy_snapshot(name="wildcard-callback-2") - - # an initial call, one for clearing the input - # and one for each hello world character - assert input_call_count.value == 2 + len("hello world") - - assert not dash_duo.get_logs() - - -def test_inin003_aborted_callback(dash_duo): - """Raising PreventUpdate OR returning no_update prevents update and - triggering dependencies.""" - - initial_input = "initial input" - initial_output = "initial output" - - app = Dash(__name__) - app.layout = html.Div( - [ - dcc.Input(id="input", value=initial_input), - html.Div(initial_output, id="output1"), - html.Div(initial_output, id="output2"), - ] - ) - - callback1_count = Value("i", 0) - callback2_count = Value("i", 0) - - @app.callback(Output("output1", "children"), [Input("input", "value")]) - def callback1(value): - callback1_count.value += 1 - if callback1_count.value > 2: - return no_update - raise PreventUpdate("testing callback does not update") - return value - - @app.callback(Output("output2", "children"), [Input("output1", "children")]) - def callback2(value): - callback2_count.value += 1 - return value - - dash_duo.start_server(app) - - input_ = dash_duo.find_element("#input") - input_.send_keys("xyz") - dash_duo.wait_for_text_to_equal("#input", "initial inputxyz") - - until( - lambda: callback1_count.value == 4, - timeout=3, - msg="callback1 runs 4x (initial page load and 3x through send_keys)", - ) - - assert ( - callback2_count.value == 0 - ), "callback2 is never triggered, even on initial load" - - # double check that output1 and output2 children were not updated - assert dash_duo.find_element("#output1").text == initial_output - assert dash_duo.find_element("#output2").text == initial_output - - assert not dash_duo.get_logs() - - dash_duo.percy_snapshot(name="aborted") +from dash.dependencies import Input, Output +from dash.exceptions import PreventUpdate def test_inin004_wildcard_data_attributes(dash_duo): @@ -372,104 +222,6 @@ def create_layout(): assert dash_duo.find_element("#a").text == "Hello World" -def test_inin012_multi_output_no_update(dash_duo): - app = Dash(__name__) - - app.layout = html.Div( - [ - html.Button("B", "btn"), - html.P("initial1", "n1"), - html.P("initial2", "n2"), - html.P("initial3", "n3"), - ] - ) - - @app.callback( - [Output("n1", "children"), Output("n2", "children"), Output("n3", "children")], - [Input("btn", "n_clicks")], - ) - def show_clicks(n): - # partial or complete cancelation of updates via no_update - return [ - no_update if n and n > 4 else n, - no_update if n and n > 2 else n, - # make a new instance, to mock up caching and restoring no_update - copy(no_update), - ] - - dash_duo.start_server(app) - - dash_duo.multiple_click("#btn", 10) - - dash_duo.wait_for_text_to_equal("#n1", "4") - dash_duo.wait_for_text_to_equal("#n2", "2") - dash_duo.wait_for_text_to_equal("#n3", "initial3") - - -def test_inin013_no_update_chains(dash_duo): - app = Dash(__name__) - - app.layout = html.Div( - [ - dcc.Input(id="a_in", value="a"), - dcc.Input(id="b_in", value="b"), - html.P("", id="a_out"), - html.P("", id="a_out_short"), - html.P("", id="b_out"), - html.P("", id="ab_out"), - ] - ) - - @app.callback( - [Output("a_out", "children"), Output("a_out_short", "children")], - [Input("a_in", "value")], - ) - def a_out(a): - return a, a if len(a) < 3 else no_update - - @app.callback(Output("b_out", "children"), [Input("b_in", "value")]) - def b_out(b): - return b - - @app.callback( - Output("ab_out", "children"), - [Input("a_out_short", "children")], - [State("b_out", "children")], - ) - def ab_out(a, b): - return a + " " + b - - dash_duo.start_server(app) - - a_in = dash_duo.find_element("#a_in") - b_in = dash_duo.find_element("#b_in") - - b_in.send_keys("b") - a_in.send_keys("a") - dash_duo.wait_for_text_to_equal("#a_out", "aa") - dash_duo.wait_for_text_to_equal("#b_out", "bb") - dash_duo.wait_for_text_to_equal("#a_out_short", "aa") - dash_duo.wait_for_text_to_equal("#ab_out", "aa bb") - - b_in.send_keys("b") - a_in.send_keys("a") - dash_duo.wait_for_text_to_equal("#a_out", "aaa") - dash_duo.wait_for_text_to_equal("#b_out", "bbb") - dash_duo.wait_for_text_to_equal("#a_out_short", "aa") - # ab_out has not been triggered because a_out_short received no_update - dash_duo.wait_for_text_to_equal("#ab_out", "aa bb") - - b_in.send_keys("b") - a_in.send_keys(Keys.END) - a_in.send_keys(Keys.BACKSPACE) - dash_duo.wait_for_text_to_equal("#a_out", "aa") - dash_duo.wait_for_text_to_equal("#b_out", "bbbb") - dash_duo.wait_for_text_to_equal("#a_out_short", "aa") - # now ab_out *is* triggered - a_out_short got a new value - # even though that value is the same as the last value it got - dash_duo.wait_for_text_to_equal("#ab_out", "aa bbbb") - - def test_inin014_with_custom_renderer(dash_duo): app = Dash(__name__) @@ -644,102 +396,6 @@ def update_output(value): dash_duo.find_element("#inserted-input") -def test_inin019_callback_dep_types(): - app = Dash(__name__) - app.layout = html.Div( - [html.Div("child", id="in"), html.Div("state", id="state"), html.Div(id="out")] - ) - - with pytest.raises(IncorrectTypeException): - - @app.callback([[Output("out", "children")]], [Input("in", "children")]) - def f(i): - return i - - pytest.fail("extra output nesting") - - with pytest.raises(IncorrectTypeException): - - @app.callback(Output("out", "children"), Input("in", "children")) - def f2(i): - return i - - pytest.fail("un-nested input") - - with pytest.raises(IncorrectTypeException): - - @app.callback( - Output("out", "children"), - [Input("in", "children")], - State("state", "children"), - ) - def f3(i): - return i - - pytest.fail("un-nested state") - - # all OK with tuples - @app.callback( - (Output("out", "children"),), - (Input("in", "children"),), - (State("state", "children"),), - ) - def f4(i): - return i - - -def test_inin020_callback_return_validation(): - app = Dash(__name__) - app.layout = html.Div( - [ - html.Div(id="a"), - html.Div(id="b"), - html.Div(id="c"), - html.Div(id="d"), - html.Div(id="e"), - html.Div(id="f"), - ] - ) - - @app.callback(Output("b", "children"), [Input("a", "children")]) - def single(a): - return set([1]) - - with pytest.raises(InvalidCallbackReturnValue): - # outputs_list (normally callback_context.outputs_list) is provided - # by the dispatcher from the request. - single("aaa", outputs_list={"id": "b", "property": "children"}) - pytest.fail("not serializable") - - @app.callback( - [Output("c", "children"), Output("d", "children")], [Input("a", "children")] - ) - def multi(a): - return [1, set([2])] - - with pytest.raises(InvalidCallbackReturnValue): - outputs_list = [ - {"id": "c", "property": "children"}, - {"id": "d", "property": "children"}, - ] - multi("aaa", outputs_list=outputs_list) - pytest.fail("nested non-serializable") - - @app.callback( - [Output("e", "children"), Output("f", "children")], [Input("a", "children")] - ) - def multi2(a): - return ["abc"] - - with pytest.raises(InvalidCallbackReturnValue): - outputs_list = [ - {"id": "e", "property": "children"}, - {"id": "f", "property": "children"}, - ] - multi2("aaa", outputs_list=outputs_list) - pytest.fail("wrong-length list") - - def test_inin_024_port_env_success(dash_duo): app = Dash(__name__) app.layout = html.Div("hi", "out")