From 928d4b4aadcd003b5628e956680a0341607b5b88 Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Thu, 4 Apr 2019 18:51:00 -0400 Subject: [PATCH 1/8] cleaner imports in test_integration --- tests/test_integration.py | 55 +++++++++++++++++++-------------------- 1 file changed, 27 insertions(+), 28 deletions(-) diff --git a/tests/test_integration.py b/tests/test_integration.py index 7b3d7be45e..92e1d51fc6 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -13,7 +13,7 @@ import dash_html_components as html import dash_core_components as dcc -import dash +from dash import Dash, callback_context from dash.dependencies import Input, Output, State from dash.exceptions import ( @@ -34,7 +34,7 @@ def wait_for_element_by_id(id): self.wait_for_element_by_id = wait_for_element_by_id def test_simple_callback(self): - app = dash.Dash(__name__) + app = Dash(__name__) app.layout = html.Div([ dcc.Input( id='input', @@ -90,7 +90,7 @@ def update_output(value): assert_clean_console(self) def test_wildcard_callback(self): - app = dash.Dash(__name__) + app = Dash(__name__) app.layout = html.Div([ dcc.Input( id='input', @@ -155,7 +155,7 @@ def test_aborted_callback(self): initial_input = 'initial input' initial_output = 'initial output' - app = dash.Dash(__name__) + app = Dash(__name__) app.layout = html.Div([ dcc.Input(id='input', value=initial_input), html.Div(initial_output, id='output1'), @@ -199,7 +199,7 @@ def callback2(value): self.percy_snapshot(name='aborted') def test_wildcard_data_attributes(self): - app = dash.Dash() + app = Dash() test_time = datetime.datetime(2012, 1, 10, 2, 3) test_date = datetime.date(test_time.year, test_time.month, test_time.day) @@ -257,7 +257,7 @@ def test_wildcard_data_attributes(self): assert_clean_console(self) def test_no_props_component(self): - app = dash.Dash() + app = Dash() app.layout = html.Div([ dash_dangerously_set_inner_html.DangerouslySetInnerHTML('''

No Props Component

@@ -268,7 +268,7 @@ def test_no_props_component(self): self.percy_snapshot(name='no-props-component') def test_flow_component(self): - app = dash.Dash() + app = Dash() app.layout = html.Div([ dash_flow_example.ExampleReactComponent( @@ -309,7 +309,7 @@ def test_meta_tags(self): {'name': 'custom', 'content': 'customized'}, ] - app = dash.Dash(meta_tags=metas) + app = Dash(meta_tags=metas) app.layout = html.Div(id='content') @@ -329,7 +329,7 @@ def test_meta_tags(self): self.assertEqual(content, meta_info['content']) def test_index_customization(self): - app = dash.Dash() + app = Dash() app.index_string = ''' @@ -384,8 +384,7 @@ def test_index_customization(self): self.percy_snapshot('custom-index') def test_assets(self): - app = dash.Dash(__name__, - assets_ignore='.*ignored.*') + app = Dash(__name__, assets_ignore='.*ignored.*') app.index_string = ''' @@ -437,7 +436,7 @@ def test_assets(self): self.percy_snapshot('test assets includes') def test_invalid_index_string(self): - app = dash.Dash() + app = Dash() def will_raise(): app.index_string = ''' @@ -496,9 +495,9 @@ def test_external_files_init(self): } ] - app = dash.Dash(__name__, - external_scripts=js_files, - external_stylesheets=css_files) + app = Dash(__name__, + external_scripts=js_files, + external_stylesheets=css_files) app.index_string = ''' @@ -547,7 +546,7 @@ def test_external_files_init(self): def test_func_layout_accepted(self): - app = dash.Dash() + app = Dash() def create_layout(): return html.Div('Hello World') @@ -557,7 +556,7 @@ def create_layout(): time.sleep(0.5) def test_multi_output(self): - app = dash.Dash(__name__) + app = Dash(__name__) app.scripts.config.serve_locally = True app.layout = html.Div([ @@ -658,7 +657,7 @@ def overlapping_multi_output(n_clicks): self.assertGreater(int(output2.text), t) def test_with_custom_renderer(self): - app = dash.Dash(__name__) + app = Dash(__name__) app.index_string = ''' @@ -758,7 +757,7 @@ def test_with_custom_renderer_interpolated(self): ''' - class CustomDash(dash.Dash): + class CustomDash(Dash): def interpolate_index(self, **kwargs): return ''' @@ -824,7 +823,7 @@ def update_output(value): self.percy_snapshot(name='request-hooks interpolated') def test_modified_response(self): - app = dash.Dash(__name__) + app = Dash(__name__) app.layout = html.Div([ dcc.Input(id='input', value='ab'), html.Div(id='output') @@ -832,7 +831,7 @@ def test_modified_response(self): @app.callback(Output('output', 'children'), [Input('input', 'value')]) def update_output(value): - dash.callback_context.response.set_cookie( + callback_context.response.set_cookie( 'dash cookie', value + ' - cookie') return value + ' - output' @@ -850,7 +849,7 @@ def update_output(value): assert_clean_console(self) def test_late_component_register(self): - app = dash.Dash() + app = Dash() app.layout = html.Div([ html.Button('Click me to put a dcc ', id='btn-insert'), @@ -874,7 +873,7 @@ def update_output(value): self.wait_for_element_by_css_selector('#inserted-input') def test_output_input_invalid_callback(self): - app = dash.Dash(__name__) + app = Dash(__name__) app.layout = html.Div([ html.Div('child', id='input-output'), html.Div(id='out') @@ -905,7 +904,7 @@ def failure2(children): ) def test_callback_return_validation(self): - app = dash.Dash(__name__) + app = Dash(__name__) app.layout = html.Div([ html.Div(id='a'), html.Div(id='b'), @@ -942,7 +941,7 @@ def multi2(a): multi2('aaa') def test_callback_context(self): - app = dash.Dash(__name__) + app = Dash(__name__) btns = ['btn-{}'.format(x) for x in range(1, 6)] @@ -956,9 +955,9 @@ def test_callback_context(self): @app.callback(Output('output', 'children'), [Input(x, 'n_clicks') for x in btns]) def on_click(*args): - if not dash.callback_context.triggered: + if not callback_context.triggered: raise PreventUpdate - trigger = dash.callback_context.triggered[0] + trigger = callback_context.triggered[0] return 'Just clicked {} for the {} time!'.format( trigger['prop_id'].split('.')[0], trigger['value'] ) @@ -982,4 +981,4 @@ def on_click(*args): def test_no_callback_context(self): for attr in ['inputs', 'states', 'triggered', 'response']: with self.assertRaises(MissingCallbackContextException): - getattr(dash.callback_context, attr) + getattr(callback_context, attr) From fb0db30ac4ff9941fb0d1dec5cb060d614f84fbb Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Thu, 4 Apr 2019 19:44:19 -0400 Subject: [PATCH 2/8] simplify test Value usage --- tests/test_integration.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_integration.py b/tests/test_integration.py index 92e1d51fc6..aa0e8ae1f6 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -54,7 +54,7 @@ def test_simple_callback(self): @app.callback(Output('output-1', 'children'), [Input('input', 'value')]) def update_output(value): - call_count.value = call_count.value + 1 + call_count.value += 1 return value self.startServer(app) @@ -111,7 +111,7 @@ def test_wildcard_callback(self): @app.callback(Output('output-1', 'data-cb'), [Input('input', 'value')]) def update_data(value): - input_call_count.value = input_call_count.value + 1 + input_call_count.value += 1 return value @app.callback(Output('output-1', 'children'), @@ -167,13 +167,13 @@ def test_aborted_callback(self): @app.callback(Output('output1', 'children'), [Input('input', 'value')]) def callback1(value): - callback1_count.value = callback1_count.value + 1 + callback1_count.value += 1 raise PreventUpdate("testing callback does not update") return value @app.callback(Output('output2', 'children'), [Input('output1', 'children')]) def callback2(value): - callback2_count.value = callback2_count.value + 1 + callback2_count.value += 1 return value self.startServer(app) From b2a97b41d376baf5122e59eb7d64641608cdffa6 Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Thu, 4 Apr 2019 20:31:01 -0400 Subject: [PATCH 3/8] add no_update for partial or non-exception update rejection --- dash/__init__.py | 2 +- dash/dash.py | 23 +++++++++++++++++-- tests/test_integration.py | 48 +++++++++++++++++++++++++++++++++++---- 3 files changed, 65 insertions(+), 8 deletions(-) diff --git a/dash/__init__.py b/dash/__init__.py index d37fef74e6..ead91983cd 100644 --- a/dash/__init__.py +++ b/dash/__init__.py @@ -1,4 +1,4 @@ -from .dash import Dash # noqa: F401 +from .dash import Dash, no_update # noqa: F401 from . import dependencies # noqa: F401 from . import development # noqa: F401 from . import exceptions # noqa: F401 diff --git a/dash/dash.py b/dash/dash.py index 10364839e4..960f2fd671 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -74,6 +74,15 @@ _re_renderer_scripts_id = re.compile(r'id="_dash-renderer') +class _NoUpdate(object): + # pylint: disable=too-few-public-methods + pass + + +# Singleton signal to not update an output, alternative to PreventUpdate +no_update = _NoUpdate() + + # pylint: disable=too-many-instance-attributes # pylint: disable=too-many-arguments, too-many-locals class Dash(object): @@ -990,15 +999,25 @@ def add_context(*args, **kwargs): ) component_ids = collections.defaultdict(dict) + has_update = False for i, o in enumerate(output): - component_ids[o.component_id][o.component_property] =\ - output_value[i] + val = output_value[i] + if val is not no_update: + has_update = True + o_id, o_prop = o.component_id, o.component_property + component_ids[o_id][o_prop] = val + + if not has_update: + raise exceptions.PreventUpdate response = { 'response': component_ids, 'multi': True } else: + if output_value is no_update: + raise exceptions.PreventUpdate + response = { 'response': { 'props': { diff --git a/tests/test_integration.py b/tests/test_integration.py index aa0e8ae1f6..bc819ca83d 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -13,7 +13,7 @@ import dash_html_components as html import dash_core_components as dcc -from dash import Dash, callback_context +from dash import Dash, callback_context, no_update from dash.dependencies import Input, Output, State from dash.exceptions import ( @@ -150,7 +150,10 @@ def update_text(data): assert_clean_console(self) def test_aborted_callback(self): - """Raising PreventUpdate prevents update and triggering dependencies""" + """ + Raising PreventUpdate OR returning no_update + prevents update and triggering dependencies + """ initial_input = 'initial input' initial_output = 'initial output' @@ -168,6 +171,8 @@ def test_aborted_callback(self): @app.callback(Output('output1', 'children'), [Input('input', 'value')]) def callback1(value): callback1_count.value += 1 + if callback1_count.value > 2: + return no_update raise PreventUpdate("testing callback does not update") return value @@ -180,12 +185,12 @@ def callback2(value): input_ = self.wait_for_element_by_id('input') input_.clear() - input_.send_keys('x') + input_.send_keys('xyz') output1 = self.wait_for_element_by_id('output1') output2 = self.wait_for_element_by_id('output2') - # callback1 runs twice (initial page load and through send_keys) - self.assertEqual(callback1_count.value, 2) + # callback1 runs 4x (initial page load and 3x through send_keys) + self.assertEqual(callback1_count.value, 4) # callback2 is never triggered, even on initial load self.assertEqual(callback2_count.value, 0) @@ -656,6 +661,39 @@ def overlapping_multi_output(n_clicks): self.assertGreater(int(output2.text), t) + def test_multi_output_no_update(self): + app = Dash(__name__) + app.scripts.config.serve_locally = True + + app.layout = html.Div([ + html.Button('B', 'btn'), + html.P('initial1', 'n1'), + html.P('initial2', 'n2'), + html.P('initial3', 'n3') + ]) + + @app.callback([Output('n1', 'children'), + Output('n2', 'children'), + Output('n3', 'children')], + [Input('btn', 'n_clicks')]) + def show_clicks(n): + # partial or complete cancelation of updates via no_update + return [ + no_update if n > 4 else n, + no_update if n > 2 else n, + no_update + ] + + self.startServer(app) + + btn = self.wait_for_element_by_id('btn') + for _ in range(10): + btn.click() + + self.wait_for_text_to_equal('#n1', '4') + self.wait_for_text_to_equal('#n2', '2') + self.wait_for_text_to_equal('#n3', 'initial3') + def test_with_custom_renderer(self): app = Dash(__name__) From ba00dd7b28b63aab8950d1af804bbcabcce324ee Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Mon, 8 Apr 2019 13:31:22 -0400 Subject: [PATCH 4/8] test that no_update prevents chained callbacks --- tests/test_integration.py | 59 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/tests/test_integration.py b/tests/test_integration.py index bc819ca83d..7545fb929c 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -694,6 +694,65 @@ def show_clicks(n): self.wait_for_text_to_equal('#n2', '2') self.wait_for_text_to_equal('#n3', 'initial3') + def test_no_update_chains(self): + app = Dash(__name__) + app.scripts.config.serve_locally = True + + app.layout = html.Div([ + dcc.Input(id='a_in', value='a'), + dcc.Input(id='b_in', value='b'), + html.P('', id='a_out'), + html.P('', id='a_out_short'), + html.P('', id='b_out'), + html.P('', id='ab_out') + ]) + + @app.callback([Output('a_out', 'children'), + Output('a_out_short', 'children')], + [Input('a_in', 'value')]) + def a_out(a): + return (a, a if len(a) < 3 else no_update) + + @app.callback(Output('b_out', 'children'), [Input('b_in', 'value')]) + def b_out(b): + return b + + @app.callback(Output('ab_out', 'children'), + [Input('a_out_short', 'children')], + [State('b_out', 'children')]) + def ab_out(a, b): + return a + ' ' + b + + self.startServer(app) + + a_in = self.wait_for_element_by_id('a_in') + b_in = self.wait_for_element_by_id('b_in') + + b_in.send_keys('b') + a_in.send_keys('a') + self.wait_for_text_to_equal('#a_out', 'aa') + self.wait_for_text_to_equal('#b_out', 'bb') + self.wait_for_text_to_equal('#a_out_short', 'aa') + self.wait_for_text_to_equal('#ab_out', 'aa bb') + + b_in.send_keys('b') + a_in.send_keys('a') + self.wait_for_text_to_equal('#a_out', 'aaa') + self.wait_for_text_to_equal('#b_out', 'bbb') + self.wait_for_text_to_equal('#a_out_short', 'aa') + # ab_out has not been triggered because a_out_short received no_update + self.wait_for_text_to_equal('#ab_out', 'aa bb') + + b_in.send_keys('b') + a_in.send_keys(Keys.END) + a_in.send_keys(Keys.BACKSPACE) + self.wait_for_text_to_equal('#a_out', 'aa') + self.wait_for_text_to_equal('#b_out', 'bbbb') + self.wait_for_text_to_equal('#a_out_short', 'aa') + # now ab_out *is* triggered - a_out_short got a new value + # even though that value is the same as the last value it got + self.wait_for_text_to_equal('#ab_out', 'aa bbbb') + def test_with_custom_renderer(self): app = Dash(__name__) From 681e8c5e905ee2a0a8f6346c293cf33a7c2dc6fd Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Mon, 8 Apr 2019 13:44:21 -0400 Subject: [PATCH 5/8] robustify aborted callback test --- 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 7545fb929c..0c245b5f42 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -184,8 +184,8 @@ def callback2(value): self.startServer(app) input_ = self.wait_for_element_by_id('input') - input_.clear() input_.send_keys('xyz') + self.wait_for_text_to_equal('#input', 'initial inputxyz') output1 = self.wait_for_element_by_id('output1') output2 = self.wait_for_element_by_id('output2') From 0ee047e85bb14cb3dfcb3ff86b7f262df0004fa8 Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Mon, 8 Apr 2019 14:00:22 -0400 Subject: [PATCH 6/8] more test fixing - including @chriddyp's updates to wait_for_* --- tests/IntegrationTests.py | 24 +++++++++++++++++------- tests/test_integration.py | 4 ++-- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/tests/IntegrationTests.py b/tests/IntegrationTests.py index c213135ed8..31dec9daf2 100644 --- a/tests/IntegrationTests.py +++ b/tests/IntegrationTests.py @@ -21,15 +21,25 @@ def percy_snapshot(cls, name=''): name=snapshot_name ) - def wait_for_element_by_css_selector(self, selector): - return WebDriverWait(self.driver, TIMEOUT).until( - EC.presence_of_element_located((By.CSS_SELECTOR, selector)) + def wait_for_element_by_css_selector(self, selector, timeout=TIMEOUT): + return WebDriverWait(self.driver, timeout).until( + EC.presence_of_element_located((By.CSS_SELECTOR, selector)), + 'Could not find element with selector "{}"'.format(selector) ) - def wait_for_text_to_equal(self, selector, assertion_text): - return WebDriverWait(self.driver, TIMEOUT).until( - EC.text_to_be_present_in_element((By.CSS_SELECTOR, selector), - assertion_text) + def wait_for_text_to_equal(self, selector, assertion_text, timeout=TIMEOUT): + self.wait_for_element_by_css_selector(selector) + WebDriverWait(self.driver, timeout).until( + lambda *args: ( + (str(self.wait_for_element_by_css_selector(selector).text) + == assertion_text) or + (str(self.wait_for_element_by_css_selector(selector).get_attribute('value')) + == assertion_text) + ), + "Element '{}' text was supposed to equal '{}' but it didn't".format( + selector, + assertion_text + ) ) @classmethod diff --git a/tests/test_integration.py b/tests/test_integration.py index 0c245b5f42..ab66dd0206 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -679,8 +679,8 @@ def test_multi_output_no_update(self): def show_clicks(n): # partial or complete cancelation of updates via no_update return [ - no_update if n > 4 else n, - no_update if n > 2 else n, + no_update if n and n > 4 else n, + no_update if n and n > 2 else n, no_update ] From ad42fa7cc54f09395750f532b2ca555218350d76 Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Mon, 8 Apr 2019 15:27:23 -0400 Subject: [PATCH 7/8] lint --- tests/IntegrationTests.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/tests/IntegrationTests.py b/tests/IntegrationTests.py index 31dec9daf2..13a69f1f53 100644 --- a/tests/IntegrationTests.py +++ b/tests/IntegrationTests.py @@ -28,13 +28,11 @@ def wait_for_element_by_css_selector(self, selector, timeout=TIMEOUT): ) def wait_for_text_to_equal(self, selector, assertion_text, timeout=TIMEOUT): - self.wait_for_element_by_css_selector(selector) + el = self.wait_for_element_by_css_selector(selector) WebDriverWait(self.driver, timeout).until( lambda *args: ( - (str(self.wait_for_element_by_css_selector(selector).text) - == assertion_text) or - (str(self.wait_for_element_by_css_selector(selector).get_attribute('value')) - == assertion_text) + (str(el.text) == assertion_text) or + (str(el.get_attribute('value')) == assertion_text) ), "Element '{}' text was supposed to equal '{}' but it didn't".format( selector, From 1689606618b1f81f70a8f77956f7c69b69169da6 Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Mon, 8 Apr 2019 15:43:22 -0400 Subject: [PATCH 8/8] changelog for partial updates --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 55a4acc5a7..1d351fe10e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ - Support for "Clientside Callbacks" - an escape hatch to execute your callbacks in JavaScript instead of Python [#672](https://github.com/plotly/dash/pull/672) - Added `dev_tools_ui` config flag in `app.run_server` (serialized in `