From 7f164fda23941dd904e0c015909f0e46a2e00440 Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Wed, 15 Apr 2020 17:14:33 -0400 Subject: [PATCH 1/5] remove unused _cached_layout --- dash/dash.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/dash/dash.py b/dash/dash.py index c9489fc85c..113831db1e 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -331,7 +331,7 @@ def __init__( self.routes = [] self._layout = None - self._cached_layout = None + self._layout_is_function = False self._setup_dev_tools() self._hot_reload = AttributeDict( @@ -421,16 +421,12 @@ def layout(self): return self._layout def _layout_value(self): - if isinstance(self._layout, patch_collections_abc("Callable")): - self._cached_layout = self._layout() - else: - self._cached_layout = self._layout - return self._cached_layout + return self._layout() if self._layout_is_function else self._layout @layout.setter def layout(self, value): _validate.validate_layout_type(value) - self._cached_layout = None + self._layout_is_function = isinstance(value, patch_collections_abc("Callable")) self._layout = value @property From 30ff9159e52131e3831b90c62befcdd062a0500a Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Fri, 17 Apr 2020 16:31:46 -0400 Subject: [PATCH 2/5] restore flask.has_request_context layout validation, and add app.validation_layout --- dash-renderer/src/actions/dependencies.js | 14 +- dash/dash.py | 35 ++++- dash/development/base_component.py | 11 +- .../devtools/test_callback_validation.py | 128 ++++++++++++++++++ 4 files changed, 181 insertions(+), 7 deletions(-) diff --git a/dash-renderer/src/actions/dependencies.js b/dash-renderer/src/actions/dependencies.js index 02d046c2a8..8ae7a7faff 100644 --- a/dash-renderer/src/actions/dependencies.js +++ b/dash-renderer/src/actions/dependencies.js @@ -35,7 +35,7 @@ import { const mergeMax = mergeWith(Math.max); -import {getPath} from './paths'; +import {computePaths, getPath} from './paths'; import {crawlLayout} from './utils'; @@ -464,9 +464,17 @@ function wildcardOverlap({id, property}, objs) { } export function validateCallbacksToLayout(state_, dispatchError) { - const {config, graphs, layout, paths} = state_; - const {outputMap, inputMap, outputPatterns, inputPatterns} = graphs; + const {config, graphs, layout: layout_, paths: paths_} = state_; const validateIds = !config.suppress_callback_exceptions; + let layout, paths; + if (validateIds && config.validation_layout) { + layout = config.validation_layout; + paths = computePaths(layout, [], null, paths_.events); + } else { + layout = layout_; + paths = paths_; + } + const {outputMap, inputMap, outputPatterns, inputPatterns} = graphs; function tail(callbacks) { return ( diff --git a/dash/dash.py b/dash/dash.py index 113831db1e..705ec28ad9 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -332,6 +332,7 @@ def __init__( self._layout = None self._layout_is_function = False + self.validation_layout = None self._setup_dev_tools() self._hot_reload = AttributeDict( @@ -429,6 +430,35 @@ def layout(self, value): self._layout_is_function = isinstance(value, patch_collections_abc("Callable")) self._layout = value + # for using flask.has_request_context() to deliver a full layout for + # validation inside a layout function - track if a user might be doing this. + if self._layout_is_function and not self.validation_layout: + + def simple_clone(c, children=None): + cls = type(c) + # in Py3 we can use the __init__ signature to reduce to just + # required args and id; in Py2 this doesn't work so we just + # empty out children. + sig = getattr(cls.__init__, "__signature__", None) + props = { + p: getattr(c, p) + for p in c._prop_names # pylint: disable=protected-access + if hasattr(c, p) + and ( + p == "id" or not sig or sig.parameters[p].default == c.REQUIRED + ) + } + if props.get("children", children): + props["children"] = children or [] + return cls(**props) + + layout_value = self._layout_value() + _validate.validate_layout(value, layout_value) + self.validation_layout = simple_clone( + # pylint: disable=protected-access + layout_value, [simple_clone(c) for c in layout_value._traverse_ids()] + ) + @property def index_string(self): return self._index_string @@ -464,6 +494,9 @@ def _config(self): "interval": int(self._dev_tools.hot_reload_interval * 1000), "max_retry": self._dev_tools.hot_reload_max_retry, } + if self.validation_layout: + config["validation_layout"] = self.validation_layout + return config def serve_reload_hash(self): @@ -598,7 +631,7 @@ def _generate_scripts_html(self): def _generate_config_html(self): return ''.format( - json.dumps(self._config()) + json.dumps(self._config(), cls=plotly.utils.PlotlyJSONEncoder) ) def _generate_renderer(self): diff --git a/dash/development/base_component.py b/dash/development/base_component.py index b68b359941..fbf73eb90c 100644 --- a/dash/development/base_component.py +++ b/dash/development/base_component.py @@ -293,11 +293,16 @@ def _traverse_with_paths(self): for p, t in i._traverse_with_paths(): yield "\n".join([list_path, p]), t - def __iter__(self): - """Yield IDs in the tree of children.""" + def _traverse_ids(self): + """Yield components with IDs in the tree of children.""" for t in self._traverse(): if isinstance(t, Component) and getattr(t, "id", None) is not None: - yield t.id + yield t + + def __iter__(self): + """Yield IDs in the tree of children.""" + for t in self._traverse_ids(): + yield t.id def __len__(self): """Return the number of items in the tree.""" diff --git a/tests/integration/devtools/test_callback_validation.py b/tests/integration/devtools/test_callback_validation.py index 08190c6dad..bbb59e2925 100644 --- a/tests/integration/devtools/test_callback_validation.py +++ b/tests/integration/devtools/test_callback_validation.py @@ -1,3 +1,6 @@ +import flask +import pytest + import dash_core_components as dcc import dash_html_components as html from dash import Dash @@ -695,3 +698,128 @@ def c3(children): ] ] check_errors(dash_duo, specs) + + +def multipage_app(validation=False): + app = Dash(__name__, suppress_callback_exceptions=(validation == "suppress")) + + skeleton = html.Div( + [dcc.Location(id="url", refresh=False), html.Div(id="page-content")] + ) + + layout_index = html.Div( + [ + dcc.Link('Navigate to "/page-1"', id="index_p1", href="/page-1"), + dcc.Link('Navigate to "/page-2"', id="index_p2", href="/page-2"), + ] + ) + + layout_page_1 = html.Div( + [ + html.H2("Page 1"), + dcc.Input(id="input-1-state", type="text", value="Montreal"), + dcc.Input(id="input-2-state", type="text", value="Canada"), + html.Button(id="submit-button", n_clicks=0, children="Submit"), + html.Div(id="output-state"), + html.Br(), + dcc.Link('Navigate to "/"', id="p1_index", href="/"), + dcc.Link('Navigate to "/page-2"', id="p1_p2", href="/page-2"), + ] + ) + + layout_page_2 = html.Div( + [ + html.H2("Page 2"), + dcc.Dropdown( + id="page-2-dropdown", + options=[{"label": i, "value": i} for i in ["LA", "NYC", "MTL"]], + value="LA", + ), + html.Div(id="page-2-display-value"), + html.Br(), + dcc.Link('Navigate to "/"', id="p2_index", href="/"), + dcc.Link('Navigate to "/page-1"', id="p2_p1", href="/page-1"), + ] + ) + + validation_layout = html.Div([skeleton, layout_index, layout_page_1, layout_page_2]) + + def validation_function(): + return skeleton if flask.has_request_context() else validation_layout + + app.layout = validation_function if validation == "function" else skeleton + if validation == "attribute": + app.validation_layout = validation_layout + + # Index callbacks + @app.callback(Output("page-content", "children"), [Input("url", "pathname")]) + def display_page(pathname): + if pathname == "/page-1": + return layout_page_1 + elif pathname == "/page-2": + return layout_page_2 + else: + return layout_index + + # Page 1 callbacks + @app.callback( + Output("output-state", "children"), + [Input("submit-button", "n_clicks")], + [State("input-1-state", "value"), State("input-2-state", "value")], + ) + def update_output(n_clicks, input1, input2): + return ( + "The Button has been pressed {} times," + 'Input 1 is "{}",' + 'and Input 2 is "{}"' + ).format(n_clicks, input1, input2) + + # Page 2 callbacks + @app.callback( + Output("page-2-display-value", "children"), [Input("page-2-dropdown", "value")] + ) + def display_value(value): + print("display_value") + return 'You have selected "{}"'.format(value) + + return app + + +def test_dvcv014_multipage_errors(dash_duo): + app = multipage_app() + dash_duo.start_server(app, **debugging) + + specs = [ + [ + "ID not found in layout", + ['"page-2-dropdown"', "page-2-display-value.children"], + ], + ["ID not found in layout", ['"submit-button"', "output-state.children"]], + [ + "ID not found in layout", + ['"page-2-display-value"', "page-2-display-value.children"], + ], + ["ID not found in layout", ['"output-state"', "output-state.children"]], + ] + check_errors(dash_duo, specs) + + +@pytest.mark.parametrize("validation", ("function", "attribute", "suppress")) +def test_dvcv015_multipage_validation_layout(validation, dash_duo): + app = multipage_app(validation) + dash_duo.start_server(app, **debugging) + + dash_duo.wait_for_text_to_equal("#index_p1", 'Navigate to "/page-1"') + dash_duo.find_element("#index_p1").click() + + dash_duo.find_element("#submit-button").click() + dash_duo.wait_for_text_to_equal( + "#output-state", + "The Button has been pressed 1 times," + 'Input 1 is "Montreal",and Input 2 is "Canada"', + ) + + dash_duo.find_element("#p1_p2").click() + dash_duo.wait_for_text_to_equal("#page-2-display-value", 'You have selected "LA"') + + assert not dash_duo.get_logs() From ce6a82c77dca320add5cc5b070e475f5c5aca51b Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Fri, 17 Apr 2020 16:53:00 -0400 Subject: [PATCH 3/5] changelog for validation_layout --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a5a525b7c..86e4be2efa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes to `dash` will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/). +## [UNRELEASED] +### Added +- [#1201](https://github.com/plotly/dash/pull/1201) New attribute `app.validation_layout` allows you to create a multi-page app without `suppress_callback_exceptions=True` or layout function tricks. Set this to a component layout containing the superset of all IDs on all pages in your app. + +### Fixed +- [#1201](https://github.com/plotly/dash/pull/1201) Fixes [#1193](https://github.com/plotly/dash/issues/1193) - prior to Dash 1.11, you could use `flask.has_request_context() == False` inside an `app.layout` function to provide a special layout containing all IDs for validation purposes in a multi-page app. Dash 1.11 broke this when we moved most of this validation into the renderer. This change makes it work again. + ## [1.11.0] - 2020-04-10 ### Added - [#1103](https://github.com/plotly/dash/pull/1103) Pattern-matching IDs and callbacks. Component IDs can be dictionaries, and callbacks can reference patterns of components, using three different wildcards: `ALL`, `MATCH`, and `ALLSMALLER`, available from `dash.dependencies`. This lets you create components on demand, and have callbacks respond to any and all of them. To help with this, `dash.callback_context` gets three new entries: `outputs_list`, `inputs_list`, and `states_list`, which contain all the ids, properties, and except for the outputs, the property values from all matched components. From e6547a5c93a9046f8b8b8147251845e37926e946 Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Fri, 17 Apr 2020 16:56:58 -0400 Subject: [PATCH 4/5] switch validation_layout test to use input instead of dropdown looks like dropdown is generating warnings from React ATM --- tests/integration/devtools/test_callback_validation.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/tests/integration/devtools/test_callback_validation.py b/tests/integration/devtools/test_callback_validation.py index bbb59e2925..423b534ed8 100644 --- a/tests/integration/devtools/test_callback_validation.py +++ b/tests/integration/devtools/test_callback_validation.py @@ -730,11 +730,7 @@ def multipage_app(validation=False): layout_page_2 = html.Div( [ html.H2("Page 2"), - dcc.Dropdown( - id="page-2-dropdown", - options=[{"label": i, "value": i} for i in ["LA", "NYC", "MTL"]], - value="LA", - ), + dcc.Input(id="page-2-input", value="LA"), html.Div(id="page-2-display-value"), html.Br(), dcc.Link('Navigate to "/"', id="p2_index", href="/"), @@ -776,7 +772,7 @@ def update_output(n_clicks, input1, input2): # Page 2 callbacks @app.callback( - Output("page-2-display-value", "children"), [Input("page-2-dropdown", "value")] + Output("page-2-display-value", "children"), [Input("page-2-input", "value")] ) def display_value(value): print("display_value") @@ -792,7 +788,7 @@ def test_dvcv014_multipage_errors(dash_duo): specs = [ [ "ID not found in layout", - ['"page-2-dropdown"', "page-2-display-value.children"], + ['"page-2-input"', "page-2-display-value.children"], ], ["ID not found in layout", ['"submit-button"', "output-state.children"]], [ From 241c047a55556082a46b6d3faf080cdb0b6fc0a4 Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Tue, 21 Apr 2020 22:35:09 -0400 Subject: [PATCH 5/5] skip validation_layout if we're suppressing callback exceptions --- dash/dash.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/dash/dash.py b/dash/dash.py index 705ec28ad9..a31fbc22c2 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -432,7 +432,11 @@ def layout(self, value): # for using flask.has_request_context() to deliver a full layout for # validation inside a layout function - track if a user might be doing this. - if self._layout_is_function and not self.validation_layout: + if ( + self._layout_is_function + and not self.validation_layout + and not self.config.suppress_callback_exceptions + ): def simple_clone(c, children=None): cls = type(c) @@ -456,7 +460,8 @@ def simple_clone(c, children=None): _validate.validate_layout(value, layout_value) self.validation_layout = simple_clone( # pylint: disable=protected-access - layout_value, [simple_clone(c) for c in layout_value._traverse_ids()] + layout_value, + [simple_clone(c) for c in layout_value._traverse_ids()], ) @property @@ -494,7 +499,7 @@ def _config(self): "interval": int(self._dev_tools.hot_reload_interval * 1000), "max_retry": self._dev_tools.hot_reload_max_retry, } - if self.validation_layout: + if self.validation_layout and not self.config.suppress_callback_exceptions: config["validation_layout"] = self.validation_layout return config