Skip to content

Commit 9db79cb

Browse files
authored
Merge pull request #436 from plotly/multi-output
Multi output callbacks support.
2 parents f22973d + 52eabdc commit 9db79cb

File tree

9 files changed

+301
-88
lines changed

9 files changed

+301
-88
lines changed

.circleci/config.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ jobs:
2828
sudo pip install virtualenv
2929
virtualenv venv
3030
. venv/bin/activate
31-
pip install -r $REQUIREMENTS_FILE
31+
pip install -r $REQUIREMENTS_FILE --force-reinstall
3232
3333
- save_cache:
3434
key: deps1-{{ .Branch }}-{{ checksum "reqs.txt" }}-{{ checksum ".circleci/config.yml" }}-{{ checksum "circlejob.txt" }}

.circleci/requirements/dev-requirements-py37.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ dash_core_components==0.43.1
22
dash_html_components==0.13.5
33
dash-flow-example==0.0.5
44
dash-dangerously-set-inner-html
5-
-e git://github.com/plotly/dash-renderer.git@master#egg=dash_renderer
5+
-e git+git://github.com/plotly/dash-renderer@multi-output#egg=dash_renderer
66
percy
77
selenium
88
mock

.circleci/requirements/dev-requirements.txt

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,12 @@ dash_core_components==0.43.1
22
dash_html_components==0.13.5
33
dash_flow_example==0.0.5
44
dash-dangerously-set-inner-html
5-
-e git://github.com/plotly/dash-renderer.git@master#egg=dash_renderer
5+
-e git+git://github.com/plotly/dash-renderer@multi-output#egg=dash_renderer
66
percy
77
selenium
88
mock
99
tox
1010
tox-pyenv
11-
mock
1211
six
1312
plotly==3.6.1
1413
requests[security]

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,4 @@ dist
2020
npm-debug*
2121
/.tox
2222
.idea
23+
.mypy_cache/

dash/_utils.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,3 +84,15 @@ def first(self, *names):
8484
value = self.get(name)
8585
if value:
8686
return value
87+
88+
89+
def create_callback_id(output):
90+
if isinstance(output, (list, tuple)):
91+
return '..{}..'.format('...'.join(
92+
'{}.{}'.format(x.component_id, x.component_property)
93+
for x in output
94+
))
95+
96+
return '{}.{}'.format(
97+
output.component_id, output.component_property
98+
)

dash/dash.py

Lines changed: 140 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import warnings
1313
import re
1414
import logging
15+
import pprint
1516

1617
from functools import wraps
1718

@@ -30,9 +31,10 @@
3031
from ._utils import interpolate_str as _interpolate
3132
from ._utils import format_tag as _format_tag
3233
from ._utils import generate_hash as _generate_hash
33-
from ._utils import get_asset_path as _get_asset_path
3434
from ._utils import patch_collections_abc as _patch_collections_abc
3535
from . import _watch
36+
from ._utils import get_asset_path as _get_asset_path
37+
from ._utils import create_callback_id as _create_callback_id
3638
from . import _configs
3739

