Skip to content

Multi output callbacks support. #436

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

Merged
merged 38 commits into from
Mar 1, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
885d4d0
Add multi-output callback support.
T4rk1n Oct 25, 2018
1d95f65
pylint enumerate.
T4rk1n Oct 25, 2018
55a89d3
Re-enable callback validation.
T4rk1n Oct 25, 2018
df053ac
Remove else after return create_callback_id.
T4rk1n Oct 25, 2018
3c8f9b1
Replace wait_for calls for wait_for_text_to_equal
T4rk1n Oct 25, 2018
25d8c87
Add multi-output test.
T4rk1n Oct 25, 2018
583062a
Sleep after clearing input (faster tests?)
T4rk1n Oct 25, 2018
8de3339
Proper multi-output callback exceptions.
T4rk1n Oct 26, 2018
cb0ea7c
Non breaking multi-output.
T4rk1n Nov 27, 2018
5b22b43
Add multi_output config value (Always true).
T4rk1n Nov 27, 2018
11d4664
Fix rebase.
T4rk1n Dec 14, 2018
e5ea672
:ok_hand: renamed props -> component_ids
T4rk1n Dec 26, 2018
6312be4
:ok_hand: Add invalid characters in id check.
T4rk1n Dec 26, 2018
3d43f75
:hammer: Raise InvalidComponentIdError instead of IDsCantContainPeriods
T4rk1n Dec 26, 2018
b6175ba
:white_check_mark: Test for multi output race condition.
T4rk1n Dec 26, 2018
cc61689
:ok_hand: Add multi output duplicate callback validation.
T4rk1n Dec 26, 2018
aac01d3
:hammer: Renamed CantHaveMultipleOutputs for less confusion.
T4rk1n Dec 26, 2018
348f9ec
:ok_hand: Clean up duplicate output logic.
T4rk1n Dec 27, 2018
8b4b3da
:hammer: Use dot notation for multi-output id
T4rk1n Jan 9, 2019
da42c45
:green_heart: Install dash-renderer from git
T4rk1n Jan 9, 2019
53b8cdb
:construction: Fix duplicate callback check
T4rk1n Jan 9, 2019
112a97b
:green_heart: Force reinstall.
T4rk1n Jan 9, 2019
41f6ffe
:hocho: Remove invalid id characters
T4rk1n Jan 9, 2019
5bbfc4c
:rotating_light: Fix multi-output test rebase.
T4rk1n Feb 7, 2019
8244575
:shirt: Fix rebase duplicate import.
T4rk1n Feb 7, 2019
eccf8c6
:white_check_mark: Add test same output in multi output.
T4rk1n Feb 20, 2019
32f341f
:white_check_mark: Add test same output/input multi output version.
T4rk1n Feb 20, 2019
9293ad4
:hammer: Prevent same output/input in multi output callbacks.
T4rk1n Feb 20, 2019
ec89478
:hammer: Prevent duplicates output in same multi output callback.
T4rk1n Feb 20, 2019
9225f2e
:shirt: Rename duplicate failure (fix F811)
T4rk1n Feb 20, 2019
0ecbf25
:white_check_mark: Add test for overlapping multi output
T4rk1n Feb 20, 2019
c51e6b4
:hocho: Remove multi_output config.
T4rk1n Feb 20, 2019
87a5d60
Merge branch 'master' into multi-output
alexcjohnson Feb 28, 2019
ae316e1
gitignore mypycache
alexcjohnson Mar 1, 2019
3795f53
some linting
alexcjohnson Mar 1, 2019
5988731
pull out prefix to simplify _add_url calls
alexcjohnson Mar 1, 2019
84fb035
pull self.renderer out of _add_url so we only do it once!
alexcjohnson Mar 1, 2019
52eabdc
remove redundant line in dev-requirements.txt
alexcjohnson Mar 1, 2019
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
2 changes: 1 addition & 1 deletion .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ jobs:
sudo pip install virtualenv
virtualenv venv
. venv/bin/activate
pip install -r $REQUIREMENTS_FILE
pip install -r $REQUIREMENTS_FILE --force-reinstall
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@T4rk1n I've already merged this but... was --force-reinstall just for debugging or do we need it going forward if we're using git branches in requirements files?


