diff --git a/dash/CHANGELOG.md b/dash/CHANGELOG.md index ef8b9027a4..3e6f17d745 100644 --- a/dash/CHANGELOG.md +++ b/dash/CHANGELOG.md @@ -1,5 +1,13 @@ ## Unreleased ### Changed +- 💥 [#761](https://github.com/plotly/dash/pull/761) Several breaking changes to the `dash.Dash` API: + - Removed two obsolete constructor kwargs: `static_folder` and `components_cache_max_age` + - Removed the misspelled `supress_callback_exceptions` fallback + - Removed the unused `resources.config.infer_from_layout` + - Revamped `app.config`: ALL constructor args are now stored in `config`, with three exceptions: `server`, `index_string`, and `plugins`. None of these are stored in any other instance attributes anymore. + - Changed `hot_reload_interval` from msec to seconds, for consistency with `hot_reload_watch_interval` + - When called from `enable_dev_tools`, `debug=True` by default. It's still `False` by default from `run_server`. + - [#753](https://github.com/plotly/dash/pull/753) `Component` no longer inherits `MutableMapping`, so `values`, `keys`, and more are no longer methods. This fixed an issue reported in [dcc](https://github.com/plotly/dash-core-components/issues/440) where components with certain prop names defined but not provided would cause a failure to render. During component generation we now disallow all props with leading underscores or matching a few remaining reserved words: `UNDEFINED`, `REQUIRED`, `to_plotly_json`, `available_properties`, and `available_wildcard_properties`. - [#739](https://github.com/plotly/dash/pull/739) Allow the Flask app to be provided to Dash after object initialization. This allows users to define Dash layouts etc when using the app factory pattern, or any other pattern that inhibits access to the app object. This broadly complies with the flask extension API, allowing Dash to be considered as a Flask extension where it needs to be. diff --git a/dash/_utils.py b/dash/_utils.py index 740f946d2a..dcd83b1c26 100644 --- a/dash/_utils.py +++ b/dash/_utils.py @@ -75,8 +75,28 @@ def __getattr__(self, key): try: return self[key] except KeyError: - # to conform with __getattr__ spec - raise AttributeError(key) + pass + # to conform with __getattr__ spec + # but get out of the except block so it doesn't look like a nested err + raise AttributeError(key) + + def set_read_only(self, names, msg='Attribute is read-only'): + object.__setattr__(self, '_read_only', names) + object.__setattr__(self, '_read_only_msg', msg) + + def finalize(self, msg='Object is final: No new keys may be added.'): + """Prevent any new keys being set""" + object.__setattr__(self, '_final', msg) + + def __setitem__(self, key, val): + if key in self.__dict__.get('_read_only', []): + raise AttributeError(self._read_only_msg, key) + + final_msg = self.__dict__.get('_final') + if final_msg and key not in self: + raise AttributeError(final_msg, key) + + return super(AttributeDict, self).__setitem__(key, val) # pylint: disable=inconsistent-return-statements def first(self, *names): diff --git a/dash/dash.py b/dash/dash.py index 292d8f207b..e48dbe9c39 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -9,7 +9,6 @@ import json import pkgutil import threading -import warnings import re import logging import pprint @@ -36,6 +35,7 @@ from ._utils import get_asset_path as _get_asset_path from ._utils import create_callback_id as _create_callback_id from ._configs import (get_combined_config, pathname_configs) +from .version import __version__ _default_index = ''' @@ -86,106 +86,223 @@ class _NoUpdate(object): # pylint: disable=too-many-instance-attributes # pylint: disable=too-many-arguments, too-many-locals class Dash(object): + """ + Dash is a framework for building analytical web applications. + No JavaScript required. + + If a parameter can be set by an environment variable, that is listed as: + env: ``DASH_****`` + Values provided here take precedence over environment variables. + + :param name: The name Flask should use for your app. Even if you provide + your own ``server``, ``name`` will be used to help find assets. + Typically ``__name__`` (the magic global var, not a string) is the + best value to use. Default ``'__main__'``, env: ``DASH_APP_NAME`` + :type name: string + + :param server: Sets the Flask server for your app. There are three options: + ``True`` (default): Dash will create a new server + ``False``: The server will be added later via ``app.init_app(server)`` + where ``server`` is a ``flask.Flask`` instance. + ``flask.Flask``: use this pre-existing Flask server. + :type server: boolean or flask.Flask + + :param assets_folder: a path, relative to the current working directory, + for extra files to be used in the browser. Default ``'assets'``. + All .js and .css files will be loaded immediately unless excluded by + ``assets_ignore``, and other files such as images will be served if + requested. + :type assets_folder: string + + :param assets_url_path: The local urls for assets will be: + ``requests_pathname_prefix + assets_url_path + '/' + asset_path`` + where ``asset_path`` is the path to a file inside ``assets_folder``. + Default ``'assets'``. + :type asset_url_path: string + + :param assets_ignore: A regex, as a string to pass to ``re.compile``, for + assets to omit from immediate loading. Ignored files will still be + served if specifically requested. You cannot use this to prevent access + to sensitive files. + :type assets_ignore: string + + :param assets_external_path: an absolute URL from which to load assets. + Use with ``serve_locally=False``. Dash can still find js and css to + automatically load if you also keep local copies in your assets + folder that Dash can index, but external serving can improve + performance and reduce load on the Dash server. + env: ``DASH_ASSETS_EXTERNAL_PATH`` + :type assets_external_path: string + + :param include_assets_files: Default ``True``, set to ``False`` to prevent + immediate loading of any assets. Assets will still be served if + specifically requested. You cannot use this to prevent access + to sensitive files. env: ``DASH_INCLUDE_ASSETS_FILES`` + :type include_assets_files: boolean + + :param url_base_pathname: A local URL prefix to use app-wide. + Default ``'/'``. Both `requests_pathname_prefix` and + `routes_pathname_prefix` default to `url_base_pathname`. + env: ``DASH_URL_BASE_PATHNAME`` + :type url_base_pathname: string + + :param requests_pathname_prefix: A local URL prefix for file requests. + Defaults to `url_base_pathname`, and must end with + `routes_pathname_prefix`. env: ``DASH_REQUESTS_PATHNAME_PREFIX`` + :type requests_pathname_prefix: string + + :param routes_pathname_prefix: A local URL prefix for JSON requests. + Defaults to ``url_base_pathname``, and must start and end + with ``'/'``. env: ``DASH_ROUTES_PATHNAME_PREFIX`` + :type routes_pathname_prefix: string + + :param serve_locally: If ``True`` (default), assets and dependencies + (Dash and Component js and css) will be served from local URLs. + If ``False`` we will use CDN links where available. + :type serve_locally: boolean + + :param compress: Use gzip to compress files and data served by Flask. + Default ``True`` + :type compress: boolean + + :param meta_tags: html tags to be added to the index page. + Each dict should have the attributes and values for one tag, eg: + ``{'name': 'description', 'content': 'My App'}`` + :type meta_tags: list of dicts + + :param index_string: Override the standard Dash index page. + Must contain the correct insertion markers to interpolate various + content into it depending on the app config and components used. + See https://dash.plot.ly/external-resources for details. + :type index_string: string + + :param external_scripts: Additional JS files to load with the page. + Each entry can be a string (the URL) or a dict with ``src`` (the URL) + and optionally other `` + + + ''' - app.layout = html.Div(id='content') + app.layout = html.Div('Dash app', id='app') - self.startServer(app) + dash_duo.start_server(app) - meta = self.driver.find_elements_by_tag_name('meta') + assert dash_duo.find_element('#custom-header').text == 'My custom header' + assert dash_duo.find_element('#custom-footer').text == 'My custom footer' + assert dash_duo.wait_for_element('#add').text == 'Got added' - # -2 for the meta charset and http-equiv. - self.assertEqual(len(metas), len(meta) - 2, 'Not enough meta tags') + dash_duo.percy_snapshot('custom-index') - for i in range(2, len(meta)): - meta_tag = meta[i] - meta_info = metas[i - 2] - name = meta_tag.get_attribute('name') - content = meta_tag.get_attribute('content') - self.assertEqual(name, meta_info['name']) - self.assertEqual(content, meta_info['content']) - def test_index_customization(self): - app = Dash() +def test_inin009_invalid_index_string(dash_duo): + app = Dash() + def will_raise(): app.index_string = ''' @@ -348,604 +332,569 @@ def test_index_customization(self):
My custom header
- {%app_entry%} - - ''' - app.layout = html.Div('Dash app', id='app') - - self.startServer(app) - - time.sleep(0.5) - - header = self.wait_for_element_by_id('custom-header') - footer = self.wait_for_element_by_id('custom-footer') + with pytest.raises(Exception) as err: + will_raise() - self.assertEqual('My custom header', header.text) - self.assertEqual('My custom footer', footer.text) + exc_msg = str(err.value) + assert '{%app_entry%}' in exc_msg + assert '{%config%}' in exc_msg + assert '{%scripts%}' in exc_msg - add = self.wait_for_element_by_id('add') + app.layout = html.Div('Hello World', id='a') - self.assertEqual('Got added', add.text) + dash_duo.start_server(app) + assert dash_duo.find_element('#a').text == 'Hello World' - self.percy_snapshot('custom-index') - def test_invalid_index_string(self): - app = Dash() +def test_inin010_func_layout_accepted(dash_duo): + app = Dash() - def will_raise(): - app.index_string = ''' - - - - {%metas%} - {%title%} - {%favicon%} - {%css%} - - -
My custom header
-
- - - - ''' + def create_layout(): + return html.Div('Hello World', id='a') + app.layout = create_layout - with self.assertRaises(Exception) as context: - will_raise() + dash_duo.start_server(app) + assert dash_duo.find_element('#a').text == 'Hello World' - app.layout = html.Div() - self.startServer(app) - exc_msg = str(context.exception) - self.assertTrue('{%app_entry%}' in exc_msg) - self.assertTrue('{%config%}' in exc_msg) - self.assertTrue('{%scripts%}' in exc_msg) - time.sleep(0.5) +def test_inin011_multi_output(dash_duo): + app = Dash(__name__) - def test_func_layout_accepted(self): + app.layout = html.Div([ + html.Button('OUTPUT', id='output-btn'), - app = Dash() - - def create_layout(): - return html.Div('Hello World') - app.layout = create_layout - - self.startServer(app) - time.sleep(0.5) - - def test_multi_output(self): - app = Dash(__name__) - - 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.Table([ + html.Thead([ + html.Tr([html.Th('Output 1'), html.Th('Output 2')]) ]), - - 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): + 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) + + with pytest.raises( + DuplicateCallbackOutput, + message="multi output can't be included in a single output" + ) as err: + @app.callback(Output('output1', 'children'), + [Input('output-btn', 'n_clicks')]) + def on_click_duplicate(n_clicks): if n_clicks is None: raise PreventUpdate - return n_clicks, n_clicks_timestamp + return 'something else' + + assert 'output1' in err.value.args[0] - # Dummy callback for DuplicateCallbackOutput test. - @app.callback(Output('output3', 'children'), + with pytest.raises( + DuplicateCallbackOutput, + message="multi output cannot contain a used single output" + ) as err: + @app.callback([Output('output3', 'children'), + Output('output4', 'children')], [Input('output-btn', 'n_clicks')]) - def dummy_callback(n_clicks): + def on_click_duplicate_multi(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]) + return 'something else' - # 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 + assert 'output3' in err.value.args[0] - return 'something else' - - self.assertTrue('output3' in context.exception.args[0]) + with pytest.raises( + DuplicateCallbackOutput, + message="same output cannot be used twice in one callback" + ) as err: + @app.callback([Output('output5', 'children'), + Output('output5', 'children')], + [Input('output-btn', 'n_clicks')]) + def on_click_same_output(n_clicks): + return n_clicks - 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 + assert 'output5' in err.value.args[0] - self.assertTrue('output5' in context.exception.args[0]) + with pytest.raises( + DuplicateCallbackOutput, + message="no part of an existing multi-output can be used in another" + ) as err: + @app.callback([Output('output1', 'children'), + Output('output5', 'children')], + [Input('output-btn', 'n_clicks')]) + def overlapping_multi_output(n_clicks): + return n_clicks - 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 + assert ( + '{\'output1.children\'}' in err.value.args[0] + or "set(['output1.children'])" in err.value.args[0] + ) - self.assertTrue( - '{\'output1.children\'}' in context.exception.args[0] - or "set(['output1.children'])" in context.exception.args[0] - ) + dash_duo.start_server(app) - self.startServer(app) + t = time.time() - t = time.time() + btn = dash_duo.find_element('#output-btn') + btn.click() + time.sleep(1) - btn = self.wait_for_element_by_id('output-btn') - btn.click() - time.sleep(1) + dash_duo.wait_for_text_to_equal('#output1', '1') - self.wait_for_text_to_equal('#output1', '1') - output2 = self.wait_for_element_by_css_selector('#output2') + assert int(dash_duo.find_element('#output2').text) > t - self.assertGreater(int(output2.text), t) - def test_multi_output_no_update(self): - app = Dash(__name__) +def test_inin012_multi_output_no_update(dash_duo): + app = Dash(__name__) - 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 and n > 4 else n, - no_update if n and 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_no_update_chains(self): - app = Dash(__name__) - - 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.layout = html.Div([ + html.Button('B', 'btn'), + html.P('initial1', 'n1'), + html.P('initial2', 'n2'), + html.P('initial3', 'n3') + ]) - @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__) + @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 and n > 4 else n, + no_update if n and n > 2 else n, + no_update + ] - app.index_string = ''' - - - - {%metas%} - {%title%} - {%favicon%} - {%css%} - - -
Testing custom DashRenderer
- {%app_entry%} - -
With request hooks
- - - ''' + }, + request_post: () => { + var output = document.getElementById('output-post') + if(output) { + output.innerHTML = 'request_post ran!'; + } + } + }) + + +
With request hooks
+ + + ''' + + app.layout = html.Div([ + dcc.Input(id='input', value='initial value'), + html.Div( + html.Div([ + html.Div(id='output-1'), + html.Div(id='output-pre'), + html.Div(id='output-post') + ]) + ) + ]) - app.layout = html.Div([ - dcc.Input( - id='input', - value='initial value' - ), - html.Div( - html.Div([ - html.Div(id='output-1'), - html.Div(id='output-pre'), - html.Div(id='output-post') - ]) - ) - ]) + @app.callback(Output('output-1', 'children'), [Input('input', 'value')]) + def update_output(value): + return value - @app.callback(Output('output-1', 'children'), [Input('input', 'value')]) - def update_output(value): - return value + dash_duo.start_server(app) - self.startServer(app) + input1 = dash_duo.find_element('#input') + dash_duo.clear_input(input1) - input1 = self.wait_for_element_by_id('input') - chain = (ActionChains(self.driver) - .click(input1) - .send_keys(Keys.HOME) - .key_down(Keys.SHIFT) - .send_keys(Keys.END) - .key_up(Keys.SHIFT) - .send_keys(Keys.DELETE)) - chain.perform() + input1.send_keys('fire request hooks') - input1.send_keys('fire request hooks') + dash_duo.wait_for_text_to_equal('#output-1', 'fire request hooks') + assert dash_duo.find_element('#output-pre').text == 'request_pre!!!' + assert dash_duo.find_element('#output-post').text == 'request_post ran!' - self.wait_for_text_to_equal('#output-1', 'fire request hooks') - self.wait_for_text_to_equal('#output-pre', 'request_pre changed this text!') - self.wait_for_text_to_equal('#output-post', 'request_post changed this text!') + dash_duo.percy_snapshot(name='request-hooks') - self.percy_snapshot(name='request-hooks') - def test_with_custom_renderer_interpolated(self): +def test_inin015_with_custom_renderer_interpolated(dash_duo): - renderer = ''' - - ''' - - class CustomDash(Dash): - - def interpolate_index(self, **kwargs): - return ''' - - - - My App - - - -
My custom header
- {app_entry} - {config} - {scripts} - {renderer} - - - - '''.format( - app_entry=kwargs['app_entry'], - config=kwargs['config'], - scripts=kwargs['scripts'], - renderer=renderer) - - app = CustomDash() - - app.layout = html.Div([ - dcc.Input( - id='input', - value='initial value' - ), - html.Div( - html.Div([ - html.Div(id='output-1'), - html.Div(id='output-pre'), - html.Div(id='output-post') - ]) - ) - ]) - - @app.callback(Output('output-1', 'children'), [Input('input', 'value')]) - def update_output(value): - return value - - self.startServer(app) - - input1 = self.wait_for_element_by_id('input') - chain = (ActionChains(self.driver) - .click(input1) - .send_keys(Keys.HOME) - .key_down(Keys.SHIFT) - .send_keys(Keys.END) - .key_up(Keys.SHIFT) - .send_keys(Keys.DELETE)) - chain.perform() + }, + request_post: () => { + var output = document.getElementById('output-post') + if(output) { + output.innerHTML = 'request_post!!!'; + } + } + }) + + ''' + + class CustomDash(Dash): + + def interpolate_index(self, **kwargs): + return ''' + + + + My App + + + +
My custom header
+ {app_entry} + {config} + {scripts} + {renderer} + + + + '''.format( + app_entry=kwargs['app_entry'], + config=kwargs['config'], + scripts=kwargs['scripts'], + renderer=renderer) + + app = CustomDash() + + app.layout = html.Div([ + dcc.Input(id='input', value='initial value'), + html.Div( + html.Div([ + html.Div(id='output-1'), + html.Div(id='output-pre'), + html.Div(id='output-post') + ]) + ) + ]) - input1.send_keys('fire request hooks') + @app.callback(Output('output-1', 'children'), [Input('input', 'value')]) + def update_output(value): + return value - self.wait_for_text_to_equal('#output-1', 'fire request hooks') - self.wait_for_text_to_equal('#output-pre', 'request_pre changed this text!') - self.wait_for_text_to_equal('#output-post', 'request_post changed this text!') + dash_duo.start_server(app) - self.percy_snapshot(name='request-hooks interpolated') + input1 = dash_duo.find_element('#input') + dash_duo.clear_input(input1) - def test_modified_response(self): - app = Dash(__name__) - app.layout = html.Div([ - dcc.Input(id='input', value='ab'), - html.Div(id='output') - ]) + input1.send_keys('fire request hooks') - @app.callback(Output('output', 'children'), [Input('input', 'value')]) - def update_output(value): - callback_context.response.set_cookie( - 'dash cookie', value + ' - cookie') - return value + ' - output' + dash_duo.wait_for_text_to_equal('#output-1', 'fire request hooks') + assert dash_duo.find_element('#output-pre').text == 'request_pre was here!' + assert dash_duo.find_element('#output-post').text == 'request_post!!!' - self.startServer(app) - self.wait_for_text_to_equal('#output', 'ab - output') - input1 = self.wait_for_element_by_id('input') + dash_duo.percy_snapshot(name='request-hooks interpolated') - input1.send_keys('cd') - self.wait_for_text_to_equal('#output', 'abcd - output') - cookie = self.driver.get_cookie('dash cookie') - # cookie gets json encoded - self.assertEqual(cookie['value'], '"abcd - cookie"') +def test_inin016_modified_response(dash_duo): + app = Dash(__name__) + app.layout = html.Div([ + dcc.Input(id='input', value='ab'), + html.Div(id='output') + ]) - self.assertTrue(self.is_console_clean()) + @app.callback(Output('output', 'children'), [Input('input', 'value')]) + def update_output(value): + callback_context.response.set_cookie( + 'dash cookie', value + ' - cookie') + return value + ' - output' - def test_late_component_register(self): - app = Dash() + dash_duo.start_server(app) + dash_duo.wait_for_text_to_equal('#output', 'ab - output') + input1 = dash_duo.find_element('#input') - app.layout = html.Div([ - html.Button('Click me to put a dcc ', id='btn-insert'), - html.Div(id='output') - ]) + input1.send_keys('cd') - @app.callback(Output('output', 'children'), - [Input('btn-insert', 'n_clicks')]) - def update_output(value): - if value is None: - raise PreventUpdate + dash_duo.wait_for_text_to_equal('#output', 'abcd - output') + cookie = dash_duo.driver.get_cookie('dash cookie') + # cookie gets json encoded + assert cookie['value'] == '"abcd - cookie"' + + assert not dash_duo.get_logs() - return dcc.Input(id='inserted-input') - self.startServer(app) +def test_inin017_late_component_register(dash_duo): + app = Dash() - btn = self.wait_for_element_by_css_selector('#btn-insert') - btn.click() - time.sleep(1) + app.layout = html.Div([ + html.Button('Click me to put a dcc ', id='btn-insert'), + html.Div(id='output') + ]) - self.wait_for_element_by_css_selector('#inserted-input') + @app.callback(Output('output', 'children'), + [Input('btn-insert', 'n_clicks')]) + def update_output(value): + if value is None: + raise PreventUpdate + + return dcc.Input(id='inserted-input') - def test_output_input_invalid_callback(self): - app = Dash(__name__) - app.layout = html.Div([ - html.Div('child', id='input-output'), - html.Div(id='out') - ]) + dash_duo.start_server(app) + + btn = dash_duo.find_element('#btn-insert') + btn.click() + + dash_duo.find_element('#inserted-input') + + +def test_inin018_output_input_invalid_callback(): + app = Dash(__name__) + app.layout = html.Div([ + html.Div('child', id='input-output'), + html.Div(id='out') + ]) - with self.assertRaises(CallbackException) as context: - @app.callback(Output('input-output', 'children'), - [Input('input-output', 'children')]) - def failure(children): - pass + with pytest.raises(CallbackException) as err: + @app.callback(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] - ) + msg = 'Same output and input: input-output.children' + assert err.value.args[0] == msg - # 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] + # Multi output version. + with pytest.raises(CallbackException) as err: + @app.callback([Output('out', 'children'), + Output('input-output', 'children')], + [Input('input-output', 'children')]) + def failure2(children): + pass + + msg = 'Same output and input: input-output.children' + assert err.value.args[0] == msg + + +def test_inin019_callback_dep_types(): + app = Dash(__name__) + app.layout = html.Div([ + html.Div('child', id='in'), + html.Div('state', id='state'), + html.Div(id='out') + ]) + + with pytest.raises(IncorrectTypeException, message="extra output nesting"): + @app.callback([[Output('out', 'children')]], + [Input('in', 'children')]) + def f(i): + return i + + with pytest.raises(IncorrectTypeException, message="un-nested input"): + @app.callback(Output('out', 'children'), + Input('in', 'children')) + def f2(i): + return i + + with pytest.raises(IncorrectTypeException, message="un-nested state"): + @app.callback(Output('out', 'children'), + [Input('in', 'children')], + State('state', 'children')) + def f3(i): + return i + + # all OK with tuples + @app.callback((Output('out', 'children'),), + (Input('in', 'children'),), + (State('state', 'children'),)) + def f4(i): + return i + + +def test_inin020_callback_return_validation(): + app = Dash(__name__) + app.layout = html.Div([ + html.Div(id='a'), + html.Div(id='b'), + html.Div(id='c'), + html.Div(id='d'), + html.Div(id='e'), + html.Div(id='f') + ]) + + @app.callback(Output('b', 'children'), [Input('a', 'children')]) + def single(a): + return set([1]) + + with pytest.raises(InvalidCallbackReturnValue, message="not serializable"): + single('aaa') + + @app.callback([Output('c', 'children'), Output('d', 'children')], + [Input('a', 'children')]) + def multi(a): + return [1, set([2])] + + with pytest.raises( + InvalidCallbackReturnValue, message="nested non-serializable" + ): + multi('aaa') + + @app.callback([Output('e', 'children'), Output('f', 'children')], + [Input('a', 'children')]) + def multi2(a): + return ['abc'] + + with pytest.raises( + InvalidCallbackReturnValue, message="wrong-length list" + ): + multi2('aaa') + + +def test_inin021_callback_context(dash_duo): + app = Dash(__name__) + + btns = ['btn-{}'.format(x) for x in range(1, 6)] + + app.layout = html.Div([ + html.Div([html.Button(btn, id=btn) for btn in btns]), + html.Div(id='output'), + ]) + + @app.callback(Output('output', 'children'), + [Input(x, 'n_clicks') for x in btns]) + def on_click(*args): + if not callback_context.triggered: + raise PreventUpdate + trigger = callback_context.triggered[0] + return 'Just clicked {} for the {} time!'.format( + trigger['prop_id'].split('.')[0], trigger['value'] ) - def test_callback_return_validation(self): - app = Dash(__name__) - app.layout = html.Div([ - html.Div(id='a'), - html.Div(id='b'), - html.Div(id='c'), - html.Div(id='d'), - html.Div(id='e'), - html.Div(id='f') - ]) - - @app.callback(Output('b', 'children'), [Input('a', 'children')]) - def single(a): - # anything non-serializable, really - return set([1]) - - with self.assertRaises(InvalidCallbackReturnValue): - single('aaa') + dash_duo.start_server(app) - @app.callback([Output('c', 'children'), Output('d', 'children')], - [Input('a', 'children')]) - def multi(a): - # non-serializable inside a list - return [1, set([2])] - - with self.assertRaises(InvalidCallbackReturnValue): - multi('aaa') - - @app.callback([Output('e', 'children'), Output('f', 'children')], - [Input('a', 'children')]) - def multi2(a): - # wrong-length list - return ['abc'] - - with self.assertRaises(InvalidCallbackReturnValue): - multi2('aaa') - - def test_callback_context(self): - app = Dash(__name__) - - btns = ['btn-{}'.format(x) for x in range(1, 6)] - - app.layout = html.Div([ - html.Div([ - html.Button(x, id=x) for x in btns - ]), - html.Div(id='output'), - ]) - - @app.callback(Output('output', 'children'), - [Input(x, 'n_clicks') for x in btns]) - def on_click(*args): - if not callback_context.triggered: - raise PreventUpdate - trigger = callback_context.triggered[0] - return 'Just clicked {} for the {} time!'.format( - trigger['prop_id'].split('.')[0], trigger['value'] + for i in range(1, 5): + for btn in btns: + dash_duo.find_element('#' + btn).click() + dash_duo.wait_for_text_to_equal( + '#output', + 'Just clicked {} for the {} time!'.format(btn, i) ) - self.startServer(app) - - btn_elements = [ - self.wait_for_element_by_id(x) for x in btns - ] - for i in range(1, 5): - for j, btn in enumerate(btns): - btn_elements[j].click() - self.wait_for_text_to_equal( - '#output', - 'Just clicked {} for the {} time!'.format( - btn, i - ) - ) - - def test_no_callback_context(self): - for attr in ['inputs', 'states', 'triggered', 'response']: - with self.assertRaises(MissingCallbackContextException): - getattr(callback_context, attr) +def test_inin022_no_callback_context(): + for attr in ['inputs', 'states', 'triggered', 'response']: + with pytest.raises(MissingCallbackContextException): + getattr(callback_context, attr) diff --git a/tests/integration/test_render.py b/tests/integration/test_render.py index 88775a9ebd..e0dedad21b 100644 --- a/tests/integration/test_render.py +++ b/tests/integration/test_render.py @@ -253,7 +253,7 @@ def display_chapter(toc_value): call_counts['body'].value += 1 return chapters[toc_value] - app.config.supress_callback_exceptions = True + app.config.suppress_callback_exceptions = True def generate_graph_callback(counterId): def callback(value): @@ -636,7 +636,7 @@ def test_removing_component_while_its_getting_updated(self): ), html.Div(id='body') ]) - app.config.supress_callback_exceptions = True + app.config.suppress_callback_exceptions = True call_counts = { 'body': Value('i', 0), diff --git a/tests/unit/dash/test_dash_import.py b/tests/unit/dash/test_dash_import.py index 5448882471..35465fc7f5 100644 --- a/tests/unit/dash/test_dash_import.py +++ b/tests/unit/dash/test_dash_import.py @@ -2,7 +2,7 @@ import types -def test_dash_import_is_correct(): +def test_dddi001_dash_import_is_correct(): imported = importlib.import_module("dash") assert isinstance(imported, types.ModuleType), "dash can be imported" diff --git a/tests/unit/dash/test_utils.py b/tests/unit/dash/test_utils.py new file mode 100644 index 0000000000..70e3c74e05 --- /dev/null +++ b/tests/unit/dash/test_utils.py @@ -0,0 +1,63 @@ +import pytest + +import dash._utils as utils + + +def test_ddut001_attribute_dict(): + a = utils.AttributeDict() + + assert str(a) == '{}' + with pytest.raises(AttributeError): + a.k + with pytest.raises(KeyError): + a['k'] + assert a.first('no', 'k', 'nope') is None + + a.k = 1 + + assert a.k == 1 + assert a['k'] == 1 + assert a.first('no', 'k', 'nope') == 1 + + a['k'] = 2 + + assert a.k == 2 + assert a['k'] == 2 + + a.set_read_only(['k', 'q'], 'boo') + + with pytest.raises(AttributeError) as err: + a.k = 3 + assert err.value.args == ('boo', 'k') + assert a.k == 2 + + with pytest.raises(AttributeError) as err: + a['k'] = 3 + assert err.value.args == ('boo', 'k') + assert a.k == 2 + + a.set_read_only(['q']) + + a.k = 3 + assert a.k == 3 + + with pytest.raises(AttributeError) as err: + a.q = 3 + assert err.value.args == ('Attribute is read-only', 'q') + assert 'q' not in a + + a.finalize('nope') + + with pytest.raises(AttributeError) as err: + a.x = 4 + assert err.value.args == ('nope', 'x') + assert 'x' not in a + + a.finalize() + + with pytest.raises(AttributeError) as err: + a.x = 4 + assert err.value.args == ( + 'Object is final: No new keys may be added.', 'x' + ) + assert 'x' not in a diff --git a/tests/unit/test_resources.py b/tests/unit/test_resources.py index 1aaf496eb8..448c5e030e 100644 --- a/tests/unit/test_resources.py +++ b/tests/unit/test_resources.py @@ -56,6 +56,12 @@ def test_external(mocker): ] +def test_external_kwarg(): + app = dash.Dash(__name__, serve_locally=False) + assert not app.scripts.config.serve_locally + assert not app.css.config.serve_locally + + def test_internal(mocker): mocker.patch("dash_core_components._js_dist") mocker.patch("dash_html_components._js_dist")