3840
_default_index = '''<!DOCTYPE html>
@@ -168,6 +170,9 @@ def __init__(
168170
self._meta_tags = meta_tags or []
169171
self._favicon = None
170172

173+
# default renderer string
174+
self.renderer = 'var renderer = new DashRenderer();'
175+
171176
if compress:
172177
# gzip
173178
Compress(self.server)
@@ -191,47 +196,36 @@ def _handle_error(_):
191196
# urls
192197
self.routes = []
193198

194-
self._add_url(
195-
'{}_dash-layout'.format(self.config['routes_pathname_prefix']),
196-
self.serve_layout)
199+
prefix = self.config['routes_pathname_prefix']
197200

198-
self._add_url(
199-
'{}_dash-dependencies'.format(
200-
self.config['routes_pathname_prefix']),
201-
self.dependencies)
201+
self._add_url('{}_dash-layout'.format(prefix), self.serve_layout)
202+
203+
self._add_url('{}_dash-dependencies'.format(prefix), self.dependencies)
202204

203205
self._add_url(
204-
'{}_dash-update-component'.format(
205-
self.config['routes_pathname_prefix']),
206+
'{}_dash-update-component'.format(prefix),
206207
self.dispatch,
207208
['POST'])
208209

209-
self._add_url((
210-
'{}_dash-component-suites'
211-
'/<string:package_name>'
212-
'/<path:path_in_package_dist>').format(
213-
self.config['routes_pathname_prefix']),
214-
self.serve_component_suites)
215-
216210
self._add_url(
217-
'{}_dash-routes'.format(self.config['routes_pathname_prefix']),
218-
self.serve_routes)
211+
(
212+
'{}_dash-component-suites'
213+
'/<string:package_name>'
214+
'/<path:path_in_package_dist>'
215+
).format(prefix),
216+
self.serve_component_suites)
219217

220-
self._add_url(
221-
self.config['routes_pathname_prefix'],
222-
self.index)
218+
self._add_url('{}_dash-routes'.format(prefix), self.serve_routes)
223219

224-
self._add_url(
225-
'{}_reload-hash'.format(self.config['routes_pathname_prefix']),
226-
self.serve_reload_hash)
220+
self._add_url(prefix, self.index)
221+
222+
self._add_url('{}_reload-hash'.format(prefix), self.serve_reload_hash)
227223

228224
# catch-all for front-end routes, used by dcc.Location
229-
self._add_url(
230-
'{}<path:path>'.format(self.config['routes_pathname_prefix']),
231-
self.index)
225+
self._add_url('{}<path:path>'.format(prefix), self.index)
232226

233227
self._add_url(
234-
'{}_favicon.ico'.format(self.config['routes_pathname_prefix']),
228+
'{}_favicon.ico'.format(prefix),
235229
self._serve_default_favicon)
236230

237231
self.server.before_first_request(self._setup_server)
@@ -273,9 +267,6 @@ def _add_url(self, name, view_func, methods=('GET',)):
273267
# e.g. for adding authentication with flask_login
274268
self.routes.append(name)
275269

276-
# default renderer string
277-
self.renderer = 'var renderer = new DashRenderer();'
278-
279270
@property
280271
def layout(self):
281272
return self._layout
@@ -637,10 +628,7 @@ def interpolate_index(self, **kwargs):
637628
def dependencies(self):
638629
return flask.jsonify([
639630
{
640-
'output': {
641-
'id': k.split('.')[0],
642-
'property': k.split('.')[1]
643-
},
631+
'output': k,
644632
'inputs': v['inputs'],
645633
'state': v['state'],
646634
} for k, v in self.callback_map.items()
@@ -656,11 +644,33 @@ def react(self, *args, **kwargs):
656644
def _validate_callback(self, output, inputs, state):
657645
# pylint: disable=too-many-branches
658646
layout = self._cached_layout or self._layout_value()
647+
is_multi = isinstance(output, (list, tuple))
659648

660649
for i in inputs:
661-
if output == i:
650+
bad = None
651+
if is_multi:
652+
for o in output:
653+
if o == i:
654+
bad = o
655+
else:
656+
if output == i:
657+
bad = output
658+
if bad:
662659
raise exceptions.SameInputOutputException(
663-
'Same output and input: {}'.format(output)
660+
'Same output and input: {}'.format(bad)
661+
)
662+
663+
if is_multi:
664+
if len(set(output)) != len(output):
665+
raise exceptions.DuplicateCallbackOutput(
666+
'Same output was used in a'
667+
' multi output callback!\n Duplicates:\n {}'.format(
668+
',\n'.join(
669+
k for k, v in
670+
((str(x), output.count(x)) for x in output)
671+
if v > 1
672+
)
673+
)
664674
)
665675

666676
if (layout is None and
@@ -676,7 +686,10 @@ def _validate_callback(self, output, inputs, state):
676686
`app.config['suppress_callback_exceptions']=True`
677687
'''.replace(' ', ''))
678688

679-
for args, obj, name in [([output], Output, 'Output'),
689+
for args, obj, name in [(output if isinstance(output, (list, tuple))
690+
else [output],
691+
(Output, list, tuple),
692+
'Output'),
680693
(inputs, Input, 'Input'),
681694
(state, State, 'State')]:
682695

@@ -695,6 +708,15 @@ def _validate_callback(self, output, inputs, state):
695708
name.lower(), str(arg), name
696709
))
697710

711+
invalid_characters = ['.']
712+
if any(x in arg.component_id for x in invalid_characters):
713+
raise exceptions.InvalidComponentIdError('''The element
714+
`{}` contains {} in its ID.
715+
Periods are not allowed in IDs right now.'''.format(
716+
arg.component_id,
717+
invalid_characters
718+
))
719+
698720
if (not self.config.first('suppress_callback_exceptions',
699721
'supress_callback_exceptions') and
700722
arg.component_id not in layout and
@@ -765,24 +787,48 @@ def _validate_callback(self, output, inputs, state):
765787
'elements' if len(state) > 1 else 'element'
766788
).replace(' ', ''))
767789