- save_cache:
key: deps1-{{ .Branch }}-{{ checksum "reqs.txt" }}-{{ checksum ".circleci/config.yml" }}-{{ checksum "circlejob.txt" }}
Expand Down
2 changes: 1 addition & 1 deletion .circleci/requirements/dev-requirements-py37.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ dash_core_components==0.43.1
dash_html_components==0.13.5
dash-flow-example==0.0.5
dash-dangerously-set-inner-html
-e git://github.com/plotly/dash-renderer.git@master#egg=dash_renderer
-e git+git://github.com/plotly/dash-renderer@multi-output#egg=dash_renderer
percy
selenium
mock
Expand Down
3 changes: 1 addition & 2 deletions .circleci/requirements/dev-requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,12 @@ dash_core_components==0.43.1
dash_html_components==0.13.5
dash_flow_example==0.0.5
dash-dangerously-set-inner-html
-e git://github.com/plotly/dash-renderer.git@master#egg=dash_renderer
-e git+git://github.com/plotly/dash-renderer@multi-output#egg=dash_renderer
percy
selenium
mock
tox
tox-pyenv
mock
six
plotly==3.6.1
requests[security]
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,4 @@ dist
npm-debug*
/.tox
.idea
.mypy_cache/
12 changes: 12 additions & 0 deletions dash/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,15 @@ def first(self, *names):
value = self.get(name)
if value:
return value


def create_callback_id(output):
if isinstance(output, (list, tuple)):
return '..{}..'.format('...'.join(
'{}.{}'.format(x.component_id, x.component_property)
for x in output
))

return '{}.{}'.format(
output.component_id, output.component_property
)
206 changes: 140 additions & 66 deletions dash/dash.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import warnings
import re
import logging
import pprint

from functools import wraps

Expand All @@ -30,9 +31,10 @@
from ._utils import interpolate_str as _interpolate
from ._utils import format_tag as _format_tag
from ._utils import generate_hash as _generate_hash
from ._utils import get_asset_path as _get_asset_path
from ._utils import patch_collections_abc as _patch_collections_abc
from . import _watch
from ._utils import get_asset_path as _get_asset_path
from ._utils import create_callback_id as _create_callback_id
from . import _configs

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

# default renderer string
self.renderer = 'var renderer = new DashRenderer();'

if compress:
# gzip
Compress(self.server)
Expand All @@ -191,47 +196,36 @@ def _handle_error(_):
# urls
self.routes = []

self._add_url(
'{}_dash-layout'.format(self.config['routes_pathname_prefix']),
self.serve_layout)
prefix = self.config['routes_pathname_prefix']

self._add_url(
'{}_dash-dependencies'.format(
self.config['routes_pathname_prefix']),
self.dependencies)
self._add_url('{}_dash-layout'.format(prefix), self.serve_layout)

self._add_url('{}_dash-dependencies'.format(prefix), self.dependencies)

self._add_url(
'{}_dash-update-component'.format(
self.config['routes_pathname_prefix']),
'{}_dash-update-component'.format(prefix),
self.dispatch,
['POST'])

self._add_url((
'{}_dash-component-suites'
'/<string:package_name>'
'/<path:path_in_package_dist>').format(
self.config['routes_pathname_prefix']),
self.serve_component_suites)

self._add_url(
'{}_dash-routes'.format(self.config['routes_pathname_prefix']),
self.serve_routes)
(
'{}_dash-component-suites'
'/<string:package_name>'
'/<path:path_in_package_dist>'
).format(prefix),
self.serve_component_suites)

self._add_url(
self.config['routes_pathname_prefix'],
self.index)
self._add_url('{}_dash-routes'.format(prefix), self.serve_routes)

self._add_url(
'{}_reload-hash'.format(self.config['routes_pathname_prefix']),
self.serve_reload_hash)
self._add_url(prefix, self.index)

self._add_url('{}_reload-hash'.format(prefix), self.serve_reload_hash)

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

self._add_url(
'{}_favicon.ico'.format(self.config['routes_pathname_prefix']),
'{}_favicon.ico'.format(prefix),
self._serve_default_favicon)

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

# default renderer string
self.renderer = 'var renderer = new DashRenderer();'

