From 250352addb403645b0e9148b1f41c78a71ae4ebd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Myriam=20B=C3=A9gel?= Date: Sun, 5 Apr 2020 09:21:49 +0200 Subject: [PATCH 01/28] Allow single Input not in a list --- dash/dash.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/dash/dash.py b/dash/dash.py index 07f3f3bbf4..92985f6764 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -846,7 +846,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 + self, clientside_function, output, input, state=(), prevent_initial_call=None ): """Create a callback that updates the output by calling a clientside (JavaScript) function instead of a Python function. @@ -912,6 +912,9 @@ 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. """ + is_multi_input = isinstance(input, (list, tuple)) + inputs = input if is_multi_input else [input] + self._insert_callback(output, inputs, state, prevent_initial_call) # If JS source is explicitly given, create a namespace and function @@ -943,7 +946,7 @@ def clientside_callback( "function_name": function_name, } - def callback(self, output, inputs, state=(), prevent_initial_call=None): + def callback(self, output, input, state=(), prevent_initial_call=None): """ Normally used as a decorator, `@app.callback` provides a server-side callback relating the values of one or more `output` items to one or @@ -955,6 +958,8 @@ def callback(self, output, inputs, state=(), prevent_initial_call=None): not to fire when its outputs are first added to the page. Defaults to `False` unless `prevent_initial_callbacks=True` at the app level. """ + is_multi_input = isinstance(input, (list, tuple)) + inputs = input if is_multi_input else [input] callback_id = self._insert_callback(output, inputs, state, prevent_initial_call) multi = isinstance(output, (list, tuple)) From 93284acc385d8b0fe092f7b0d92749a6ea783522 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Myriam=20B=C3=A9gel?= Date: Sun, 5 Apr 2020 09:33:54 +0200 Subject: [PATCH 02/28] One test uses single Input not in list --- tests/assets/simple_app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From d167e949c3ac425a49f0a10c758b8abadb37baba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Myriam=20B=C3=A9gel?= Date: Sun, 5 Apr 2020 11:24:22 +0200 Subject: [PATCH 03/28] Update CHANGELOG.md with PR 1180 --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fe67863ca0..ed386a2ff6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ All notable changes to `dash` will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/). +## [UNRELEASED] +### Changed +- [#1180](https://github.com/plotly/dash/pull/1180) Single `Input` in callbacks doesn't need to be in a list + ## [1.12.0] - 2020-05-05 ### Added - [#1228](https://github.com/plotly/dash/pull/1228) Adds control over firing callbacks on page (or layout chunk) load. Individual callbacks can have their initial calls disabled in their definition `@app.callback(..., prevent_initial_call=True)` and similar for `app.clientside_callback`. The app-wide default can also be changed with `app=Dash(prevent_initial_callbacks=True)`, then individual callbacks may disable this behavior. From c839c8071e3183a6bc76795c0d2fd6ab5c08f1f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Myriam=20B=C3=A9gel?= Date: Sun, 5 Apr 2020 11:32:24 +0200 Subject: [PATCH 04/28] Don't use input as variable --- dash/dash.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/dash/dash.py b/dash/dash.py index 92985f6764..0154d74043 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -846,7 +846,7 @@ def _insert_callback(self, output, inputs, state, prevent_initial_call): return callback_id def clientside_callback( - self, clientside_function, output, input, state=(), prevent_initial_call=None + self, clientside_function, output, input_, state=(), prevent_initial_call=None ): """Create a callback that updates the output by calling a clientside (JavaScript) function instead of a Python function. @@ -912,8 +912,8 @@ 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. """ - is_multi_input = isinstance(input, (list, tuple)) - inputs = input if is_multi_input else [input] + is_multi_input = isinstance(input_, (list, tuple)) + inputs = input_ if is_multi_input else [input_] self._insert_callback(output, inputs, state, prevent_initial_call) @@ -946,7 +946,7 @@ def clientside_callback( "function_name": function_name, } - def callback(self, output, input, state=(), prevent_initial_call=None): + def callback(self, output, input_, state=(), prevent_initial_call=None): """ Normally used as a decorator, `@app.callback` provides a server-side callback relating the values of one or more `output` items to one or @@ -958,8 +958,8 @@ def callback(self, output, input, state=(), prevent_initial_call=None): not to fire when its outputs are first added to the page. Defaults to `False` unless `prevent_initial_callbacks=True` at the app level. """ - is_multi_input = isinstance(input, (list, tuple)) - inputs = input if is_multi_input else [input] + is_multi_input = isinstance(input_, (list, tuple)) + inputs = input_ if is_multi_input else [input_] callback_id = self._insert_callback(output, inputs, state, prevent_initial_call) multi = isinstance(output, (list, tuple)) From 0e03062eabf21f6c9d347c338660d5f7b2a7a174 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Myriam=20B=C3=A9gel?= Date: Sun, 5 Apr 2020 11:50:30 +0200 Subject: [PATCH 05/28] Adapt the integration code to un-nested inputs --- tests/integration/test_integration.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/tests/integration/test_integration.py b/tests/integration/test_integration.py index 8476f9fb90..ab37aeb600 100644 --- a/tests/integration/test_integration.py +++ b/tests/integration/test_integration.py @@ -658,14 +658,6 @@ def f(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( From e9f55596398ffb235e3427c123eeb0cdae0d8925 Mon Sep 17 00:00:00 2001 From: Moi Date: Wed, 15 Apr 2020 20:13:56 +0200 Subject: [PATCH 06/28] Fix typo in error message --- dash/_validate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dash/_validate.py b/dash/_validate.py index 98ef6de530..e5bd3e7bcb 100644 --- a/dash/_validate.py +++ b/dash/_validate.py @@ -133,7 +133,7 @@ def validate_multi_return(outputs_list, output_value, callback_id): if not isinstance(vi, (list, tuple)): raise exceptions.InvalidCallbackReturnValue( """ - The callback {} ouput {} is a wildcard multi-output. + The callback {} output {} is a wildcard multi-output. Expected the output type to be a list or tuple but got: {}. output spec: {} From 35f35e62c7015e69d1c8c0d4f175d8176031c719 Mon Sep 17 00:00:00 2001 From: Moi Date: Wed, 15 Apr 2020 20:28:21 +0200 Subject: [PATCH 07/28] Allow callback arguments to be passed outside lists --- dash/dash.py | 71 ++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 55 insertions(+), 16 deletions(-) diff --git a/dash/dash.py b/dash/dash.py index 0154d74043..0141ac7db3 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -24,6 +24,7 @@ from .fingerprint import build_fingerprint, check_fingerprint from .resources import Scripts, Css +from .dependencies import Input, Output, State from .development.base_component import ComponentRegistry from .exceptions import PreventUpdate, InvalidResourceError from .version import __version__ @@ -824,7 +825,7 @@ def interpolate_index(self, **kwargs): def dependencies(self): return flask.jsonify(self._callback_list) - def _insert_callback(self, output, inputs, state, prevent_initial_call): + def _insert_callback(self, output, inputs, state, callback_args, prevent_initial_call): if prevent_initial_call is None: prevent_initial_call = self.config.prevent_initial_callbacks @@ -834,20 +835,20 @@ def _insert_callback(self, output, inputs, state, prevent_initial_call): "output": callback_id, "inputs": [c.to_dict() for c in inputs], "state": [c.to_dict() for c in state], + "args": [c.to_dict() for c in callback_args], "clientside_function": None, "prevent_initial_call": prevent_initial_call, } self.callback_map[callback_id] = { "inputs": callback_spec["inputs"], "state": callback_spec["state"], + "args": callback_spec["args"], } self._callback_list.append(callback_spec) return callback_id - def clientside_callback( - self, clientside_function, output, input_, 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. @@ -912,10 +913,8 @@ 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. """ - is_multi_input = isinstance(input_, (list, tuple)) - inputs = input_ if is_multi_input else [input_] - - self._insert_callback(output, inputs, state, prevent_initial_call) + output, inputs, state, callback_args, prevent_initial_call = self._handle_callback_args(args, kwargs) + self._insert_callback(output, inputs, state, callback_args) # If JS source is explicitly given, create a namespace and function # name, then inject the code. @@ -946,7 +945,38 @@ def clientside_callback( "function_name": function_name, } - def callback(self, output, input_, state=(), prevent_initial_call=None): + def _handle_callback_args(self, args, kwargs): + """Split args into outputs, inputs and states""" + prevent_initial_call = None + for k, v in kwargs.items(): + if k == "prevent_initial_call": + prevent_initial_call = v + else: + raise TypeError( + "callback got an unexpected keyword argument '{}'".format(k) + ) + args = [ + arg + # for backward compatibility, one arg can be a list + for arg_or_list in args + # flatten args that are lists + for arg in ( + arg_or_list if isinstance(arg_or_list, (list, tuple)) + else [arg_or_list] + ) + ] + return [ + # split according to type Output, Input, State + [arg for arg in args if isinstance(arg, class_)] + for class_ in [Output, Input, State] + ] + [ + # keep list of args in order, for matching order + # in the callback's parameters + [arg for arg in args if not isinstance(arg, Output)], + prevent_initial_call + ] + + 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 @@ -958,10 +988,8 @@ def callback(self, output, input_, state=(), prevent_initial_call=None): not to fire when its outputs are first added to the page. Defaults to `False` unless `prevent_initial_callbacks=True` at the app level. """ - is_multi_input = isinstance(input_, (list, tuple)) - inputs = input_ if is_multi_input else [input_] - callback_id = self._insert_callback(output, inputs, state, prevent_initial_call) - multi = isinstance(output, (list, tuple)) + output, inputs, state, callback_args, prevent_initial_call = self._handle_callback_args(args, kwargs) + callback_id = self._insert_callback(output, inputs, state, callback_args, prevent_initial_call) def wrap_func(func): @wraps(func) @@ -976,8 +1004,11 @@ def add_context(*args, **kwargs): # wrap single outputs so we can treat them all the same # for validation and response creation - if not multi: - output_value, output_spec = [output_value], [output_spec] + if not isinstance(output_value, (list, tuple)): + if not isinstance(output_spec, (list, tuple)): + output_value, output_spec = [output_value], [output_spec] + else: + output_value, output_spec = [output_value], output_spec _validate.validate_multi_return(output_spec, output_value, callback_id) @@ -1031,7 +1062,15 @@ def dispatch(self): response = flask.g.dash_response = flask.Response(mimetype="application/json") - args = inputs_to_vals(inputs) + inputs_to_vals(state) + # frontend sends inputs and state in separate variables + # we need to reorder them for the callback + args_inputs = [ + value + for arg in self.callback_map[output]["args"] + for value in (inputs + state) + if arg['id'] == value['id'] + and arg['property'] == value['property']] + args = inputs_to_vals(args_inputs) func = self.callback_map[output]["callback"] response.set_data(func(*args, outputs_list=outputs_list)) From 3ab52cd4663837e26274ca2ff7f06f2710053f0d Mon Sep 17 00:00:00 2001 From: Moi Date: Wed, 15 Apr 2020 20:28:52 +0200 Subject: [PATCH 08/28] Add tests --- tests/integration/test_integration.py | 44 ++++++++++++++++----------- 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/tests/integration/test_integration.py b/tests/integration/test_integration.py index ab37aeb600..c060d8efe8 100644 --- a/tests/integration/test_integration.py +++ b/tests/integration/test_integration.py @@ -647,36 +647,46 @@ def update_output(value): 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")] + [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): - @app.callback([[Output("out", "children")]], [Input("in", "children")]) + @app.callback([[Output("out1", "children")]], + [Input("in1", "children")]) def f(i): return i pytest.fail("extra output nesting") - with pytest.raises(IncorrectTypeException): - - @app.callback( - Output("out", "children"), - [Input("in", "children")], - State("state", "children"), - ) - def f3(i): - return i + # all OK with tuples + @app.callback( + (Output("out1", "children"),), + (Input("in1", "children"),), + (State("state1", "children"),), + ) + def f1(i): + return i - pytest.fail("un-nested state") + # 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 tuples + # all OK with lists @app.callback( - (Output("out", "children"),), - (Input("in", "children"),), - (State("state", "children"),), + [Output("out3", "children")], + [Input("in3", "children")], + [State("state3", "children")], ) - def f4(i): + def f3(i): return i From b61e4d0543cfd047134d05f712719de192154e28 Mon Sep 17 00:00:00 2001 From: Moi Date: Wed, 15 Apr 2020 21:12:47 +0200 Subject: [PATCH 09/28] Fix linting --- dash/dash.py | 67 ++++++++++++++------------- tests/integration/test_integration.py | 7 +-- 2 files changed, 38 insertions(+), 36 deletions(-) diff --git a/dash/dash.py b/dash/dash.py index 0141ac7db3..ee1a2f8ab9 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -102,6 +102,38 @@ class _NoUpdate(object): """ +def _handle_callback_args(args, kwargs): + """Split args into outputs, inputs and states""" + prevent_initial_call = None + for k, v in kwargs.items(): + if k == "prevent_initial_call": + prevent_initial_call = v + else: + raise TypeError( + "callback got an unexpected keyword argument '{}'".format(k) + ) + args = [ + arg + # for backward compatibility, one arg can be a list + for arg_or_list in args + # flatten args that are lists + for arg in ( + arg_or_list if isinstance(arg_or_list, (list, tuple)) + else [arg_or_list] + ) + ] + return [ + # split according to type Output, Input, State + [arg for arg in args if isinstance(arg, class_)] + for class_ in [Output, Input, State] + ] + [ + # keep list of args in order, for matching order + # in the callback's parameters + [arg for arg in args if not isinstance(arg, Output)], + prevent_initial_call + ] + + # pylint: disable=too-many-instance-attributes # pylint: disable=too-many-arguments, too-many-locals class Dash(object): @@ -913,7 +945,7 @@ def clientside_callback(self, clientside_function, *args, **kwargs): 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, callback_args, prevent_initial_call = self._handle_callback_args(args, kwargs) + output, inputs, state, callback_args, prevent_initial_call = _handle_callback_args(args, kwargs) self._insert_callback(output, inputs, state, callback_args) # If JS source is explicitly given, create a namespace and function @@ -945,37 +977,6 @@ def clientside_callback(self, clientside_function, *args, **kwargs): "function_name": function_name, } - def _handle_callback_args(self, args, kwargs): - """Split args into outputs, inputs and states""" - prevent_initial_call = None - for k, v in kwargs.items(): - if k == "prevent_initial_call": - prevent_initial_call = v - else: - raise TypeError( - "callback got an unexpected keyword argument '{}'".format(k) - ) - args = [ - arg - # for backward compatibility, one arg can be a list - for arg_or_list in args - # flatten args that are lists - for arg in ( - arg_or_list if isinstance(arg_or_list, (list, tuple)) - else [arg_or_list] - ) - ] - return [ - # split according to type Output, Input, State - [arg for arg in args if isinstance(arg, class_)] - for class_ in [Output, Input, State] - ] + [ - # keep list of args in order, for matching order - # in the callback's parameters - [arg for arg in args if not isinstance(arg, Output)], - prevent_initial_call - ] - def callback(self, *args, **kwargs): """ Normally used as a decorator, `@app.callback` provides a server-side @@ -988,7 +989,7 @@ def callback(self, *args, **kwargs): 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, callback_args, prevent_initial_call = self._handle_callback_args(args, kwargs) + output, inputs, state, callback_args, prevent_initial_call = _handle_callback_args(args, kwargs) callback_id = self._insert_callback(output, inputs, state, callback_args, prevent_initial_call) def wrap_func(func): diff --git a/tests/integration/test_integration.py b/tests/integration/test_integration.py index c060d8efe8..3f46c3643d 100644 --- a/tests/integration/test_integration.py +++ b/tests/integration/test_integration.py @@ -647,9 +647,10 @@ def update_output(value): def test_inin019_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"), + [ + 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"), ] ) From c926b2009d777aa32a3cbd2d4d9bc81481115487 Mon Sep 17 00:00:00 2001 From: Moi Date: Wed, 15 Apr 2020 21:13:58 +0200 Subject: [PATCH 10/28] Fix backward compatibility for single output lists --- dash/dash.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/dash/dash.py b/dash/dash.py index ee1a2f8ab9..9bd2a82432 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -989,6 +989,11 @@ def callback(self, *args, **kwargs): not to fire when its outputs are first added to the page. Defaults to `False` unless `prevent_initial_callbacks=True` at the app level. """ + # for backward compatibility, store whether first argument is a + # list of only 1 Output + specified_output_list = ( + isinstance(args[0], (list, tuple)) + and len(args[0]) == 1) output, inputs, state, callback_args, prevent_initial_call = _handle_callback_args(args, kwargs) callback_id = self._insert_callback(output, inputs, state, callback_args, prevent_initial_call) @@ -1005,11 +1010,8 @@ def add_context(*args, **kwargs): # wrap single outputs so we can treat them all the same # for validation and response creation - if not isinstance(output_value, (list, tuple)): - if not isinstance(output_spec, (list, tuple)): - output_value, output_spec = [output_value], [output_spec] - else: - output_value, output_spec = [output_value], output_spec + if len(output_spec) == 1 and not specified_output_list: + output_value = [output_value] _validate.validate_multi_return(output_spec, output_value, callback_id) From 775bf090a35df4506c8d5d1fd7104903832a941a Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Wed, 13 May 2020 19:08:59 -0400 Subject: [PATCH 11/28] lint --- dash/dash.py | 37 ++++++++++++++++++--------- tests/integration/test_integration.py | 15 +++++++---- 2 files changed, 35 insertions(+), 17 deletions(-) diff --git a/dash/dash.py b/dash/dash.py index 9bd2a82432..db038084ef 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -118,8 +118,7 @@ def _handle_callback_args(args, kwargs): for arg_or_list in args # flatten args that are lists for arg in ( - arg_or_list if isinstance(arg_or_list, (list, tuple)) - else [arg_or_list] + arg_or_list if isinstance(arg_or_list, (list, tuple)) else [arg_or_list] ) ] return [ @@ -130,7 +129,7 @@ def _handle_callback_args(args, kwargs): # keep list of args in order, for matching order # in the callback's parameters [arg for arg in args if not isinstance(arg, Output)], - prevent_initial_call + prevent_initial_call, ] @@ -857,7 +856,9 @@ def interpolate_index(self, **kwargs): def dependencies(self): return flask.jsonify(self._callback_list) - def _insert_callback(self, output, inputs, state, callback_args, prevent_initial_call): + def _insert_callback( + self, output, inputs, state, callback_args, prevent_initial_call + ): if prevent_initial_call is None: prevent_initial_call = self.config.prevent_initial_callbacks @@ -945,7 +946,13 @@ def clientside_callback(self, clientside_function, *args, **kwargs): 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, callback_args, prevent_initial_call = _handle_callback_args(args, kwargs) + ( + output, + inputs, + state, + callback_args, + prevent_initial_call, + ) = _handle_callback_args(args, kwargs) self._insert_callback(output, inputs, state, callback_args) # If JS source is explicitly given, create a namespace and function @@ -991,11 +998,17 @@ def callback(self, *args, **kwargs): """ # for backward compatibility, store whether first argument is a # list of only 1 Output - specified_output_list = ( - isinstance(args[0], (list, tuple)) - and len(args[0]) == 1) - output, inputs, state, callback_args, prevent_initial_call = _handle_callback_args(args, kwargs) - callback_id = self._insert_callback(output, inputs, state, callback_args, prevent_initial_call) + specified_output_list = isinstance(args[0], (list, tuple)) and len(args[0]) == 1 + ( + output, + inputs, + state, + callback_args, + prevent_initial_call, + ) = _handle_callback_args(args, kwargs) + callback_id = self._insert_callback( + output, inputs, state, callback_args, prevent_initial_call + ) def wrap_func(func): @wraps(func) @@ -1071,8 +1084,8 @@ def dispatch(self): value for arg in self.callback_map[output]["args"] for value in (inputs + state) - if arg['id'] == value['id'] - and arg['property'] == value['property']] + if arg["id"] == value["id"] and arg["property"] == value["property"] + ] args = inputs_to_vals(args_inputs) func = self.callback_map[output]["callback"] diff --git a/tests/integration/test_integration.py b/tests/integration/test_integration.py index 3f46c3643d..c0b1dad122 100644 --- a/tests/integration/test_integration.py +++ b/tests/integration/test_integration.py @@ -648,16 +648,21 @@ def test_inin019_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"), + 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): - @app.callback([[Output("out1", "children")]], - [Input("in1", "children")]) + @app.callback([[Output("out1", "children")]], [Input("in1", "children")]) def f(i): return i From 94b4bdf7fbb1222a01a79db52d8a1a70b7561d57 Mon Sep 17 00:00:00 2001 From: Moi Date: Sun, 24 May 2020 19:40:07 +0200 Subject: [PATCH 12/28] Backward compatibility Allow to use named parameters in callback Raise exceptions if outputs, inputs or states are not ordered --- dash/dash.py | 64 +++++++++++++++++++++++++--------------------------- 1 file changed, 31 insertions(+), 33 deletions(-) diff --git a/dash/dash.py b/dash/dash.py index db038084ef..00997dd3bb 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -101,17 +101,19 @@ class _NoUpdate(object): ns["{function_name}"] = {clientside_function}; """ - -def _handle_callback_args(args, kwargs): +def extract_callback_args(args, kwargs, name, type_): + """Extract arguments for callback from a name and type""" + print(args, kwargs) + 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 = None - for k, v in kwargs.items(): - if k == "prevent_initial_call": - prevent_initial_call = v - else: - raise TypeError( - "callback got an unexpected keyword argument '{}'".format(k) - ) + prevent_initial_call = kwargs.get('prevent_initial_call', None) + # flatten args args = [ arg # for backward compatibility, one arg can be a list @@ -121,14 +123,17 @@ def _handle_callback_args(args, kwargs): arg_or_list if isinstance(arg_or_list, (list, tuple)) else [arg_or_list] ) ] + outputs = extract_callback_args(args, kwargs, 'output', Output) + inputs = extract_callback_args(args, kwargs, 'inputs', Input) + states = extract_callback_args(args, kwargs, 'state', State) + + if args: + raise TypeError( + "callback must received first all Outputs, then all Inputs, then all States") return [ - # split according to type Output, Input, State - [arg for arg in args if isinstance(arg, class_)] - for class_ in [Output, Input, State] - ] + [ - # keep list of args in order, for matching order - # in the callback's parameters - [arg for arg in args if not isinstance(arg, Output)], + outputs, + inputs, + states, prevent_initial_call, ] @@ -857,7 +862,7 @@ def dependencies(self): return flask.jsonify(self._callback_list) def _insert_callback( - self, output, inputs, state, callback_args, prevent_initial_call + self, output, inputs, state, prevent_initial_call ): if prevent_initial_call is None: prevent_initial_call = self.config.prevent_initial_callbacks @@ -868,14 +873,12 @@ def _insert_callback( "output": callback_id, "inputs": [c.to_dict() for c in inputs], "state": [c.to_dict() for c in state], - "args": [c.to_dict() for c in callback_args], "clientside_function": None, "prevent_initial_call": prevent_initial_call, } self.callback_map[callback_id] = { "inputs": callback_spec["inputs"], "state": callback_spec["state"], - "args": callback_spec["args"], } self._callback_list.append(callback_spec) @@ -996,18 +999,21 @@ def callback(self, *args, **kwargs): not to fire when its outputs are first added to the page. Defaults to `False` unless `prevent_initial_callbacks=True` at the app level. """ + kwargs['prevent_initial_call'] = kwargs.get( + 'prevent_initial_call', None) + output = kwargs.get('output', args[0]) # for backward compatibility, store whether first argument is a # list of only 1 Output - specified_output_list = isinstance(args[0], (list, tuple)) and len(args[0]) == 1 + specified_output_list = ( + isinstance(output, (list, tuple)) and len(output) == 1) ( output, inputs, state, - callback_args, prevent_initial_call, - ) = _handle_callback_args(args, kwargs) + ) = _handle_callback_args(*args, **kwargs) callback_id = self._insert_callback( - output, inputs, state, callback_args, prevent_initial_call + output, inputs, state, prevent_initial_call ) def wrap_func(func): @@ -1078,15 +1084,7 @@ def dispatch(self): response = flask.g.dash_response = flask.Response(mimetype="application/json") - # frontend sends inputs and state in separate variables - # we need to reorder them for the callback - args_inputs = [ - value - for arg in self.callback_map[output]["args"] - for value in (inputs + state) - if arg["id"] == value["id"] and arg["property"] == value["property"] - ] - args = inputs_to_vals(args_inputs) + args = inputs_to_vals(inputs + state) func = self.callback_map[output]["callback"] response.set_data(func(*args, outputs_list=outputs_list)) From 6edd176ca4e6f0d2ead6b22bbf27a567c69e2ce4 Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Fri, 19 Jun 2020 22:18:46 -0400 Subject: [PATCH 13/28] lint - and hopefully make black work on CI --- dash/dash.py | 36 +++++++++++++++--------------------- package.json | 2 +- 2 files changed, 16 insertions(+), 22 deletions(-) diff --git a/dash/dash.py b/dash/dash.py index bbe1196f57..d93ecf5849 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -100,6 +100,7 @@ class _NoUpdate(object): ns["{function_name}"] = {clientside_function}; """ + def extract_callback_args(args, kwargs, name, type_): """Extract arguments for callback from a name and type""" print(args, kwargs) @@ -109,9 +110,10 @@ def extract_callback_args(args, kwargs, name, 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) + prevent_initial_call = kwargs.get("prevent_initial_call", None) # flatten args args = [ arg @@ -122,13 +124,14 @@ def _handle_callback_args(*args, **kwargs): arg_or_list if isinstance(arg_or_list, (list, tuple)) else [arg_or_list] ) ] - outputs = extract_callback_args(args, kwargs, 'output', Output) - inputs = extract_callback_args(args, kwargs, 'inputs', Input) - states = extract_callback_args(args, kwargs, 'state', State) + outputs = extract_callback_args(args, kwargs, "output", Output) + inputs = extract_callback_args(args, kwargs, "inputs", Input) + states = extract_callback_args(args, kwargs, "state", State) if args: raise TypeError( - "callback must received first all Outputs, then all Inputs, then all States") + "callback must received first all Outputs, then all Inputs, then all States" + ) return [ outputs, inputs, @@ -860,9 +863,7 @@ def interpolate_index(self, **kwargs): def dependencies(self): return flask.jsonify(self._callback_list) - def _insert_callback( - self, output, inputs, state, prevent_initial_call - ): + def _insert_callback(self, output, inputs, state, prevent_initial_call): if prevent_initial_call is None: prevent_initial_call = self.config.prevent_initial_callbacks @@ -998,22 +999,15 @@ def callback(self, *args, **kwargs): not to fire when its outputs are first added to the page. Defaults to `False` unless `prevent_initial_callbacks=True` at the app level. """ - kwargs['prevent_initial_call'] = kwargs.get( - 'prevent_initial_call', None) - output = kwargs.get('output', args[0]) + kwargs["prevent_initial_call"] = kwargs.get("prevent_initial_call", None) + output = kwargs.get("output", args[0]) # for backward compatibility, store whether first argument is a # list of only 1 Output - specified_output_list = ( - isinstance(output, (list, tuple)) and len(output) == 1) - ( - output, - inputs, - state, - prevent_initial_call, - ) = _handle_callback_args(*args, **kwargs) - callback_id = self._insert_callback( - output, inputs, state, prevent_initial_call + specified_output_list = isinstance(output, (list, tuple)) and len(output) == 1 + (output, inputs, state, prevent_initial_call,) = _handle_callback_args( + *args, **kwargs ) + callback_id = self._insert_callback(output, inputs, state, prevent_initial_call) def wrap_func(func): @wraps(func) 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", From 3cdf975bb6cde750e9c9a14394d48cf1360048db Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Fri, 19 Jun 2020 22:29:51 -0400 Subject: [PATCH 14/28] move callback arg parsing into dependencies.py --- dash/dash.py | 45 +++----------------------------------------- dash/dependencies.py | 39 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 42 deletions(-) diff --git a/dash/dash.py b/dash/dash.py index d93ecf5849..fc7d999df3 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -23,7 +23,7 @@ from .fingerprint import build_fingerprint, check_fingerprint from .resources import Scripts, Css -from .dependencies import Input, Output, State +from .dependencies import handle_callback_args from .development.base_component import ComponentRegistry from .exceptions import PreventUpdate, InvalidResourceError, ProxyError from .version import __version__ @@ -101,45 +101,6 @@ class _NoUpdate(object): """ -def extract_callback_args(args, kwargs, name, type_): - """Extract arguments for callback from a name and type""" - print(args, kwargs) - 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) - # flatten args - args = [ - arg - # for backward compatibility, one arg can be a list - for arg_or_list in args - # flatten args that are lists - for arg in ( - arg_or_list if isinstance(arg_or_list, (list, tuple)) else [arg_or_list] - ) - ] - outputs = extract_callback_args(args, kwargs, "output", Output) - inputs = extract_callback_args(args, kwargs, "inputs", Input) - states = extract_callback_args(args, kwargs, "state", State) - - if args: - raise TypeError( - "callback must received first all Outputs, then all Inputs, then all States" - ) - return [ - outputs, - inputs, - states, - prevent_initial_call, - ] - - # pylint: disable=too-many-instance-attributes # pylint: disable=too-many-arguments, too-many-locals class Dash(object): @@ -955,7 +916,7 @@ def clientside_callback(self, clientside_function, *args, **kwargs): state, callback_args, prevent_initial_call, - ) = _handle_callback_args(args, kwargs) + ) = handle_callback_args(args, kwargs) self._insert_callback(output, inputs, state, callback_args) # If JS source is explicitly given, create a namespace and function @@ -1004,7 +965,7 @@ def callback(self, *args, **kwargs): # for backward compatibility, store whether first argument is a # list of only 1 Output specified_output_list = isinstance(output, (list, tuple)) and len(output) == 1 - (output, inputs, state, prevent_initial_call,) = _handle_callback_args( + (output, inputs, state, prevent_initial_call,) = handle_callback_args( *args, **kwargs ) callback_id = self._insert_callback(output, inputs, state, prevent_initial_call) diff --git a/dash/dependencies.py b/dash/dependencies.py index fa79b842d5..daccd4fc23 100644 --- a/dash/dependencies.py +++ b/dash/dependencies.py @@ -133,3 +133,42 @@ 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""" + print(args, kwargs) + 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) + # flatten args + args = [ + arg + # for backward compatibility, one arg can be a list + for arg_or_list in args + # flatten args that are lists + for arg in ( + arg_or_list if isinstance(arg_or_list, (list, tuple)) else [arg_or_list] + ) + ] + outputs = extract_callback_args(args, kwargs, "output", Output) + inputs = extract_callback_args(args, kwargs, "inputs", Input) + states = extract_callback_args(args, kwargs, "state", State) + + if args: + raise TypeError( + "callback must received first all Outputs, then all Inputs, then all States" + ) + return [ + outputs, + inputs, + states, + prevent_initial_call, + ] From 474b83722dec82af81bda77581812f8a68e253bb Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Wed, 24 Jun 2020 17:56:01 -0400 Subject: [PATCH 15/28] partial fix for flat callbacks --- dash/dash.py | 39 +++++++++++++++++---------------------- dash/dependencies.py | 39 ++++++++++++++++++++++----------------- 2 files changed, 39 insertions(+), 39 deletions(-) diff --git a/dash/dash.py b/dash/dash.py index fc7d999df3..2e54b7601f 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -910,14 +910,8 @@ def clientside_callback(self, clientside_function, *args, **kwargs): 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, - callback_args, - prevent_initial_call, - ) = handle_callback_args(args, kwargs) - self._insert_callback(output, inputs, state, callback_args) + 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 # name, then inject the code. @@ -948,27 +942,28 @@ def clientside_callback(self, clientside_function, *args, **kwargs): "function_name": function_name, } - def callback(self, *args, **kwargs): + 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. + + """ - kwargs["prevent_initial_call"] = kwargs.get("prevent_initial_call", None) - output = kwargs.get("output", args[0]) - # for backward compatibility, store whether first argument is a - # list of only 1 Output - specified_output_list = isinstance(output, (list, tuple)) and len(output) == 1 - (output, inputs, state, prevent_initial_call,) = handle_callback_args( - *args, **kwargs - ) + # kwargs["prevent_initial_call"] = kwargs.get("prevent_initial_call", None) + # output = kwargs.get("output", args[0]) + # # for backward compatibility, store whether first argument is a + # # list of only 1 Output + # specified_output_list = isinstance(output, (list, tuple)) and len(output) == 1 + 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)) def wrap_func(func): @wraps(func) @@ -983,8 +978,8 @@ def add_context(*args, **kwargs): # wrap single outputs so we can treat them all the same # for validation and response creation - if len(output_spec) == 1 and not specified_output_list: - output_value = [output_value] + if not multi: + output_value, output_spec = [output_value], [output_spec] _validate.validate_multi_return(output_spec, output_value, callback_id) diff --git a/dash/dependencies.py b/dash/dependencies.py index daccd4fc23..883f10817b 100644 --- a/dash/dependencies.py +++ b/dash/dependencies.py @@ -137,7 +137,6 @@ def __repr__(self): def extract_callback_args(args, kwargs, name, type_): """Extract arguments for callback from a name and type""" - print(args, kwargs) parameters = kwargs.get(name, []) if not parameters: while args and isinstance(args[0], type_): @@ -145,26 +144,32 @@ def extract_callback_args(args, kwargs, name, type_): return parameters -def handle_callback_args(*args, **kwargs): +def handle_callback_args(args, kwargs): """Split args into outputs, inputs and states""" prevent_initial_call = kwargs.get("prevent_initial_call", None) - # flatten args - args = [ - arg - # for backward compatibility, one arg can be a list - for arg_or_list in args - # flatten args that are lists - for arg in ( - arg_or_list if isinstance(arg_or_list, (list, tuple)) else [arg_or_list] - ) - ] - outputs = extract_callback_args(args, kwargs, "output", Output) - inputs = extract_callback_args(args, kwargs, "inputs", Input) - states = extract_callback_args(args, kwargs, "state", State) + if prevent_initial_call is None and len(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) + if len(outputs) == 1: + out0 = kwargs.get("output", args[0] if len(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) - if args: + if flat_args: raise TypeError( - "callback must received first all Outputs, then all Inputs, then all States" + "In a callback definition, you must provide all Outputs first,\n" + "then all Inputs, then all States. Trailing this we found:\n" + + repr(flat_args) ) return [ outputs, From bfabbeab23cf7d75212cf87824fbeee704ea4a63 Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Fri, 10 Jul 2020 15:47:09 -0400 Subject: [PATCH 16/28] fix changelog for single_input --- CHANGELOG.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4339dd1d4d..9d1aa5183d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,18 +2,17 @@ All notable changes to `dash` will be documented in this file. This project adheres to [Semantic Versioning](https://semver.org/). -## Unreleased +## [UNRELEASED] ### Added - [#1315](https://github.com/plotly/dash/pull/1315) Add `update_title` parameter to set or disable the "Updating...." document title during updates. Closes [#856](https://github.com/plotly/dash/issues/856) and [#732](https://github.com/plotly/dash/issues/732) +### 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.13.4] - 2020-06-25 ### Fixed - [#1310](https://github.com/plotly/dash/pull/1310) Fix a regression since 1.13.0 preventing more than one loading state from being shown at a time. -## [UNRELEASED] -### 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.13.3] - 2020-06-19 ## [1.13.2] - 2020-06-18 From 23f93136e576e1f60fc3c076ae3487de1997ab3d Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Fri, 10 Jul 2020 15:51:20 -0400 Subject: [PATCH 17/28] remove commented-out code --- dash/dash.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/dash/dash.py b/dash/dash.py index 51ded89122..83c9b5b3b8 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -959,11 +959,6 @@ def callback(self, *_args, **_kwargs): """ - # kwargs["prevent_initial_call"] = kwargs.get("prevent_initial_call", None) - # output = kwargs.get("output", args[0]) - # # for backward compatibility, store whether first argument is a - # # list of only 1 Output - # specified_output_list = isinstance(output, (list, tuple)) and len(output) == 1 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)) From 868cff39814370604a87ebcf5656b6dadbd85955 Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Fri, 10 Jul 2020 15:55:26 -0400 Subject: [PATCH 18/28] black - now that it's actually running in CI :tada: --- dash/dash.py | 4 +++- tests/integration/renderer/test_loading_states.py | 7 ++----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/dash/dash.py b/dash/dash.py index 83c9b5b3b8..ac8f71b87d 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -959,7 +959,9 @@ def callback(self, *_args, **_kwargs): """ - output, inputs, state, prevent_initial_call = handle_callback_args(_args, _kwargs) + 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)) diff --git a/tests/integration/renderer/test_loading_states.py b/tests/integration/renderer/test_loading_states.py index b946d72e33..591c9a9482 100644 --- a/tests/integration/renderer/test_loading_states.py +++ b/tests/integration/renderer/test_loading_states.py @@ -178,7 +178,7 @@ def find_text(spec): ({"update_title": None}, "Dash"), ({"update_title": ""}, "Dash"), ({"update_title": "Hello World"}, "Hello World"), - ] + ], ) def test_rdls003_update_title(dash_duo, kwargs, expected_update_title): app = dash.Dash("Dash", **kwargs) @@ -192,10 +192,7 @@ def test_rdls003_update_title(dash_duo, kwargs, expected_update_title): ] ) - @app.callback( - Output("output", "children"), - [Input("button", "n_clicks")] - ) + @app.callback(Output("output", "children"), [Input("button", "n_clicks")]) def update(n): with lock: return n From c6dc6a84e6b3c44df4d4e50b2f29de262f4fa553 Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Fri, 10 Jul 2020 16:00:52 -0400 Subject: [PATCH 19/28] lint a little more --- dash/dependencies.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/dash/dependencies.py b/dash/dependencies.py index 883f10817b..e31588fd9f 100644 --- a/dash/dependencies.py +++ b/dash/dependencies.py @@ -147,7 +147,7 @@ def extract_callback_args(args, kwargs, name, type_): 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 len(args) and isinstance(args[-1], bool): + 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 @@ -158,7 +158,7 @@ def handle_callback_args(args, kwargs): outputs = extract_callback_args(flat_args, kwargs, "output", Output) if len(outputs) == 1: - out0 = kwargs.get("output", args[0] if len(args) else None) + out0 = kwargs.get("output", args[0] if args else None) if not isinstance(out0, (list, tuple)): outputs = outputs[0] @@ -171,9 +171,5 @@ def handle_callback_args(args, kwargs): "then all Inputs, then all States. Trailing this we found:\n" + repr(flat_args) ) - return [ - outputs, - inputs, - states, - prevent_initial_call, - ] + + return outputs, inputs, states, prevent_initial_call From 3006905992d50bd27b594ac08061af131e9e6ca2 Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Fri, 10 Jul 2020 17:18:15 -0400 Subject: [PATCH 20/28] simplify validation & fix exception type --- dash/_validate.py | 47 +++++++++++++++++++++++--------------------- dash/dash.py | 1 - dash/dependencies.py | 11 +++++------ 3 files changed, 30 insertions(+), 29 deletions(-) diff --git a/dash/_validate.py b/dash/_validate.py index e5bd3e7bcb..d3a9a9f026 100644 --- a/dash/_validate.py +++ b/dash/_validate.py @@ -2,12 +2,35 @@ 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): + 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]) + ) + ) + + raise exceptions.IncorrectTypeException( + """ + In a callback definition, you must provide all Outputs first, + "then all Inputs, then all States. Found this out of order: + {} + """.format( + repr(extra_args) + ) + ) + is_multi = isinstance(output, (list, tuple)) outputs = output if is_multi else [output] @@ -17,27 +40,7 @@ def validate_callback(output, inputs, state): 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. - """.format( - name.lower(), str(args), name - ) - ) - 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 - ) - ) - if not isinstance(getattr(arg, "component_property", None), _strings): raise exceptions.IncorrectTypeException( """ diff --git a/dash/dash.py b/dash/dash.py index ac8f71b87d..d223a1317f 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -831,7 +831,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, diff --git a/dash/dependencies.py b/dash/dependencies.py index e31588fd9f..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): @@ -157,6 +159,7 @@ def handle_callback_args(args, kwargs): 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)): @@ -165,11 +168,7 @@ def handle_callback_args(args, kwargs): inputs = extract_callback_args(flat_args, kwargs, "inputs", Input) states = extract_callback_args(flat_args, kwargs, "state", State) - if flat_args: - raise TypeError( - "In a callback definition, you must provide all Outputs first,\n" - "then all Inputs, then all States. Trailing this we found:\n" - + repr(flat_args) - ) + types = Input, Output, State + validate_callback(validate_outputs, inputs, states, flat_args, types) return outputs, inputs, states, prevent_initial_call From 5e17caf7da068f900c9127deda708ab480f4334b Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Fri, 10 Jul 2020 17:18:44 -0400 Subject: [PATCH 21/28] rearrange callback tests get some more of them out of the old test_integration --- .../callbacks/test_basic_callback.py | 50 +++ .../callbacks/test_prevent_update.py | 167 ++++++++ .../integration/callbacks/test_validation.py | 112 ++++++ tests/integration/test_integration.py | 358 +----------------- 4 files changed, 332 insertions(+), 355 deletions(-) create mode 100644 tests/integration/callbacks/test_prevent_update.py create mode 100644 tests/integration/callbacks/test_validation.py diff --git a/tests/integration/callbacks/test_basic_callback.py b/tests/integration/callbacks/test_basic_callback.py index f7f37c2f47..9ac0172a13 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_callbacka(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..a7c226139f --- /dev/null +++ b/tests/integration/callbacks/test_validation.py @@ -0,0 +1,112 @@ +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): + + @app.callback([[Output("out1", "children")]], [Input("in1", "children")]) + def f(i): + return i + + pytest.fail("extra output nesting") + + # 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") diff --git a/tests/integration/test_integration.py b/tests/integration/test_integration.py index c0b1dad122..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,110 +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="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): - - @app.callback([[Output("out1", "children")]], [Input("in1", "children")]) - def f(i): - return i - - pytest.fail("extra output nesting") - - # 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_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") From d301e3b57c71ee5b97acc8fcc4c46608c653605c Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Fri, 10 Jul 2020 17:20:12 -0400 Subject: [PATCH 22/28] tyop --- tests/integration/callbacks/test_basic_callback.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/callbacks/test_basic_callback.py b/tests/integration/callbacks/test_basic_callback.py index 9ac0172a13..13276b1899 100644 --- a/tests/integration/callbacks/test_basic_callback.py +++ b/tests/integration/callbacks/test_basic_callback.py @@ -344,7 +344,7 @@ def set_path(n): dash_duo.wait_for_text_to_equal("#out", '[{"a": "/2:a"}] - /2') -def test_cbsc008_wildcard_prop_callbacka(dash_duo): +def test_cbsc008_wildcard_prop_callbacks(dash_duo): app = dash.Dash(__name__) app.layout = html.Div( [ From 473895e074b6a4879e9730b0087569596332bf77 Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Fri, 10 Jul 2020 17:36:59 -0400 Subject: [PATCH 23/28] lint & simplify _validate --- dash/_validate.py | 58 +++++++++++++++++++++++------------------------ 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/dash/_validate.py b/dash/_validate.py index d3a9a9f026..6ecc8c0632 100644 --- a/dash/_validate.py +++ b/dash/_validate.py @@ -35,43 +35,43 @@ def validate_callback(output, inputs, state, extra_args, types): outputs = output if is_multi else [output] - for args, cls in [(outputs, Output), (inputs, Input), (state, State)]: - validate_callback_args(args, cls) + for args in [outputs, inputs, state]: + for arg in args: + validate_callback_arg(arg) -def validate_callback_args(args, cls): - for arg in args: - if not isinstance(getattr(arg, "component_property", None), _strings): - raise exceptions.IncorrectTypeException( - """ - component_property must be a string, found {!r} - """.format( - arg.component_property - ) +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 hasattr(arg, "component_event"): - raise exceptions.NonExistentEventException( - """ - Events have been removed. - Use the associated property instead. - """ - ) + if hasattr(arg, "component_event"): + raise exceptions.NonExistentEventException( + """ + Events have been removed. + Use the associated property instead. + """ + ) - if isinstance(arg.component_id, dict): - validate_id_dict(arg) + if isinstance(arg.component_id, dict): + validate_id_dict(arg) - elif isinstance(arg.component_id, _strings): - validate_id_string(arg) + 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 - ) + else: + raise exceptions.IncorrectTypeException( + """ + component_id must be a string or dict, found {!r} + """.format( + arg.component_id ) + ) def validate_id_dict(arg): From 302f44c4a138ad763e117627fb7af010cd0ec91a Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Fri, 10 Jul 2020 17:37:33 -0400 Subject: [PATCH 24/28] test for wrapped vs unwrapped single output callback --- .../integration/callbacks/test_validation.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/integration/callbacks/test_validation.py b/tests/integration/callbacks/test_validation.py index a7c226139f..1a59786d59 100644 --- a/tests/integration/callbacks/test_validation.py +++ b/tests/integration/callbacks/test_validation.py @@ -110,3 +110,22 @@ def multi2(a): ] 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") From 3d7bc0cf0fa610a1ce06488e1266b2197663fe6d Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Fri, 10 Jul 2020 17:44:19 -0400 Subject: [PATCH 25/28] convert a few wildcard tests - including clientside callbacks - to unwrapped args --- tests/integration/callbacks/test_wildcards.py | 39 ++++++++----------- 1 file changed, 17 insertions(+), 22 deletions(-) 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 From 06039d7a9c64e4d6e3d5895f0f83cf2f57f8f796 Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Fri, 10 Jul 2020 17:47:58 -0400 Subject: [PATCH 26/28] lint --- tests/integration/callbacks/test_validation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/callbacks/test_validation.py b/tests/integration/callbacks/test_validation.py index 1a59786d59..793bb50509 100644 --- a/tests/integration/callbacks/test_validation.py +++ b/tests/integration/callbacks/test_validation.py @@ -115,7 +115,7 @@ def multi2(a): 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"),] + [html.Div("Hi", id="in"), html.Div(id="out1"), html.Div(id="out2")] ) @app.callback(Output("out1", "children"), Input("in", "children")) From d158ef8bfb4128f23578ab42d20e17d9745e9d87 Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Fri, 31 Jul 2020 16:52:07 -0400 Subject: [PATCH 27/28] more targeted error message on out-of-order callback args --- dash/_validate.py | 15 +++++++++------ tests/integration/callbacks/test_validation.py | 17 ++++++++++++++++- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/dash/_validate.py b/dash/_validate.py index 6ecc8c0632..178529a665 100644 --- a/dash/_validate.py +++ b/dash/_validate.py @@ -7,6 +7,10 @@ def validate_callback(output, inputs, state, extra_args, types): + is_multi = isinstance(output, (list, tuple)) + + outputs = output if is_multi else [output] + Input, Output, State = types if extra_args: if not isinstance(extra_args[0], (Output, Input, State)): @@ -24,17 +28,16 @@ def validate_callback(output, inputs, state, extra_args, types): raise exceptions.IncorrectTypeException( """ In a callback definition, you must provide all Outputs first, - "then all Inputs, then all States. Found this out of order: + then all Inputs, then all States. After this item: + {} + we found this item next: {} """.format( - repr(extra_args) + repr((outputs + inputs + state)[-1]), + repr(extra_args[0]) ) ) - is_multi = isinstance(output, (list, tuple)) - - outputs = output if is_multi else [output] - for args in [outputs, inputs, state]: for arg in args: validate_callback_arg(arg) diff --git a/tests/integration/callbacks/test_validation.py b/tests/integration/callbacks/test_validation.py index 793bb50509..249eb6172c 100644 --- a/tests/integration/callbacks/test_validation.py +++ b/tests/integration/callbacks/test_validation.py @@ -24,7 +24,7 @@ def test_cbva001_callback_dep_types(): ] ) - with pytest.raises(IncorrectTypeException): + with pytest.raises(IncorrectTypeException) as err: @app.callback([[Output("out1", "children")]], [Input("in1", "children")]) def f(i): @@ -32,6 +32,21 @@ def f(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"),), From 47cb860d7b8820f5ac344977b00971b45257ea69 Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Fri, 31 Jul 2020 16:56:58 -0400 Subject: [PATCH 28/28] black --- dash/_validate.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/dash/_validate.py b/dash/_validate.py index 178529a665..76047242a6 100644 --- a/dash/_validate.py +++ b/dash/_validate.py @@ -33,8 +33,7 @@ def validate_callback(output, inputs, state, extra_args, types): we found this item next: {} """.format( - repr((outputs + inputs + state)[-1]), - repr(extra_args[0]) + repr((outputs + inputs + state)[-1]), repr(extra_args[0]) ) )