Skip to content

Commit d24b303

Browse files
authored
Merge pull request #1180 from mbegel/single_input
Single input
2 parents 44a45fc + 34b61a2 commit d24b303

11 files changed

+490
-425
lines changed

Diff for: CHANGELOG.md

+3
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ This project adheres to [Semantic Versioning](https://semver.org/).
66
### Added
77
- [#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)`.
88

9+
### Changed
10+
- [#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.
11+
912
## [1.14.0] - 2020-07-27
1013
### Added
1114
- [#1343](https://github.com/plotly/dash/pull/1343) Add `title` parameter to set the

Diff for: dash/_validate.py

+50-45
Original file line numberDiff line numberDiff line change
@@ -2,73 +2,78 @@
22
import re
33

44
from .development.base_component import Component
5-
from .dependencies import Input, Output, State
65
from . import exceptions
76
from ._utils import patch_collections_abc, _strings, stringify_id
87

98

10-
def validate_callback(output, inputs, state):
9+
def validate_callback(output, inputs, state, extra_args, types):
1110
is_multi = isinstance(output, (list, tuple))
1211

1312
outputs = output if is_multi else [output]
1413

15-
for args, cls in [(outputs, Output), (inputs, Input), (state, State)]:
16-
validate_callback_args(args, cls)
17-
14+
Input, Output, State = types
15+
if extra_args:
16+
if not isinstance(extra_args[0], (Output, Input, State)):
17+
raise exceptions.IncorrectTypeException(
18+
"""
19+
Callback arguments must be `Output`, `Input`, or `State` objects,
20+
optionally wrapped in a list or tuple. We found (possibly after
21+
unwrapping a list or tuple):
22+
{}
23+
""".format(
24+
repr(extra_args[0])
25+
)
26+
)
1827

19-
def validate_callback_args(args, cls):
20-
name = cls.__name__
21-
if not isinstance(args, (list, tuple)):
2228
raise exceptions.IncorrectTypeException(
2329
"""
24-
The {} argument `{}` must be a list or tuple of
25-
`dash.dependencies.{}`s.
30+
In a callback definition, you must provide all Outputs first,
31+
then all Inputs, then all States. After this item:
32+
{}
33+
we found this item next:
34+
{}
2635
""".format(
27-
name.lower(), str(args), name
36+
repr((outputs + inputs + state)[-1]), repr(extra_args[0])
2837
)
2938
)
3039

31-
for arg in args:
32-
if not isinstance(arg, cls):
33-
raise exceptions.IncorrectTypeException(
34-
"""
35-
The {} argument `{}` must be of type `dash.dependencies.{}`.
36-
""".format(
37-
name.lower(), str(arg), name
38-
)
39-
)
40+
for args in [outputs, inputs, state]:
41+
for arg in args:
42+
validate_callback_arg(arg)
4043

41-
if not isinstance(getattr(arg, "component_property", None), _strings):
42-
raise exceptions.IncorrectTypeException(
43-
"""
44-
component_property must be a string, found {!r}
45-
""".format(
46-
arg.component_property
47-
)
48-
)
4944

50-
if hasattr(arg, "component_event"):
51-
raise exceptions.NonExistentEventException(
52-
"""
53-
Events have been removed.
54-
Use the associated property instead.
55-
"""
45+
def validate_callback_arg(arg):
46+
if not isinstance(getattr(arg, "component_property", None), _strings):
47+
raise exceptions.IncorrectTypeException(
48+
"""
49+
component_property must be a string, found {!r}
50+
""".format(
51+
arg.component_property
5652
)
53+
)
5754

58-
if isinstance(arg.component_id, dict):
59-
validate_id_dict(arg)
55+
if hasattr(arg, "component_event"):
56+
raise exceptions.NonExistentEventException(
57+
"""
58+
Events have been removed.
59+
Use the associated property instead.
60+
"""
61+
)
6062

61-
elif isinstance(arg.component_id, _strings):
62-
validate_id_string(arg)
63+
if isinstance(arg.component_id, dict):
64+
validate_id_dict(arg)
6365

64-
else:
65-
raise exceptions.IncorrectTypeException(
66-
"""
67-
component_id must be a string or dict, found {!r}
68-
""".format(
69-
arg.component_id
70-
)
66+
elif isinstance(arg.component_id, _strings):
67+
validate_id_string(arg)
68+
69+
else:
70+
raise exceptions.IncorrectTypeException(
71+
"""
72+
component_id must be a string or dict, found {!r}
73+
""".format(
74+
arg.component_id
7175
)
76+
)
7277

7378

7479
def validate_id_dict(arg):

Diff for: dash/dash.py

+13-9
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323

2424
from .fingerprint import build_fingerprint, check_fingerprint
2525
from .resources import Scripts, Css
26+
from .dependencies import handle_callback_args
2627
from .development.base_component import ComponentRegistry
2728
from .exceptions import PreventUpdate, InvalidResourceError, ProxyError
2829
from .version import __version__
@@ -848,7 +849,6 @@ def _insert_callback(self, output, inputs, state, prevent_initial_call):
848849
if prevent_initial_call is None:
849850
prevent_initial_call = self.config.prevent_initial_callbacks
850851

851-
_validate.validate_callback(output, inputs, state)
852852
callback_id = create_callback_id(output)
853853
callback_spec = {
854854
"output": callback_id,
@@ -865,9 +865,7 @@ def _insert_callback(self, output, inputs, state, prevent_initial_call):
865865

866866
return callback_id
867867

868-
def clientside_callback(
869-
self, clientside_function, output, inputs, state=(), prevent_initial_call=None
870-
):
868+
def clientside_callback(self, clientside_function, *args, **kwargs):
871869
"""Create a callback that updates the output by calling a clientside
872870
(JavaScript) function instead of a Python function.
873871
@@ -932,6 +930,7 @@ def clientside_callback(
932930
not to fire when its outputs are first added to the page. Defaults to
933931
`False` unless `prevent_initial_callbacks=True` at the app level.
934932
"""
933+
output, inputs, state, prevent_initial_call = handle_callback_args(args, kwargs)
935934
self._insert_callback(output, inputs, state, prevent_initial_call)
936935

937936
# If JS source is explicitly given, create a namespace and function
@@ -963,18 +962,23 @@ def clientside_callback(
963962
"function_name": function_name,
964963
}
965964

966-
def callback(self, output, inputs, state=(), prevent_initial_call=None):
965+
def callback(self, *_args, **_kwargs):
967966
"""
968967
Normally used as a decorator, `@app.callback` provides a server-side
969-
callback relating the values of one or more `output` items to one or
970-
more `input` items which will trigger the callback when they change,
971-
and optionally `state` items which provide additional information but
968+
callback relating the values of one or more `Output` items to one or
969+
more `Input` items which will trigger the callback when they change,
970+
and optionally `State` items which provide additional information but
972971
do not trigger the callback directly.
973972
974973
The last, optional argument `prevent_initial_call` causes the callback
975974
not to fire when its outputs are first added to the page. Defaults to
976975
`False` unless `prevent_initial_callbacks=True` at the app level.
976+
977+
977978
"""
979+
output, inputs, state, prevent_initial_call = handle_callback_args(
980+
_args, _kwargs
981+
)
978982
callback_id = self._insert_callback(output, inputs, state, prevent_initial_call)
979983
multi = isinstance(output, (list, tuple))
980984

@@ -1046,7 +1050,7 @@ def dispatch(self):
10461050

10471051
response = flask.g.dash_response = flask.Response(mimetype="application/json")
10481052

1049-
args = inputs_to_vals(inputs) + inputs_to_vals(state)
1053+
args = inputs_to_vals(inputs + state)
10501054

10511055
func = self.callback_map[output]["callback"]
10521056
response.set_data(func(*args, outputs_list=outputs_list))

Diff for: dash/dependencies.py

+39
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import json
22

3+
from ._validate import validate_callback
4+
35

46
class _Wildcard: # pylint: disable=too-few-public-methods
57
def __init__(self, name):
@@ -133,3 +135,40 @@ def __init__(self, namespace=None, function_name=None):
133135

134136
def __repr__(self):
135137
return "ClientsideFunction({}, {})".format(self.namespace, self.function_name)
138+
139+
140+
def extract_callback_args(args, kwargs, name, type_):
141+
"""Extract arguments for callback from a name and type"""
142+
parameters = kwargs.get(name, [])
143+
if not parameters:
144+
while args and isinstance(args[0], type_):
145+
parameters.append(args.pop(0))
146+
return parameters
147+
148+
149+
def handle_callback_args(args, kwargs):
150+
"""Split args into outputs, inputs and states"""
151+
prevent_initial_call = kwargs.get("prevent_initial_call", None)
152+
if prevent_initial_call is None and args and isinstance(args[-1], bool):
153+
prevent_initial_call = args.pop()
154+
155+
# flatten args, to support the older syntax where outputs, inputs, and states
156+
# each needed to be in their own list
157+
flat_args = []
158+
for arg in args:
159+
flat_args += arg if isinstance(arg, (list, tuple)) else [arg]
160+
161+
outputs = extract_callback_args(flat_args, kwargs, "output", Output)
162+
validate_outputs = outputs
163+
if len(outputs) == 1:
164+
out0 = kwargs.get("output", args[0] if args else None)
165+
if not isinstance(out0, (list, tuple)):
166+
outputs = outputs[0]
167+
168+
inputs = extract_callback_args(flat_args, kwargs, "inputs", Input)
169+
states = extract_callback_args(flat_args, kwargs, "state", State)
170+
171+
types = Input, Output, State
172+
validate_callback(validate_outputs, inputs, states, flat_args, types)
173+
174+
return outputs, inputs, states, prevent_initial_call

Diff for: package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
"private::format.black": "black dash tests --exclude metadata_test.py",
66
"private::format.renderer": "cd dash-renderer && npm run format",
77
"private::initialize.renderer": "cd dash-renderer && npm ci",
8-
"private::lint.black": "if [[ $PYLINTRC != '.pylintrc' ]]; then black dash tests --exclude metadata_test.py --check; fi",
8+
"private::lint.black": "if [ ${PYLINTRC:-x} != '.pylintrc' ]; then black dash tests --exclude metadata_test.py --check; fi",
99
"private::lint.flake8": "flake8 --exclude=metadata_test.py dash tests",
1010
"private::lint.pylint-dash": "PYLINTRC=${PYLINTRC:=.pylintrc37} && pylint dash setup.py --rcfile=$PYLINTRC",
1111
"private::lint.pylint-tests": "PYLINTRC=${PYLINTRC:=.pylintrc37} && pylint tests/unit tests/integration -d all --rcfile=$PYLINTRC",

Diff for: tests/assets/simple_app.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
)
1919

2020

21-
@app.callback(Output("out", "children"), [Input("value", "value")])
21+
@app.callback(Output("out", "children"), Input("value", "value"))
2222
def on_value(value):
2323
if value is None:
2424
raise PreventUpdate

Diff for: tests/integration/callbacks/test_basic_callback.py

+50
Original file line numberDiff line numberDiff line change
@@ -342,3 +342,53 @@ def set_path(n):
342342
if not refresh:
343343
dash_duo.find_element("#btn").click()
344344
dash_duo.wait_for_text_to_equal("#out", '[{"a": "/2:a"}] - /2')
345+
346+
347+
def test_cbsc008_wildcard_prop_callbacks(dash_duo):
348+
app = dash.Dash(__name__)
349+
app.layout = html.Div(
350+
[
351+
dcc.Input(id="input", value="initial value"),
352+
html.Div(
353+
html.Div(
354+
[
355+
1.5,
356+
None,
357+
"string",
358+
html.Div(
359+
id="output-1",
360+
**{"data-cb": "initial value", "aria-cb": "initial value"}
361+
),
362+
]
363+
)
364+
),
365+
]
366+
)
367+
368+
input_call_count = Value("i", 0)
369+
370+
@app.callback(Output("output-1", "data-cb"), [Input("input", "value")])
371+
def update_data(value):
372+
input_call_count.value += 1
373+
return value
374+
375+
@app.callback(Output("output-1", "children"), [Input("output-1", "data-cb")])
376+
def update_text(data):
377+
return data
378+
379+
dash_duo.start_server(app)
380+
dash_duo.wait_for_text_to_equal("#output-1", "initial value")
381+
dash_duo.percy_snapshot(name="wildcard-callback-1")
382+
383+
input1 = dash_duo.find_element("#input")
384+
dash_duo.clear_input(input1)
385+
input1.send_keys("hello world")
386+
387+
dash_duo.wait_for_text_to_equal("#output-1", "hello world")
388+
dash_duo.percy_snapshot(name="wildcard-callback-2")
389+
390+
# an initial call, one for clearing the input
391+
# and one for each hello world character
392+
assert input_call_count.value == 2 + len("hello world")
393+
394+
assert not dash_duo.get_logs()

0 commit comments

Comments
 (0)