768-
if '.' in output.component_id:
769-
raise exceptions.IDsCantContainPeriods('''The Output element
770-
`{}` contains a period in its ID.
771-
Periods are not allowed in IDs right now.'''.format(
772-
output.component_id
773-
))
774-
775-
callback_id = '{}.{}'.format(
776-
output.component_id, output.component_property)
777-
if callback_id in self.callback_map:
778-
raise exceptions.CantHaveMultipleOutputs('''
790+
callback_id = _create_callback_id(output)
791+
792+
callbacks = set(itertools.chain(*(
793+
x[2:-2].split('...')
794+
if x.startswith('..')
795+
else [x]
796+
for x in self.callback_map
797+
)))
798+
ns = {
799+
'duplicates': set()
800+
}
801+
if is_multi:
802+
def duplicate_check():
803+
ns['duplicates'] = callbacks.intersection(
804+
str(y) for y in output
805+
)
806+
return ns['duplicates']
807+
else:
808+
def duplicate_check():
809+
return callback_id in callbacks
810+
if duplicate_check():
811+
if is_multi:
812+
msg = '''
813+
Multi output {} contains an `Output` object
814+
that was already assigned.
815+
Duplicates:
816+
{}
817+
'''.format(
818+
callback_id,
819+
pprint.pformat(ns['duplicates'])
820+
)
821+
else:
822+
msg = '''
779823
You have already assigned a callback to the output
780824
with ID "{}" and property "{}". An output can only have
781825
a single callback function. Try combining your inputs and
782826
callback functions together into one function.
783-
'''.format(
784-
output.component_id,
785-
output.component_property).replace(' ', ''))
827+
'''.format(
828+
output.component_id,
829+
output.component_property
830+
).replace(' ', '')
831+
raise exceptions.DuplicateCallbackOutput(msg)
786832

787833
def _validate_callback_output(self, output_value, output):
788834
valid = [str, dict, int, float, type(None), Component]
@@ -904,9 +950,9 @@ def _validate_value(val, index=None):
904950
def callback(self, output, inputs=[], state=[]):
905951
self._validate_callback(output, inputs, state)
906952

907-
callback_id = '{}.{}'.format(
908-
output.component_id, output.component_property
909-
)
953+
callback_id = _create_callback_id(output)
954+
multi = isinstance(output, (list, tuple))
955+
910956
self.callback_map[callback_id] = {
911957
'inputs': [
912958
{'id': c.component_id, 'property': c.component_property}
@@ -921,15 +967,44 @@ def callback(self, output, inputs=[], state=[]):
921967
def wrap_func(func):
922968
@wraps(func)
923969
def add_context(*args, **kwargs):
924-
925970
output_value = func(*args, **kwargs)
926-
response = {
927-
'response': {
928-
'props': {
929-
output.component_property: output_value
971+
if multi:
972+
if not isinstance(output_value, (list, tuple)):
973+
raise exceptions.InvalidCallbackReturnValue(
974+
'The callback {} is a multi-output.\n'
975+
'Expected the output type to be a list'
976+
' or tuple but got {}.'.format(
977+
callback_id, repr(output_value)
978+
)
979+
)
980+
981+
if not len(output_value) == len(output):
982+
raise exceptions.InvalidCallbackReturnValue(
983+
'Invalid number of output values for {}.\n'
984+
' Expected {} got {}'.format(
985+
callback_id,
986+
len(output),
987+
len(output_value)
988+
)
989+
)
990+
991+
component_ids = collections.defaultdict(dict)
992+
for i, o in enumerate(output):
993+
component_ids[o.component_id][o.component_property] =\
994+
output_value[i]
995+
996+
response = {
997+
'response': component_ids,
998+
'multi': True
999+
}
1000+
else:
1001+
response = {
1002+
'response': {
1003+
'props': {
1004+
output.component_property: output_value
1005+
}
9301006
}
9311007
}
932-
}
9331008

9341009
try:
9351010
jsonResponse = json.dumps(
@@ -966,7 +1041,6 @@ def dispatch(self):
9661041
state = body.get('state', [])
9671042
output = body['output']
9681043

969-
target_id = '{}.{}'.format(output['id'], output['property'])
9701044
args = []
9711045

9721046
flask.g.input_values = input_values = {
@@ -983,21 +1057,21 @@ def dispatch(self):
9831057
for x in changed_props
9841058
] if changed_props else []
9851059

986-
for component_registration in self.callback_map[target_id]['inputs']:
1060+
for component_registration in self.callback_map[output]['inputs']:
9871061
args.append([
9881062
c.get('value', None) for c in inputs if
9891063
c['property'] == component_registration['property'] and
9901064
c['id'] == component_registration['id']
9911065
][0])
9921066

993-
for component_registration in self.callback_map[target_id]['state']:
1067+
for component_registration in self.callback_map[output]['state']:
9941068
args.append([
9951069
c.get('value', None) for c in state if
9961070
c['property'] == component_registration['property'] and
9971071
c['id'] == component_registration['id']
9981072
][0])
9991073

1000-
return self.callback_map[target_id]['callback'](*args)
1074+
return self.callback_map[output]['callback'](*args)
10011075

10021076
def _validate_layout(self):
10031077
if self.layout is None:

0 commit comments

Comments
 (0)