Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Single input #1180

Merged
merged 32 commits into from
Aug 6, 2020
Merged
Show file tree
Hide file tree
Changes from 31 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
250352a
Allow single Input not in a list
mbegel Apr 5, 2020
93284ac
One test uses single Input not in list
mbegel Apr 5, 2020
d167e94
Update CHANGELOG.md with PR 1180
mbegel Apr 5, 2020
c839c80
Don't use input as variable
mbegel Apr 5, 2020
0e03062
Adapt the integration code to un-nested inputs
mbegel Apr 5, 2020
e9f5559
Fix typo in error message
wilhelmhb Apr 15, 2020
35f35e6
Allow callback arguments to be passed outside lists
wilhelmhb Apr 15, 2020
3ab52cd
Add tests
wilhelmhb Apr 15, 2020
b61e4d0
Fix linting
wilhelmhb Apr 15, 2020
c926b20
Fix backward compatibility for single output lists
wilhelmhb Apr 15, 2020
775bf09
lint
alexcjohnson May 13, 2020
94b4bdf
Backward compatibility
wilhelmhb May 24, 2020
0ed8144
Merge branch 'dev' into single_input
alexcjohnson Jun 20, 2020
6edd176
lint - and hopefully make black work on CI
alexcjohnson Jun 20, 2020
3cdf975
move callback arg parsing into dependencies.py
alexcjohnson Jun 20, 2020
474b837
partial fix for flat callbacks
alexcjohnson Jun 24, 2020
5ead982
Merge branch 'dev' into single_input
alexcjohnson Jul 10, 2020
bfabbea
fix changelog for single_input
alexcjohnson Jul 10, 2020
23f9313
remove commented-out code
alexcjohnson Jul 10, 2020
868cff3
black - now that it's actually running in CI :tada:
alexcjohnson Jul 10, 2020
c6dc6a8
lint a little more
alexcjohnson Jul 10, 2020
3006905
simplify validation & fix exception type
alexcjohnson Jul 10, 2020
5e17caf
rearrange callback tests
alexcjohnson Jul 10, 2020
d301e3b
tyop
alexcjohnson Jul 10, 2020
473895e
lint & simplify _validate
alexcjohnson Jul 10, 2020
302f44c
test for wrapped vs unwrapped single output callback
alexcjohnson Jul 10, 2020
3d7bc0c
convert a few wildcard tests - including clientside callbacks - to un…
alexcjohnson Jul 10, 2020
06039d7
lint
alexcjohnson Jul 10, 2020
bda5823
Merge branch 'dev' into single_input
alexcjohnson Jul 31, 2020
d158ef8
more targeted error message on out-of-order callback args
alexcjohnson Jul 31, 2020
47cb860
black
alexcjohnson Jul 31, 2020
34b61a2
Merge branch 'dev' into single_input
alexcjohnson Aug 6, 2020
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@
All notable changes to `dash` will be documented in this file.
This project adheres to [Semantic Versioning](https://semver.org/).

## [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.14.0] - 2020-07-27
### Added
- [#1343](https://github.com/plotly/dash/pull/1343) Add `title` parameter to set the
Expand Down
95 changes: 50 additions & 45 deletions dash/_validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,73 +2,78 @@
import re

from .development.base_component import Component
from .dependencies import Input, Output, State
from . import exceptions
from ._utils import patch_collections_abc, _strings, stringify_id


def validate_callback(output, inputs, state):
def validate_callback(output, inputs, state, extra_args, types):
is_multi = isinstance(output, (list, tuple))

outputs = output if is_multi else [output]

for args, cls in [(outputs, Output), (inputs, Input), (state, State)]:
validate_callback_args(args, cls)

Input, Output, State = types
if extra_args:
if not isinstance(extra_args[0], (Output, Input, State)):
raise exceptions.IncorrectTypeException(
"""
Callback arguments must be `Output`, `Input`, or `State` objects,
optionally wrapped in a list or tuple. We found (possibly after
unwrapping a list or tuple):
{}
""".format(
repr(extra_args[0])
)
)

def validate_callback_args(args, cls):
name = cls.__name__
if not isinstance(args, (list, tuple)):
raise exceptions.IncorrectTypeException(
"""
The {} argument `{}` must be a list or tuple of
`dash.dependencies.{}`s.
In a callback definition, you must provide all Outputs first,
then all Inputs, then all States. After this item:
{}
we found this item next:
{}
""".format(
name.lower(), str(args), name
repr((outputs + inputs + state)[-1]), repr(extra_args[0])
)
)

for arg in args:
if not isinstance(arg, cls):
raise exceptions.IncorrectTypeException(
"""
The {} argument `{}` must be of type `dash.dependencies.{}`.
""".format(
name.lower(), str(arg), name
)
)
for args in [outputs, inputs, state]:
for arg in args:
validate_callback_arg(arg)

if not isinstance(getattr(arg, "component_property", None), _strings):
raise exceptions.IncorrectTypeException(
"""
component_property must be a string, found {!r}
""".format(
arg.component_property
)
)

if hasattr(arg, "component_event"):
raise exceptions.NonExistentEventException(
"""
Events have been removed.
Use the associated property instead.
"""
def validate_callback_arg(arg):
if not isinstance(getattr(arg, "component_property", None), _strings):
raise exceptions.IncorrectTypeException(
"""
component_property must be a string, found {!r}
""".format(
arg.component_property
)
)

if isinstance(arg.component_id, dict):
validate_id_dict(arg)
if hasattr(arg, "component_event"):
raise exceptions.NonExistentEventException(
"""
Events have been removed.
Use the associated property instead.
"""
)

elif isinstance(arg.component_id, _strings):
validate_id_string(arg)
if isinstance(arg.component_id, dict):
validate_id_dict(arg)

else:
raise exceptions.IncorrectTypeException(
"""
component_id must be a string or dict, found {!r}
""".format(
arg.component_id
)
elif isinstance(arg.component_id, _strings):
validate_id_string(arg)

else:
raise exceptions.IncorrectTypeException(
"""
component_id must be a string or dict, found {!r}
""".format(
arg.component_id
)
)


def validate_id_dict(arg):
Expand Down
22 changes: 13 additions & 9 deletions dash/dash.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@

from .fingerprint import build_fingerprint, check_fingerprint
from .resources import Scripts, Css
from .dependencies import handle_callback_args
from .development.base_component import ComponentRegistry
from .exceptions import PreventUpdate, InvalidResourceError, ProxyError
from .version import __version__
Expand Down Expand Up @@ -846,7 +847,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,
Expand All @@ -863,9 +863,7 @@ def _insert_callback(self, output, inputs, state, prevent_initial_call):

return callback_id

def clientside_callback(
self, clientside_function, output, inputs, state=(), prevent_initial_call=None
):
def clientside_callback(self, clientside_function, *args, **kwargs):
"""Create a callback that updates the output by calling a clientside
(JavaScript) function instead of a Python function.

Expand Down Expand Up @@ -930,6 +928,7 @@ def clientside_callback(
not to fire when its outputs are first added to the page. Defaults to
`False` unless `prevent_initial_callbacks=True` at the app level.
"""
output, inputs, state, prevent_initial_call = handle_callback_args(args, kwargs)
self._insert_callback(output, inputs, state, prevent_initial_call)

# If JS source is explicitly given, create a namespace and function
Expand Down Expand Up @@ -961,18 +960,23 @@ def clientside_callback(
"function_name": function_name,
}

def callback(self, output, inputs, state=(), prevent_initial_call=None):
def callback(self, *_args, **_kwargs):
"""
Normally used as a decorator, `@app.callback` provides a server-side
callback relating the values of one or more `output` items to one or
more `input` items which will trigger the callback when they change,
and optionally `state` items which provide additional information but
callback relating the values of one or more `Output` items to one or
more `Input` items which will trigger the callback when they change,
and optionally `State` items which provide additional information but
do not trigger the callback directly.

The last, optional argument `prevent_initial_call` causes the callback
not to fire when its outputs are first added to the page. Defaults to
`False` unless `prevent_initial_callbacks=True` at the app level.


"""
output, inputs, state, prevent_initial_call = handle_callback_args(
_args, _kwargs
)
callback_id = self._insert_callback(output, inputs, state, prevent_initial_call)
multi = isinstance(output, (list, tuple))

Expand Down Expand Up @@ -1044,7 +1048,7 @@ def dispatch(self):

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

args = inputs_to_vals(inputs) + inputs_to_vals(state)
args = inputs_to_vals(inputs + state)

func = self.callback_map[output]["callback"]
response.set_data(func(*args, outputs_list=outputs_list))
Expand Down
39 changes: 39 additions & 0 deletions dash/dependencies.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import json

from ._validate import validate_callback


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

def __repr__(self):
return "ClientsideFunction({}, {})".format(self.namespace, self.function_name)


def extract_callback_args(args, kwargs, name, type_):
"""Extract arguments for callback from a name and type"""
parameters = kwargs.get(name, [])
if not parameters:
while args and isinstance(args[0], type_):
parameters.append(args.pop(0))
return parameters


def handle_callback_args(args, kwargs):
"""Split args into outputs, inputs and states"""
prevent_initial_call = kwargs.get("prevent_initial_call", None)
if prevent_initial_call is None and args and isinstance(args[-1], bool):
prevent_initial_call = args.pop()

# flatten args, to support the older syntax where outputs, inputs, and states
# each needed to be in their own list
flat_args = []
for arg in args:
flat_args += arg if isinstance(arg, (list, tuple)) else [arg]

outputs = extract_callback_args(flat_args, kwargs, "output", Output)
validate_outputs = outputs
if len(outputs) == 1:
out0 = kwargs.get("output", args[0] if args else None)
if not isinstance(out0, (list, tuple)):
outputs = outputs[0]

inputs = extract_callback_args(flat_args, kwargs, "inputs", Input)
states = extract_callback_args(flat_args, kwargs, "state", State)

types = Input, Output, State
validate_callback(validate_outputs, inputs, states, flat_args, types)

return outputs, inputs, states, prevent_initial_call
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion tests/assets/simple_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
50 changes: 50 additions & 0 deletions tests/integration/callbacks/test_basic_callback.py
Original file line number Diff line number Diff line change
Expand Up @@ -342,3 +342,53 @@ def set_path(n):
if not refresh:
dash_duo.find_element("#btn").click()
dash_duo.wait_for_text_to_equal("#out", '[{"a": "/2:a"}] - /2')


def test_cbsc008_wildcard_prop_callbacks(dash_duo):
app = dash.Dash(__name__)
app.layout = html.Div(
[
dcc.Input(id="input", value="initial value"),
html.Div(
html.Div(
[
1.5,
None,
"string",
html.Div(
id="output-1",
**{"data-cb": "initial value", "aria-cb": "initial value"}
),
]
)
),
]
)

input_call_count = Value("i", 0)

@app.callback(Output("output-1", "data-cb"), [Input("input", "value")])
def update_data(value):
input_call_count.value += 1
return value

@app.callback(Output("output-1", "children"), [Input("output-1", "data-cb")])
def update_text(data):
return data

dash_duo.start_server(app)
dash_duo.wait_for_text_to_equal("#output-1", "initial value")
dash_duo.percy_snapshot(name="wildcard-callback-1")

input1 = dash_duo.find_element("#input")
dash_duo.clear_input(input1)
input1.send_keys("hello world")

dash_duo.wait_for_text_to_equal("#output-1", "hello world")
dash_duo.percy_snapshot(name="wildcard-callback-2")

# an initial call, one for clearing the input
# and one for each hello world character
assert input_call_count.value == 2 + len("hello world")

assert not dash_duo.get_logs()
Loading