From 885d4d0ca14508d714b73bffbdbfaa1c172a73e8 Mon Sep 17 00:00:00 2001 From: Philippe Duval Date: Thu, 25 Oct 2018 14:35:46 -0400 Subject: [PATCH 01/37] Add multi-output callback support. --- dash/dash.py | 62 ++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 43 insertions(+), 19 deletions(-) diff --git a/dash/dash.py b/dash/dash.py index ecb7bbf136..a04d28c29c 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -618,10 +618,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() @@ -657,7 +654,7 @@ 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], (Output, list), 'Output'), (inputs, Input, 'Input'), (state, State, 'State')]: @@ -883,11 +880,20 @@ def _validate_value(val, index=None): # relationships # pylint: disable=dangerous-default-value def callback(self, output, inputs=[], state=[]): - self._validate_callback(output, inputs, state) + # self._validate_callback(output, inputs, state) + + if isinstance(output, (list, tuple)): + callback_id = '[{}]'.format(':'.join( + '{}.{}'.format(x.component_id, x.component_property) + for x in output + )) + multi = True + else: + callback_id = '{}.{}'.format( + output.component_id, output.component_property + ) + multi = False - callback_id = '{}.{}'.format( - output.component_id, output.component_property - ) self.callback_map[callback_id] = { 'inputs': [ {'id': c.component_id, 'property': c.component_property} @@ -902,15 +908,34 @@ 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 Exception('Invalid output value') + + if not len(output_value) == len(output): + raise Exception( + 'Invalid number of output values.' + ' Expected {} got {}'.format( + len(output), len(output_value))) + + props = collections.defaultdict(dict) + for i in range(len(output)): + out = output[i] + props[out.component_id][out.component_property] =\ + output_value[i] + + response = { + 'response': props + } + else: + response = { + 'response': { + output.component_id: { + output.component_property: output_value + } } } - } try: jsonResponse = json.dumps( @@ -947,23 +972,22 @@ def dispatch(self): state = body.get('state', []) output = body['output'] - target_id = '{}.{}'.format(output['id'], output['property']) args = [] - 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: From 1d95f65ef0159d276a4e3e312563187c9a643477 Mon Sep 17 00:00:00 2001 From: Philippe Duval Date: Thu, 25 Oct 2018 14:44:02 -0400 Subject: [PATCH 02/37] pylint enumerate. --- dash/dash.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/dash/dash.py b/dash/dash.py index a04d28c29c..7e5beeda2f 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -920,8 +920,7 @@ def add_context(*args, **kwargs): len(output), len(output_value))) props = collections.defaultdict(dict) - for i in range(len(output)): - out = output[i] + for i, out in enumerate(output): props[out.component_id][out.component_property] =\ output_value[i] From 55a89d3ed41496dece97ffc1603f29568181e66d Mon Sep 17 00:00:00 2001 From: Philippe Duval Date: Thu, 25 Oct 2018 17:07:40 -0400 Subject: [PATCH 03/37] Re-enable callback validation. --- dash/_utils.py | 12 ++++++++++++ dash/dash.py | 41 ++++++++++++++++++----------------------- 2 files changed, 30 insertions(+), 23 deletions(-) diff --git a/dash/_utils.py b/dash/_utils.py index 45787ac940..08dc22e5d4 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 + )) + else: + return '{}.{}'.format( + output.component_id, output.component_property + ) diff --git a/dash/dash.py b/dash/dash.py index 7e5beeda2f..c4864984d5 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -33,6 +33,8 @@ 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 @@ -654,7 +656,10 @@ def _validate_callback(self, output, inputs, state): `app.config['suppress_callback_exceptions']=True` '''.replace(' ', '')) - for args, obj, name in [([output], (Output, list), 'Output'), + for args, obj, name in [(output if isinstance(output, (list, tuple)) + else [output], + (Output, list, tuple), + 'Output'), (inputs, Input, 'Input'), (state, State, 'State')]: @@ -673,6 +678,13 @@ def _validate_callback(self, output, inputs, state): name.lower(), str(arg), name )) + if '.' in arg.component_id: + raise exceptions.IDsCantContainPeriods('''The element + `{}` contains a period in its ID. + Periods are not allowed in IDs right now.'''.format( + arg.component_id + )) + if (not self.config.first('suppress_callback_exceptions', 'supress_callback_exceptions') and arg.component_id not in layout and @@ -743,15 +755,7 @@ 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) + callback_id = _create_callback_id(output) if callback_id in self.callback_map: raise exceptions.CantHaveMultipleOutputs(''' You have already assigned a callback to the output @@ -880,19 +884,10 @@ def _validate_value(val, index=None): # relationships # pylint: disable=dangerous-default-value def callback(self, output, inputs=[], state=[]): - # self._validate_callback(output, inputs, state) - - if isinstance(output, (list, tuple)): - callback_id = '[{}]'.format(':'.join( - '{}.{}'.format(x.component_id, x.component_property) - for x in output - )) - multi = True - else: - callback_id = '{}.{}'.format( - output.component_id, output.component_property - ) - multi = False + self._validate_callback(output, inputs, state) + + callback_id = _create_callback_id(output) + multi = isinstance(output, (list, tuple)) self.callback_map[callback_id] = { 'inputs': [ From df053acc0a9c410ec7e3d1c860c481548a5688e6 Mon Sep 17 00:00:00 2001 From: Philippe Duval Date: Thu, 25 Oct 2018 17:25:30 -0400 Subject: [PATCH 04/37] Remove else after return create_callback_id. --- dash/_utils.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/dash/_utils.py b/dash/_utils.py index 08dc22e5d4..860ba3acc1 100644 --- a/dash/_utils.py +++ b/dash/_utils.py @@ -92,7 +92,7 @@ def create_callback_id(output): '{}.{}'.format(x.component_id, x.component_property) for x in output )) - else: - return '{}.{}'.format( - output.component_id, output.component_property - ) + + return '{}.{}'.format( + output.component_id, output.component_property + ) From 3c8f9b17fc7b589f9aed15eff586cea4b2c36ae2 Mon Sep 17 00:00:00 2001 From: Philippe Duval Date: Thu, 25 Oct 2018 17:39:50 -0400 Subject: [PATCH 05/37] Replace wait_for calls for wait_for_text_to_equal --- tests/test_integration.py | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/tests/test_integration.py b/tests/test_integration.py index 3624795941..c670121a53 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -530,6 +530,45 @@ 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')]), + ]) + ]), + ]) + + @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 + + + t = time.time() + + btn = self.wait_for_element_by_id('output-btn') + btn.click() + time.sleep(1) + + self.wait_for_text_to_equals() + + def test_late_component_register(self): app = dash.Dash() From 25d8c877d316df269521da37a35d5749b68d3a89 Mon Sep 17 00:00:00 2001 From: Philippe Duval Date: Thu, 25 Oct 2018 17:44:56 -0400 Subject: [PATCH 06/37] Add multi-output test. --- tests/test_integration.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/test_integration.py b/tests/test_integration.py index c670121a53..da07964fdb 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -559,6 +559,7 @@ def on_click(n_clicks, n_clicks_timestamp): return n_clicks, n_clicks_timestamp + self.startServer(app) t = time.time() @@ -566,7 +567,10 @@ def on_click(n_clicks, n_clicks_timestamp): btn.click() time.sleep(1) - self.wait_for_text_to_equals() + 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_late_component_register(self): From 583062a7236ea8ba90467a17c98ca6f697c3f4e3 Mon Sep 17 00:00:00 2001 From: Philippe Duval Date: Thu, 25 Oct 2018 17:51:26 -0400 Subject: [PATCH 07/37] Sleep after clearing input (faster tests?) --- tests/test_integration.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/test_integration.py b/tests/test_integration.py index da07964fdb..cd65c49b1b 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -52,8 +52,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') @@ -61,7 +60,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( @@ -108,7 +107,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') input1.clear() input1.send_keys('hello world') From 8de333981ec63b041c8b1e4f472f66f00b556f4c Mon Sep 17 00:00:00 2001 From: Philippe Duval Date: Fri, 26 Oct 2018 11:20:27 -0400 Subject: [PATCH 08/37] Proper multi-output callback exceptions. --- dash/dash.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/dash/dash.py b/dash/dash.py index c4864984d5..ef058173c5 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -906,13 +906,23 @@ def add_context(*args, **kwargs): output_value = func(*args, **kwargs) if multi: if not isinstance(output_value, (list, tuple)): - raise Exception('Invalid output value') + 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 Exception( - 'Invalid number of output values.' + raise exceptions.InvalidCallbackReturnValue( + 'Invalid number of output values for {}.\n' ' Expected {} got {}'.format( - len(output), len(output_value))) + callback_id, + len(output), + len(output_value) + ) + ) props = collections.defaultdict(dict) for i, out in enumerate(output): From cb0ea7c0b31ecdd091576a7c37ab9b7a3a2d5fca Mon Sep 17 00:00:00 2001 From: Philippe Duval Date: Tue, 27 Nov 2018 14:01:12 -0500 Subject: [PATCH 09/37] Non breaking multi-output. --- dash/dash.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/dash/dash.py b/dash/dash.py index ef058173c5..e7035c5b32 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -930,14 +930,13 @@ def add_context(*args, **kwargs): output_value[i] response = { - 'response': props + 'response': props, + 'multi': True } else: response = { 'response': { - output.component_id: { - output.component_property: output_value - } + output.component_property: output_value } } From 5b22b43b4a2f557d1024650470a7444a33cce64c Mon Sep 17 00:00:00 2001 From: Philippe Duval Date: Tue, 27 Nov 2018 16:26:01 -0500 Subject: [PATCH 10/37] Add multi_output config value (Always true). --- dash/dash.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/dash/dash.py b/dash/dash.py index e7035c5b32..5d918e70ef 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -333,6 +333,7 @@ def serve_layout(self): def _config(self): config = { + 'multi_output': True, 'url_base_pathname': self.url_base_pathname, 'requests_pathname_prefix': self.config['requests_pathname_prefix'] } @@ -936,7 +937,9 @@ def add_context(*args, **kwargs): else: response = { 'response': { - output.component_property: output_value + 'props': { + output.component_property: output_value + } } } From 11d466408dd30fe20ab448e2dc0703e3e83f59ad Mon Sep 17 00:00:00 2001 From: Philippe Duval Date: Fri, 14 Dec 2018 15:38:35 -0500 Subject: [PATCH 11/37] Fix rebase. --- tests/test_integration.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_integration.py b/tests/test_integration.py index cd65c49b1b..942a6410f1 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -571,7 +571,6 @@ def on_click(n_clicks, n_clicks_timestamp): self.assertGreater(int(output2.text), t) - def test_late_component_register(self): app = dash.Dash() From e5ea67206c1b50699f70c3b4500a82e2a8fa98bd Mon Sep 17 00:00:00 2001 From: t4rk1n Date: Wed, 26 Dec 2018 15:23:39 -0500 Subject: [PATCH 12/37] :ok_hand: renamed props -> component_ids --- dash/dash.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/dash/dash.py b/dash/dash.py index 5d918e70ef..2e5e93c080 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -925,13 +925,13 @@ def add_context(*args, **kwargs): ) ) - props = collections.defaultdict(dict) - for i, out in enumerate(output): - props[out.component_id][out.component_property] =\ + component_ids = collections.defaultdict(dict) + for i, o in enumerate(output): + component_ids[o.component_id][o.component_property] =\ output_value[i] response = { - 'response': props, + 'response': component_ids, 'multi': True } else: From 6312be42a3582827172bf90a6a52a073e3976029 Mon Sep 17 00:00:00 2001 From: t4rk1n Date: Wed, 26 Dec 2018 15:31:43 -0500 Subject: [PATCH 13/37] :ok_hand: Add invalid characters in id check. --- dash/dash.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/dash/dash.py b/dash/dash.py index 2e5e93c080..bf48d3653f 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -679,11 +679,13 @@ def _validate_callback(self, output, inputs, state): name.lower(), str(arg), name )) - if '.' in arg.component_id: + invalid_characters = ['.', ':', '[', ']'] + if any(x in arg.component_id for x in invalid_characters): raise exceptions.IDsCantContainPeriods('''The element - `{}` contains a period in its ID. + `{}` contains {} in its ID. Periods are not allowed in IDs right now.'''.format( - arg.component_id + arg.component_id, + invalid_characters )) if (not self.config.first('suppress_callback_exceptions', From 3d43f757cdb2c9d1a258b5c6867dfd61eacdcb22 Mon Sep 17 00:00:00 2001 From: t4rk1n Date: Wed, 26 Dec 2018 15:35:37 -0500 Subject: [PATCH 14/37] :hammer: Raise InvalidComponentIdError instead of IDsCantContainPeriods --- dash/dash.py | 2 +- dash/exceptions.py | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/dash/dash.py b/dash/dash.py index bf48d3653f..5c9836ef1c 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -681,7 +681,7 @@ def _validate_callback(self, output, inputs, state): invalid_characters = ['.', ':', '[', ']'] if any(x in arg.component_id for x in invalid_characters): - raise exceptions.IDsCantContainPeriods('''The element + raise exceptions.InvalidComponentIdError('''The element `{}` contains {} in its ID. Periods are not allowed in IDs right now.'''.format( arg.component_id, diff --git a/dash/exceptions.py b/dash/exceptions.py index 2b98bbbc18..79de0fd57b 100644 --- a/dash/exceptions.py +++ b/dash/exceptions.py @@ -42,6 +42,11 @@ class IDsCantContainPeriods(CallbackException): pass +# Better error name now that more than periods are not permitted. +class InvalidComponentIdError(IDsCantContainPeriods): + pass + + class CantHaveMultipleOutputs(CallbackException): pass From b6175bad957f5a6ac01c625ba7efee9194ec44dc Mon Sep 17 00:00:00 2001 From: t4rk1n Date: Wed, 26 Dec 2018 16:03:20 -0500 Subject: [PATCH 15/37] :white_check_mark: Test for multi output race condition. --- tests/test_integration.py | 39 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/tests/test_integration.py b/tests/test_integration.py index 942a6410f1..d8fb4b47f6 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -12,7 +12,7 @@ import dash from dash.dependencies import Input, Output -from dash.exceptions import PreventUpdate, CallbackException +from dash.exceptions import PreventUpdate, CallbackException, CantHaveMultipleOutputs from .IntegrationTests import IntegrationTests from .utils import assert_clean_console, invincible, wait_for @@ -547,6 +547,9 @@ def test_multi_output(self): html.Tr([html.Td(id='output1'), html.Td(id='output2')]), ]) ]), + + html.Div(id='output3'), + html.Div(id='output4') ]) @app.callback([Output('output1', 'children'), Output('output2', 'children')], @@ -558,6 +561,40 @@ def on_click(n_clicks, n_clicks_timestamp): return n_clicks, n_clicks_timestamp + # Dummy callback for CantHaveMultipleOutputs + @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(CantHaveMultipleOutputs) 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(CantHaveMultipleOutputs) 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]) + self.startServer(app) t = time.time() From cc616890ce2a12c0d3d3ae103c5ea1149c382eae Mon Sep 17 00:00:00 2001 From: t4rk1n Date: Wed, 26 Dec 2018 18:23:45 -0500 Subject: [PATCH 16/37] :ok_hand: Add multi output duplicate callback validation. --- dash/dash.py | 43 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 38 insertions(+), 5 deletions(-) diff --git a/dash/dash.py b/dash/dash.py index 5c9836ef1c..ba94893e25 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 @@ -759,15 +760,47 @@ def _validate_callback(self, output, inputs, state): ).replace(' ', '')) callback_id = _create_callback_id(output) - if callback_id in self.callback_map: - raise exceptions.CantHaveMultipleOutputs(''' + is_multi = isinstance(output, (list, tuple)) + callbacks = set(itertools.chain(*( + x[1:-1].split(':') + if x.startswith('[') + else [x] + for x in self.callback_map + ))) + ns = { + 'duplicates': callback_id + } + if is_multi: + def duplicate_check(): + ns['duplicates'] = intersection = callbacks.intersection( + _create_callback_id(y) for y in output + ) + return intersection + 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.CantHaveMultipleOutputs(msg) def _validate_callback_output(self, output_value, output): valid = [str, dict, int, float, type(None), Component] From aac01d3013e41d989af29368ae9194551d03fc5f Mon Sep 17 00:00:00 2001 From: t4rk1n Date: Wed, 26 Dec 2018 18:40:46 -0500 Subject: [PATCH 17/37] :hammer: Renamed CantHaveMultipleOutputs for less confusion. --- dash/dash.py | 4 ++-- dash/exceptions.py | 5 +++++ tests/test_integration.py | 8 ++++---- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/dash/dash.py b/dash/dash.py index ba94893e25..373a0ad02e 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -788,7 +788,7 @@ def duplicate_check(): {} '''.format( callback_id, - pprint.pformat((ns['duplicates'])) + pprint.pformat(ns['duplicates']) ) else: msg = ''' @@ -800,7 +800,7 @@ def duplicate_check(): output.component_id, output.component_property ).replace(' ', '') - raise exceptions.CantHaveMultipleOutputs(msg) + raise exceptions.DuplicateCallbackOutput(msg) def _validate_callback_output(self, output_value, output): valid = [str, dict, int, float, type(None), Component] diff --git a/dash/exceptions.py b/dash/exceptions.py index 79de0fd57b..85ef3f5026 100644 --- a/dash/exceptions.py +++ b/dash/exceptions.py @@ -51,6 +51,11 @@ 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 d8fb4b47f6..a7af6e1e1f 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -12,7 +12,7 @@ import dash from dash.dependencies import Input, Output -from dash.exceptions import PreventUpdate, CallbackException, CantHaveMultipleOutputs +from dash.exceptions import PreventUpdate, DuplicateCallbackOutput, CallbackException from .IntegrationTests import IntegrationTests from .utils import assert_clean_console, invincible, wait_for @@ -561,7 +561,7 @@ def on_click(n_clicks, n_clicks_timestamp): return n_clicks, n_clicks_timestamp - # Dummy callback for CantHaveMultipleOutputs + # Dummy callback for DuplicateCallbackOutput test. @app.callback(Output('output3', 'children'), [Input('output-btn', 'n_clicks')]) def dummy_callback(n_clicks): @@ -571,7 +571,7 @@ def dummy_callback(n_clicks): return 'Output 3: {}'.format(n_clicks) # Test that a multi output can't be included in a single output - with self.assertRaises(CantHaveMultipleOutputs) as context: + with self.assertRaises(DuplicateCallbackOutput) as context: @app.callback(Output('output1', 'children'), [Input('output-btn', 'n_clicks')]) def on_click_duplicate(n_clicks): @@ -583,7 +583,7 @@ def on_click_duplicate(n_clicks): self.assertTrue('output1' in context.exception.args[0]) # Test a multi output cannot contain a used single output - with self.assertRaises(CantHaveMultipleOutputs) as context: + with self.assertRaises(DuplicateCallbackOutput) as context: @app.callback([Output('output3', 'children'), Output('output4', 'children')], [Input('output-btn', 'n_clicks')]) From 348f9ec1dbf021df12940c7cff53fd800b991c9e Mon Sep 17 00:00:00 2001 From: t4rk1n Date: Thu, 27 Dec 2018 13:19:24 -0500 Subject: [PATCH 18/37] :ok_hand: Clean up duplicate output logic. --- dash/dash.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/dash/dash.py b/dash/dash.py index 373a0ad02e..e0f51f6150 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -768,14 +768,14 @@ def _validate_callback(self, output, inputs, state): for x in self.callback_map ))) ns = { - 'duplicates': callback_id + 'duplicates': set() } if is_multi: def duplicate_check(): - ns['duplicates'] = intersection = callbacks.intersection( + ns['duplicates'] = callbacks.intersection( _create_callback_id(y) for y in output ) - return intersection + return ns['duplicates'] else: def duplicate_check(): return callback_id in callbacks From 8b4b3dae9225b219667a4b5906e89e22b0feb910 Mon Sep 17 00:00:00 2001 From: t4rk1n Date: Wed, 9 Jan 2019 16:17:00 -0500 Subject: [PATCH 19/37] :hammer: Use dot notation for multi-output id --- dash/_utils.py | 2 +- dash/dash.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/dash/_utils.py b/dash/_utils.py index 860ba3acc1..740f946d2a 100644 --- a/dash/_utils.py +++ b/dash/_utils.py @@ -88,7 +88,7 @@ def first(self, *names): def create_callback_id(output): if isinstance(output, (list, tuple)): - return '[{}]'.format(':'.join( + return '..{}..'.format('...'.join( '{}.{}'.format(x.component_id, x.component_property) for x in output )) diff --git a/dash/dash.py b/dash/dash.py index e0f51f6150..95c5a43ac9 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -762,8 +762,8 @@ def _validate_callback(self, output, inputs, state): callback_id = _create_callback_id(output) is_multi = isinstance(output, (list, tuple)) callbacks = set(itertools.chain(*( - x[1:-1].split(':') - if x.startswith('[') + x[1:-1].split('...') + if x.startswith('..') else [x] for x in self.callback_map ))) From da42c45841db15426de468c0cad8f1eaacaa72a6 Mon Sep 17 00:00:00 2001 From: t4rk1n Date: Wed, 9 Jan 2019 16:29:31 -0500 Subject: [PATCH 20/37] :green_heart: Install dash-renderer from git --- .circleci/requirements/dev-requirements-py37.txt | 2 +- .circleci/requirements/dev-requirements.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.circleci/requirements/dev-requirements-py37.txt b/.circleci/requirements/dev-requirements-py37.txt index 3eab773cae..b0c260b366 100644 --- a/.circleci/requirements/dev-requirements-py37.txt +++ b/.circleci/requirements/dev-requirements-py37.txt @@ -2,7 +2,7 @@ dash_core_components==0.43.0 dash_html_components==0.13.5 dash-flow-example==0.0.5 dash-dangerously-set-inner-html -dash_renderer==0.17.0 +-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 0cbe0a8acd..d3432a653c 100644 --- a/.circleci/requirements/dev-requirements.txt +++ b/.circleci/requirements/dev-requirements.txt @@ -2,7 +2,7 @@ dash_core_components==0.43.0 dash_html_components==0.13.5 dash_flow_example==0.0.5 dash-dangerously-set-inner-html -dash_renderer==0.17.0 +-e git+git://github.com/plotly/dash-renderer@multi-output#egg=dash_renderer percy selenium mock From 53b8cdb892f592ad2ce033cc2f49b26f5466f403 Mon Sep 17 00:00:00 2001 From: t4rk1n Date: Wed, 9 Jan 2019 16:39:16 -0500 Subject: [PATCH 21/37] :construction: Fix duplicate callback check --- dash/dash.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dash/dash.py b/dash/dash.py index 95c5a43ac9..27bf238e7c 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -762,7 +762,7 @@ def _validate_callback(self, output, inputs, state): callback_id = _create_callback_id(output) is_multi = isinstance(output, (list, tuple)) callbacks = set(itertools.chain(*( - x[1:-1].split('...') + x[2:-2].split('...') if x.startswith('..') else [x] for x in self.callback_map From 112a97b3e82173b6e5bc76a3920f2ad9106fc0bd Mon Sep 17 00:00:00 2001 From: t4rk1n Date: Wed, 9 Jan 2019 16:58:37 -0500 Subject: [PATCH 22/37] :green_heart: Force reinstall. --- .circleci/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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" }} From 41f6ffe113ab2e04de51573af161bc5faff10fc9 Mon Sep 17 00:00:00 2001 From: t4rk1n Date: Wed, 9 Jan 2019 17:10:58 -0500 Subject: [PATCH 23/37] :hocho: Remove invalid id characters --- dash/dash.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dash/dash.py b/dash/dash.py index 27bf238e7c..85cfac4423 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -680,7 +680,7 @@ def _validate_callback(self, output, inputs, state): name.lower(), str(arg), name )) - invalid_characters = ['.', ':', '[', ']'] + invalid_characters = ['.'] if any(x in arg.component_id for x in invalid_characters): raise exceptions.InvalidComponentIdError('''The element `{}` contains {} in its ID. From 5bbfc4ce77c85acb2c4bbcbb167afc48bdcee20d Mon Sep 17 00:00:00 2001 From: t4rk1n Date: Thu, 7 Feb 2019 14:57:33 -0500 Subject: [PATCH 24/37] :rotating_light: Fix multi-output test rebase. --- tests/test_integration.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_integration.py b/tests/test_integration.py index a7af6e1e1f..cb117c6bc3 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -11,7 +11,7 @@ import dash -from dash.dependencies import Input, Output +from dash.dependencies import Input, Output, State from dash.exceptions import PreventUpdate, DuplicateCallbackOutput, CallbackException from .IntegrationTests import IntegrationTests from .utils import assert_clean_console, invincible, wait_for From 8244575c427bb58997ce46e64170c993a23ff55c Mon Sep 17 00:00:00 2001 From: t4rk1n Date: Thu, 7 Feb 2019 14:59:21 -0500 Subject: [PATCH 25/37] :shirt: Fix rebase duplicate import. --- dash/dash.py | 1 - 1 file changed, 1 deletion(-) diff --git a/dash/dash.py b/dash/dash.py index 85cfac4423..e6ff89849f 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -31,7 +31,6 @@ 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 eccf8c6ce16edd721d487b7c34ecb1af92cff35c Mon Sep 17 00:00:00 2001 From: t4rk1n Date: Wed, 20 Feb 2019 12:17:10 -0500 Subject: [PATCH 26/37] :white_check_mark: Add test same output in multi output. --- tests/test_integration.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/tests/test_integration.py b/tests/test_integration.py index cb117c6bc3..45037c65a0 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -549,7 +549,8 @@ def test_multi_output(self): ]), html.Div(id='output3'), - html.Div(id='output4') + html.Div(id='output4'), + html.Div(id='output5') ]) @app.callback([Output('output1', 'children'), Output('output2', 'children')], @@ -595,6 +596,15 @@ def on_click_duplicate_multi(n_clicks): 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]) + self.startServer(app) t = time.time() From 32f341ff3310f8f24da8b6bfec16ee6653cbbed9 Mon Sep 17 00:00:00 2001 From: t4rk1n Date: Wed, 20 Feb 2019 12:31:31 -0500 Subject: [PATCH 27/37] :white_check_mark: Add test same output/input multi output version. --- tests/test_integration.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/test_integration.py b/tests/test_integration.py index 45037c65a0..efe57e8cf5 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -659,3 +659,16 @@ def failure(children): 'Same output and input: input-output.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 failure(children): + pass + + self.assertEqual( + 'Same output and input: input-output.children', + context.exception.args[0] + ) From 9293ad4ca1382458e32d8f42af65587814e9d7b2 Mon Sep 17 00:00:00 2001 From: t4rk1n Date: Wed, 20 Feb 2019 12:32:02 -0500 Subject: [PATCH 28/37] :hammer: Prevent same output/input in multi output callbacks. --- dash/dash.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/dash/dash.py b/dash/dash.py index e6ff89849f..74dee6a81d 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -637,11 +637,20 @@ 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 (layout is None and @@ -759,7 +768,7 @@ def _validate_callback(self, output, inputs, state): ).replace(' ', '')) callback_id = _create_callback_id(output) - is_multi = isinstance(output, (list, tuple)) + callbacks = set(itertools.chain(*( x[2:-2].split('...') if x.startswith('..') From ec89478b7f1fc52087e170d83a73a6698990f175 Mon Sep 17 00:00:00 2001 From: t4rk1n Date: Wed, 20 Feb 2019 12:48:04 -0500 Subject: [PATCH 29/37] :hammer: Prevent duplicates output in same multi output callback. --- dash/dash.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/dash/dash.py b/dash/dash.py index 74dee6a81d..02922deb74 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -653,6 +653,19 @@ def _validate_callback(self, output, inputs, state): '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 not self.config.first('suppress_callback_exceptions', 'supress_callback_exceptions')): @@ -781,7 +794,7 @@ def _validate_callback(self, output, inputs, state): if is_multi: def duplicate_check(): ns['duplicates'] = callbacks.intersection( - _create_callback_id(y) for y in output + str(y) for y in output ) return ns['duplicates'] else: From 9225f2ecd90b3e59ebdfa8f05882c715c0d35ebf Mon Sep 17 00:00:00 2001 From: t4rk1n Date: Wed, 20 Feb 2019 13:11:55 -0500 Subject: [PATCH 30/37] :shirt: Rename duplicate failure (fix F811) --- tests/test_integration.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_integration.py b/tests/test_integration.py index efe57e8cf5..f4aaf7dd2e 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -665,7 +665,7 @@ def failure(children): @app.callback([Output('out', 'children'), Output('input-output', 'children')], [Input('input-output', 'children')]) - def failure(children): + def failure2(children): pass self.assertEqual( From 0ecbf25865799a6a46aa74776f33035841c983c4 Mon Sep 17 00:00:00 2001 From: t4rk1n Date: Wed, 20 Feb 2019 13:27:50 -0500 Subject: [PATCH 31/37] :white_check_mark: Add test for overlapping multi output --- tests/test_integration.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/test_integration.py b/tests/test_integration.py index f4aaf7dd2e..95cc83fb0e 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -605,6 +605,18 @@ def on_click_same_output(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() From c51e6b44a4e5a38443e7aca4a28d81baad97517b Mon Sep 17 00:00:00 2001 From: t4rk1n Date: Wed, 20 Feb 2019 16:29:21 -0500 Subject: [PATCH 32/37] :hocho: Remove multi_output config. --- dash/dash.py | 1 - 1 file changed, 1 deletion(-) diff --git a/dash/dash.py b/dash/dash.py index 02922deb74..e0bf12d8dc 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -333,7 +333,6 @@ def serve_layout(self): def _config(self): config = { - 'multi_output': True, 'url_base_pathname': self.url_base_pathname, 'requests_pathname_prefix': self.config['requests_pathname_prefix'] } From ae316e15f69a87ff0cd4d35db57bb0259c463f3a Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Thu, 28 Feb 2019 21:28:11 -0500 Subject: [PATCH 33/37] gitignore mypycache --- .gitignore | 1 + 1 file changed, 1 insertion(+) 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/ From 3795f53225570480bf66a70b96d21457854c3ffd Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Thu, 28 Feb 2019 21:29:13 -0500 Subject: [PATCH 34/37] some linting for some reason pylint complains about things differently on AJs machine? --- dash/development/_py_components_generation.py | 17 +++++++++-------- tests/test_integration.py | 10 ++++++---- 2 files changed, 15 insertions(+), 12 deletions(-) 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/tests/test_integration.py b/tests/test_integration.py index 49a752d5f9..22097a2c86 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -4,13 +4,14 @@ 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 @@ -756,6 +757,7 @@ def test_with_custom_renderer_interpolated(self): }) ''' + class CustomDash(dash.Dash): def interpolate_index(self, **kwargs): From 5988731b5df6a1562c42554136203b8d8ad4c655 Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Thu, 28 Feb 2019 21:33:02 -0500 Subject: [PATCH 35/37] pull out prefix to simplify _add_url calls --- dash/dash.py | 45 +++++++++++++++++---------------------------- 1 file changed, 17 insertions(+), 28 deletions(-) diff --git a/dash/dash.py b/dash/dash.py index 2161b0df5a..3cea509246 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -193,47 +193,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) From 84fb0351e845800ddbff7e355363cc8a2b197aaf Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Thu, 28 Feb 2019 21:38:44 -0500 Subject: [PATCH 36/37] pull self.renderer out of _add_url so we only do it once! --- dash/dash.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/dash/dash.py b/dash/dash.py index 3cea509246..219a4ec45f 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -170,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) @@ -264,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 From 52eabdc224db35343c843092be3d42e0d2fd0d28 Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Thu, 28 Feb 2019 21:55:20 -0500 Subject: [PATCH 37/37] remove redundant line in dev-requirements.txt --- .circleci/requirements/dev-requirements.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/.circleci/requirements/dev-requirements.txt b/.circleci/requirements/dev-requirements.txt index a9a5613b11..6a4a78720e 100644 --- a/.circleci/requirements/dev-requirements.txt +++ b/.circleci/requirements/dev-requirements.txt @@ -8,7 +8,6 @@ selenium mock tox tox-pyenv -mock six plotly==3.6.1 requests[security]