diff --git a/.circleci/config.yml b/.circleci/config.yml index f91a8a1edb..bc92a8bccf 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -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 - save_cache: key: deps1-{{ .Branch }}-{{ checksum "reqs.txt" }}-{{ checksum ".circleci/config.yml" }}-{{ checksum "circlejob.txt" }} diff --git a/.circleci/requirements/dev-requirements-py37.txt b/.circleci/requirements/dev-requirements-py37.txt index 80bb309dc2..a168b88e7c 100644 --- a/.circleci/requirements/dev-requirements-py37.txt +++ b/.circleci/requirements/dev-requirements-py37.txt @@ -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 diff --git a/.circleci/requirements/dev-requirements.txt b/.circleci/requirements/dev-requirements.txt index 5793728632..6a4a78720e 100644 --- a/.circleci/requirements/dev-requirements.txt +++ b/.circleci/requirements/dev-requirements.txt @@ -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] diff --git a/.gitignore b/.gitignore index 5230a5f149..9267b2ccc5 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,4 @@ dist npm-debug* /.tox .idea +.mypy_cache/ diff --git a/dash/_utils.py b/dash/_utils.py index 45787ac940..740f946d2a 100644 --- a/dash/_utils.py +++ b/dash/_utils.py @@ -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 + ) diff --git a/dash/dash.py b/dash/dash.py index 48bce0984c..219a4ec45f 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -12,6 +12,7 @@ import warnings import re import logging +import pprint from functools import wraps @@ -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 = ''' @@ -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) @@ -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' - '/' - '/').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' + '/' + '/' + ).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( - '{}'.format(self.config['routes_pathname_prefix']), - self.index) + self._add_url('{}'.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) @@ -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 @@ -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() @@ -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 @@ -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')]: @@ -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 @@ -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] @@ -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} @@ -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( @@ -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 = { @@ -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: diff --git a/dash/development/_py_components_generation.py b/dash/development/_py_components_generation.py index 74462ba96b..9db0c611b0 100644 --- a/dash/development/_py_components_generation.py +++ b/dash/development/_py_components_generation.py @@ -487,14 +487,15 @@ def map_js_to_py_types_prop_types(type_object): "'{}'".format(t) for t in list(type_object['value'].keys())), 'Those keys have the following types:\n{}'.format( - '\n'.join(create_prop_docstring( - prop_name=prop_name, - type_object=prop, - required=prop['required'], - description=prop.get('description', ''), - indent_num=1) - for prop_name, prop in - list(type_object['value'].items())))), + '\n'.join( + create_prop_docstring( + prop_name=prop_name, + type_object=prop, + required=prop['required'], + description=prop.get('description', ''), + indent_num=1 + ) for prop_name, prop in + list(type_object['value'].items())))), ) diff --git a/dash/exceptions.py b/dash/exceptions.py index 7ed976633d..0b02966209 100644 --- a/dash/exceptions.py +++ b/dash/exceptions.py @@ -42,10 +42,20 @@ class IDsCantContainPeriods(CallbackException): pass +# Better error name now that more than periods are not permitted. +class InvalidComponentIdError(IDsCantContainPeriods): + pass + + class CantHaveMultipleOutputs(CallbackException): pass +# Renamed for less confusion with multi output. +class DuplicateCallbackOutput(CantHaveMultipleOutputs): + pass + + class PreventUpdate(CallbackException): pass diff --git a/tests/test_integration.py b/tests/test_integration.py index 6fada65c2a..22097a2c86 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -4,19 +4,21 @@ import itertools import re import time -import dash_html_components as html +from selenium.webdriver.common.action_chains import ActionChains +from selenium.webdriver.common.keys import Keys + import dash_dangerously_set_inner_html -import dash_core_components as dcc import dash_flow_example -from selenium.webdriver.common.action_chains import ActionChains -from selenium.webdriver.common.keys import Keys +import dash_html_components as html +import dash_core_components as dcc import dash -from dash.dependencies import Input, Output +from dash.dependencies import Input, Output, State from dash.exceptions import ( - PreventUpdate, CallbackException, MissingCallbackContextException + PreventUpdate, DuplicateCallbackOutput, CallbackException, + MissingCallbackContextException ) from .IntegrationTests import IntegrationTests from .utils import assert_clean_console, invincible, wait_for @@ -57,8 +59,7 @@ def update_output(value): self.startServer(app) - output1 = self.wait_for_element_by_id('output-1') - wait_for(lambda: output1.text == 'initial value') + self.wait_for_text_to_equal('#output-1', 'initial value') self.percy_snapshot(name='simple-callback-1') input1 = self.wait_for_element_by_id('input') @@ -74,7 +75,7 @@ def update_output(value): input1.send_keys('hello world') - output1 = self.wait_for_text_to_equal('#output-1', 'hello world') + self.wait_for_text_to_equal('#output-1', 'hello world') self.percy_snapshot(name='simple-callback-2') self.assertEqual( @@ -122,7 +123,7 @@ def update_text(data): self.wait_for_text_to_equal('#output-1', 'initial value') self.percy_snapshot(name='wildcard-callback-1') - input1 = self.wait_for_element_by_id('input') + input1 = self.wait_for_element_by_css_selector('#input') chain = (ActionChains(self.driver) .click(input1) .send_keys(Keys.HOME) @@ -555,6 +556,107 @@ def create_layout(): self.startServer(app) time.sleep(0.5) + def test_multi_output(self): + app = dash.Dash(__name__) + app.scripts.config.serve_locally = True + + app.layout = html.Div([ + html.Button('OUTPUT', id='output-btn'), + + html.Table([ + html.Thead([ + html.Tr([ + html.Th('Output 1'), + html.Th('Output 2') + ]) + ]), + html.Tbody([ + html.Tr([html.Td(id='output1'), html.Td(id='output2')]), + ]) + ]), + + html.Div(id='output3'), + html.Div(id='output4'), + html.Div(id='output5') + ]) + + @app.callback([Output('output1', 'children'), Output('output2', 'children')], + [Input('output-btn', 'n_clicks')], + [State('output-btn', 'n_clicks_timestamp')]) + def on_click(n_clicks, n_clicks_timestamp): + if n_clicks is None: + raise PreventUpdate + + return n_clicks, n_clicks_timestamp + + # Dummy callback for DuplicateCallbackOutput test. + @app.callback(Output('output3', 'children'), + [Input('output-btn', 'n_clicks')]) + def dummy_callback(n_clicks): + if n_clicks is None: + raise PreventUpdate + + return 'Output 3: {}'.format(n_clicks) + + # Test that a multi output can't be included in a single output + with self.assertRaises(DuplicateCallbackOutput) as context: + @app.callback(Output('output1', 'children'), + [Input('output-btn', 'n_clicks')]) + def on_click_duplicate(n_clicks): + if n_clicks is None: + raise PreventUpdate + + return 'something else' + + self.assertTrue('output1' in context.exception.args[0]) + + # Test a multi output cannot contain a used single output + with self.assertRaises(DuplicateCallbackOutput) as context: + @app.callback([Output('output3', 'children'), + Output('output4', 'children')], + [Input('output-btn', 'n_clicks')]) + def on_click_duplicate_multi(n_clicks): + if n_clicks is None: + raise PreventUpdate + + return 'something else' + + self.assertTrue('output3' in context.exception.args[0]) + + with self.assertRaises(DuplicateCallbackOutput) as context: + @app.callback([Output('output5', 'children'), + Output('output5', 'children')], + [Input('output-btn', 'n_clicks')]) + def on_click_same_output(n_clicks): + return n_clicks + + self.assertTrue('output5' in context.exception.args[0]) + + with self.assertRaises(DuplicateCallbackOutput) as context: + @app.callback([Output('output1', 'children'), + Output('output5', 'children')], + [Input('output-btn', 'n_clicks')]) + def overlapping_multi_output(n_clicks): + return n_clicks + + self.assertTrue( + '{\'output1.children\'}' in context.exception.args[0] + or "set(['output1.children'])" in context.exception.args[0] + ) + + self.startServer(app) + + t = time.time() + + btn = self.wait_for_element_by_id('output-btn') + btn.click() + time.sleep(1) + + self.wait_for_text_to_equal('#output1', '1') + output2 = self.wait_for_element_by_css_selector('#output2') + + self.assertGreater(int(output2.text), t) + def test_with_custom_renderer(self): app = dash.Dash(__name__) @@ -655,6 +757,7 @@ def test_with_custom_renderer_interpolated(self): }) ''' + class CustomDash(dash.Dash): def interpolate_index(self, **kwargs): @@ -762,6 +865,19 @@ def failure(children): context.exception.args[0] ) + # Multi output version. + with self.assertRaises(CallbackException) as context: + @app.callback([Output('out', 'children'), + Output('input-output', 'children')], + [Input('input-output', 'children')]) + def failure2(children): + pass + + self.assertEqual( + 'Same output and input: input-output.children', + context.exception.args[0] + ) + def test_callback_context(self): app = dash.Dash(__name__)