@property
def layout(self):
return self._layout
Expand Down Expand Up @@ -637,10 +628,7 @@ def interpolate_index(self, **kwargs):
def dependencies(self):
return flask.jsonify([
{
'output': {
'id': k.split('.')[0],
'property': k.split('.')[1]
},
'output': k,
'inputs': v['inputs'],
'state': v['state'],
} for k, v in self.callback_map.items()
Expand All @@ -656,11 +644,33 @@ def react(self, *args, **kwargs):
def _validate_callback(self, output, inputs, state):
# pylint: disable=too-many-branches
layout = self._cached_layout or self._layout_value()
is_multi = isinstance(output, (list, tuple))

for i in inputs:
if output == i:
bad = None
if is_multi:
for o in output:
if o == i:
bad = o
else:
if output == i:
bad = output
if bad:
raise exceptions.SameInputOutputException(
'Same output and input: {}'.format(output)
'Same output and input: {}'.format(bad)
)

if is_multi:
if len(set(output)) != len(output):
raise exceptions.DuplicateCallbackOutput(
'Same output was used in a'
' multi output callback!\n Duplicates:\n {}'.format(
',\n'.join(
k for k, v in
((str(x), output.count(x)) for x in output)
if v > 1
)
)
)

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

for args, obj, name in [([output], Output, 'Output'),
for args, obj, name in [(output if isinstance(output, (list, tuple))
else [output],
(Output, list, tuple),
'Output'),
(inputs, Input, 'Input'),
(state, State, 'State')]:

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

invalid_characters = ['.']
if any(x in arg.component_id for x in invalid_characters):
raise exceptions.InvalidComponentIdError('''The element
`{}` contains {} in its ID.
Periods are not allowed in IDs right now.'''.format(
arg.component_id,
invalid_characters
))

if (not self.config.first('suppress_callback_exceptions',
'supress_callback_exceptions') and
arg.component_id not in layout and
Expand Down Expand Up @@ -765,24 +787,48 @@ def _validate_callback(self, output, inputs, state):
'elements' if len(state) > 1 else 'element'
).replace(' ', ''))

if '.' in output.component_id:
raise exceptions.IDsCantContainPeriods('''The Output element
`{}` contains a period in its ID.
Periods are not allowed in IDs right now.'''.format(
output.component_id
))

callback_id = '{}.{}'.format(
output.component_id, output.component_property)
if callback_id in self.callback_map:
raise exceptions.CantHaveMultipleOutputs('''
callback_id = _create_callback_id(output)

callbacks = set(itertools.chain(*(
x[2:-2].split('...')
if x.startswith('..')
else [x]
for x in self.callback_map
)))
ns = {
'duplicates': set()
}
if is_multi:
def duplicate_check():
ns['duplicates'] = callbacks.intersection(
str(y) for y in output
)
return ns['duplicates']
else:
def duplicate_check():
return callback_id in callbacks
if duplicate_check():
if is_multi:
msg = '''
Multi output {} contains an `Output` object
that was already assigned.
Duplicates:
{}
'''.format(
callback_id,
pprint.pformat(ns['duplicates'])
)
else:
msg = '''
You have already assigned a callback to the output
with ID "{}" and property "{}". An output can only have
a single callback function. Try combining your inputs and
callback functions together into one function.
'''.format(
output.component_id,
output.component_property).replace(' ', ''))
'''.format(
output.component_id,
output.component_property
).replace(' ', '')
raise exceptions.DuplicateCallbackOutput(msg)

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

callback_id = '{}.{}'.format(
output.component_id, output.component_property
)
callback_id = _create_callback_id(output)
multi = isinstance(output, (list, tuple))

self.callback_map[callback_id] = {
'inputs': [
{'id': c.component_id, 'property': c.component_property}
Expand All @@ -921,15 +967,44 @@ def callback(self, output, inputs=[], state=[]):
def wrap_func(func):
@wraps(func)
def add_context(*args, **kwargs):

output_value = func(*args, **kwargs)
response = {
'response': {
'props': {
output.component_property: output_value
if multi:
if not isinstance(output_value, (list, tuple)):
raise exceptions.InvalidCallbackReturnValue(
'The callback {} is a multi-output.\n'
'Expected the output type to be a list'
' or tuple but got {}.'.format(
callback_id, repr(output_value)
)
)

if not len(output_value) == len(output):
raise exceptions.InvalidCallbackReturnValue(
'Invalid number of output values for {}.\n'
' Expected {} got {}'.format(
callback_id,
len(output),
len(output_value)
)
)

component_ids = collections.defaultdict(dict)
for i, o in enumerate(output):
component_ids[o.component_id][o.component_property] =\
output_value[i]

response = {
'response': component_ids,
'multi': True
}
else:
response = {
'response': {
'props': {
output.component_property: output_value
}
}
}
}

try:
jsonResponse = json.dumps(
Expand Down Expand Up @@ -966,7 +1041,6 @@ def dispatch(self):
state = body.get('state', [])
output = body['output']

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

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

for component_registration in self.callback_map[target_id]['inputs']:
for component_registration in self.callback_map[output]['inputs']:
args.append([
c.get('value', None) for c in inputs if
c['property'] == component_registration['property'] and
c['id'] == component_registration['id']
][0])

for component_registration in self.callback_map[target_id]['state']:
for component_registration in self.callback_map[output]['state']:
args.append([
c.get('value', None) for c in state if
c['property'] == component_registration['property'] and
c['id'] == component_registration['id']
][0])

return self.callback_map[target_id]['callback'](*args)
return self.callback_map[output]['callback'](*args)

def _validate_layout(self):
if self.layout is None:
Expand Down
Loading