From 5b4120748afa05d04d0234bf8a509d3b5a10d16e Mon Sep 17 00:00:00 2001 From: RenaudLN Date: Fri, 22 Sep 2023 13:29:24 +0200 Subject: [PATCH 1/7] Routing callback states --- dash/dash.py | 35 ++++++++++++++++++++++++++--------- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/dash/dash.py b/dash/dash.py index 608dac7511..9d56f247bd 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -16,7 +16,7 @@ import base64 import traceback from urllib.parse import urlparse -from typing import Union +from typing import Dict, Optional, Union import flask @@ -29,8 +29,9 @@ from .fingerprint import build_fingerprint, check_fingerprint from .resources import Scripts, Css from .dependencies import ( - Output, Input, + Output, + State, ) from .development.base_component import ComponentRegistry from .exceptions import ( @@ -346,6 +347,17 @@ class Dash: :param hooks: Extend Dash renderer functionality by passing a dictionary of javascript functions. To hook into the layout, use dict keys "layout_pre" and "layout_post". To hook into the callbacks, use keys "request_pre" and "request_post" + + :param routing_callback_states: When using Dash pages (use_pages=True), allows to + add new States to the routing callback, to pass additional data to the layout + functions. The syntax for this parameter is a dict of State objects: + `routing_callback_states={"language": State("language", "value")}` + This allows things like (non-exhaustive list): + * A language dropdown that will be passed to every layout function, + for internationalisation + * Serialising the state in URL hashes without reloading the page on every + input update, and using the hash on first load / refresh + * Passing a global app data store on page load """ _plotlyjs_url: str @@ -383,6 +395,7 @@ def __init__( # pylint: disable=too-many-statements background_callback_manager=None, add_log_handler=True, hooks: Union[RendererHooks, None] = None, + routing_callback_states: Optional[Dict[str, State]] = None, **obsolete, ): _validate.check_obsolete(obsolete) @@ -458,6 +471,7 @@ def __init__( # pylint: disable=too-many-statements self.pages_folder = str(pages_folder) self.use_pages = (pages_folder != "pages") if use_pages is None else use_pages + self.routing_callback_states = routing_callback_states or {} # keep title as a class property for backwards compatibility self.title = title @@ -2078,18 +2092,21 @@ def router(): @self.callback( Output(_ID_CONTENT, "children"), Output(_ID_STORE, "data"), - Input(_ID_LOCATION, "pathname"), - Input(_ID_LOCATION, "search"), + inputs={ + "pathname_": Input(_ID_LOCATION, "pathname"), + "search_": Input(_ID_LOCATION, "search"), + **self.routing_callback_states, + }, prevent_initial_call=True, ) - def update(pathname, search): + def update(pathname_, search_, **states): """ Updates dash.page_container layout on page navigation. Updates the stored page title which will trigger the clientside callback to update the app title """ - query_parameters = _parse_query_string(search) - page, path_variables = _path_to_page(self.strip_relative_path(pathname)) + query_parameters = _parse_query_string(search_) + page, path_variables = _path_to_page(self.strip_relative_path(pathname_)) # get layout if page == {}: @@ -2107,9 +2124,9 @@ def update(pathname, search): if callable(layout): layout = ( - layout(**path_variables, **query_parameters) + layout(**path_variables, **query_parameters, **states) if path_variables - else layout(**query_parameters) + else layout(**query_parameters, **states) ) if callable(title): title = title(**path_variables) if path_variables else title() From b71613d97e7fa80acb88304b9b6e55779395077b Mon Sep 17 00:00:00 2001 From: RenaudLN Date: Fri, 22 Sep 2023 13:53:17 +0200 Subject: [PATCH 2/7] Changelog entry --- CHANGELOG.md | 3 ++- dash/dash.py | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 30bc58eed4..e09d289423 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ This project adheres to [Semantic Versioning](https://semver.org/). ## [UNRELEASED] -## Fixed +## Fixed - [#2634](https://github.com/plotly/dash/pull/2634) Fix deprecation warning on pkg_resources, fix [#2631](https://github.com/plotly/dash/issues/2631) @@ -16,6 +16,7 @@ This project adheres to [Semantic Versioning](https://semver.org/). ## Added - [#2630](https://github.com/plotly/dash/pull/2630) New layout hooks in the renderer +- [#2647](https://github.com/plotly/dash/pull/2647) `routing_callback_states` allowing to pass more State arguments to the pages routing callback ## [2.12.1] - 2023-08-16 diff --git a/dash/dash.py b/dash/dash.py index 9d56f247bd..5bb76bd27c 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -2106,7 +2106,9 @@ def update(pathname_, search_, **states): """ query_parameters = _parse_query_string(search_) - page, path_variables = _path_to_page(self.strip_relative_path(pathname_)) + page, path_variables = _path_to_page( + self.strip_relative_path(pathname_) + ) # get layout if page == {}: From 4b9c9d60283d33174095f24be6d5e970e62f7b01 Mon Sep 17 00:00:00 2001 From: RenaudLN Date: Thu, 28 Sep 2023 08:05:42 +0200 Subject: [PATCH 3/7] allow inputs --- CHANGELOG.md | 2 +- dash/dash.py | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e09d289423..ca5b3b204b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ This project adheres to [Semantic Versioning](https://semver.org/). ## Added - [#2630](https://github.com/plotly/dash/pull/2630) New layout hooks in the renderer -- [#2647](https://github.com/plotly/dash/pull/2647) `routing_callback_states` allowing to pass more State arguments to the pages routing callback +- [#2647](https://github.com/plotly/dash/pull/2647) `routing_callback_inputs` allowing to pass more State arguments to the pages routing callback ## [2.12.1] - 2023-08-16 diff --git a/dash/dash.py b/dash/dash.py index 5bb76bd27c..f55e984772 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -348,10 +348,10 @@ class Dash: javascript functions. To hook into the layout, use dict keys "layout_pre" and "layout_post". To hook into the callbacks, use keys "request_pre" and "request_post" - :param routing_callback_states: When using Dash pages (use_pages=True), allows to + :param routing_callback_inputs: When using Dash pages (use_pages=True), allows to add new States to the routing callback, to pass additional data to the layout functions. The syntax for this parameter is a dict of State objects: - `routing_callback_states={"language": State("language", "value")}` + `routing_callback_inputs={"language": Input("language", "value")}` This allows things like (non-exhaustive list): * A language dropdown that will be passed to every layout function, for internationalisation @@ -395,7 +395,7 @@ def __init__( # pylint: disable=too-many-statements background_callback_manager=None, add_log_handler=True, hooks: Union[RendererHooks, None] = None, - routing_callback_states: Optional[Dict[str, State]] = None, + routing_callback_inputs: Optional[Dict[str, Union[Input, State]]] = None, **obsolete, ): _validate.check_obsolete(obsolete) @@ -471,7 +471,7 @@ def __init__( # pylint: disable=too-many-statements self.pages_folder = str(pages_folder) self.use_pages = (pages_folder != "pages") if use_pages is None else use_pages - self.routing_callback_states = routing_callback_states or {} + self.routing_callback_inputs = routing_callback_inputs or {} # keep title as a class property for backwards compatibility self.title = title @@ -2095,7 +2095,7 @@ def router(): inputs={ "pathname_": Input(_ID_LOCATION, "pathname"), "search_": Input(_ID_LOCATION, "search"), - **self.routing_callback_states, + **self.routing_callback_inputs, }, prevent_initial_call=True, ) From 1faffe3f64cfdb885cafb73c13389808ed46309d Mon Sep 17 00:00:00 2001 From: RenaudLN Date: Thu, 28 Sep 2023 10:27:06 +0200 Subject: [PATCH 4/7] rm docstring too prescriptive examples add importlib-metadata in requirements add integration test --- dash/dash.py | 6 -- requires-all.txt | 1 + .../multi_page/test_pages_layout.py | 65 ++++++++++++++++++- 3 files changed, 65 insertions(+), 7 deletions(-) diff --git a/dash/dash.py b/dash/dash.py index f55e984772..a3b0f74ce5 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -352,12 +352,6 @@ class Dash: add new States to the routing callback, to pass additional data to the layout functions. The syntax for this parameter is a dict of State objects: `routing_callback_inputs={"language": Input("language", "value")}` - This allows things like (non-exhaustive list): - * A language dropdown that will be passed to every layout function, - for internationalisation - * Serialising the state in URL hashes without reloading the page on every - input update, and using the hash on first load / refresh - * Passing a global app data store on page load """ _plotlyjs_url: str diff --git a/requires-all.txt b/requires-all.txt index 59a15e4465..25b7414c8c 100644 --- a/requires-all.txt +++ b/requires-all.txt @@ -9,6 +9,7 @@ flake8==3.9.2 flaky==3.7.0 flask-talisman==0.8.1 isort==4.3.21;python_version<"3.7" +importlib-metadata mimesis mock==4.0.3 numpy diff --git a/tests/integration/multi_page/test_pages_layout.py b/tests/integration/multi_page/test_pages_layout.py index 32573aaf36..6ca0492406 100644 --- a/tests/integration/multi_page/test_pages_layout.py +++ b/tests/integration/multi_page/test_pages_layout.py @@ -1,6 +1,7 @@ import pytest import dash -from dash import Dash, dcc, html +from dash import Dash, Input, State, dcc, html +from dash.dash import _ID_LOCATION from dash.exceptions import NoLayoutException @@ -179,3 +180,65 @@ def test_pala004_no_layout_exception(clear_pages_state): Dash(__name__, use_pages=True, pages_folder="pages_error") assert error_msg in err.value.args[0] + + +def get_routing_inputs_app(): + app = Dash( + __name__, + use_pages=True, + routing_callback_inputs={ + "hash": State(_ID_LOCATION, "hash"), + "language": Input("language", "value"), + }, + ) + # Page with layout from a variable: should render and not be impacted + # by routing callback inputs + dash.register_page( + "home", + layout=html.Div("Home", id="contents"), + path="/", + ) + + # Page with a layout function, should see the routing callback inputs + # as keyword arguments + def layout1(hash: str = None, language: str = "en", **kwargs): + if language == "fr": + return html.Div(f"Le hash dit: {hash}", id="contents") + return html.Div(f"Hash says: {hash}", id="contents") + + dash.register_page( + "function_layout", + path="/function-layout", + layout=layout1, + ) + app.layout = html.Div( + [ + dcc.Dropdown(id="language", options=["en", "fr"], value="en"), + dash.page_container, + ] + ) + return app + + +def test_pala005_routing_inputs(dash_duo, clear_pages_state): + dash_duo.start_server(get_routing_inputs_app()) + dash_duo.wait_for_page(url=f"http://localhost:{dash_duo.server.port}#123") + dash_duo.wait_for_text_to_equal("#contents", "Home") + dash_duo.wait_for_page(url=f"http://localhost:{dash_duo.server.port}/") + dash_duo.wait_for_text_to_equal("#contents", "Home") + dash_duo.wait_for_page( + url=f"http://localhost:{dash_duo.server.port}/function-layout" + ) + dash_duo.wait_for_text_to_equal("#contents", "Hash says:") + # hash is a State therefore navigating to the same page with hash will not + # re-render the layout function + dash_duo.wait_for_page( + url=f"http://localhost:{dash_duo.server.port}/function-layout#123" + ) + dash_duo.wait_for_text_to_equal("#contents", "Hash says:") + # Refreshing the page re-runs the layout function + dash_duo.driver.refresh() + dash_duo.wait_for_text_to_equal("#contents", "Hash says: #123") + # Changing the language Input re-runs the layout function + dash_duo.select_dcc_dropdown("#language", "fr") + dash_duo.wait_for_text_to_equal("#contents", "Le hash dit: #123") From 2d5eed9d039d9f1a5d440f4b2833920941d1eb88 Mon Sep 17 00:00:00 2001 From: RenaudLN Date: Mon, 2 Oct 2023 22:02:47 +0200 Subject: [PATCH 5/7] rm duplication in test --- tests/integration/multi_page/test_pages_layout.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/integration/multi_page/test_pages_layout.py b/tests/integration/multi_page/test_pages_layout.py index 6ca0492406..cb9f89f94b 100644 --- a/tests/integration/multi_page/test_pages_layout.py +++ b/tests/integration/multi_page/test_pages_layout.py @@ -202,9 +202,11 @@ def get_routing_inputs_app(): # Page with a layout function, should see the routing callback inputs # as keyword arguments def layout1(hash: str = None, language: str = "en", **kwargs): - if language == "fr": - return html.Div(f"Le hash dit: {hash}", id="contents") - return html.Div(f"Hash says: {hash}", id="contents") + translations = { + "en": "Hash says: {}", + "fr": "Le hash dit: {}", + } + return html.Div(translations[language].format(hash), id="contents") dash.register_page( "function_layout", From b8b83e9ac0e2b4e743d5524069ff5f462207273d Mon Sep 17 00:00:00 2001 From: RenaudLN Date: Wed, 4 Oct 2023 16:06:30 +0200 Subject: [PATCH 6/7] use dash_duo.server_url --- requires-all.txt | 1 - requires-install.txt | 1 + .../multi_page/test_pages_layout.py | 45 ++++++++----------- 3 files changed, 19 insertions(+), 28 deletions(-) diff --git a/requires-all.txt b/requires-all.txt index 25b7414c8c..59a15e4465 100644 --- a/requires-all.txt +++ b/requires-all.txt @@ -9,7 +9,6 @@ flake8==3.9.2 flaky==3.7.0 flask-talisman==0.8.1 isort==4.3.21;python_version<"3.7" -importlib-metadata mimesis mock==4.0.3 numpy diff --git a/requires-install.txt b/requires-install.txt index 0606e4cfd9..f69a115de3 100644 --- a/requires-install.txt +++ b/requires-install.txt @@ -5,6 +5,7 @@ dash_html_components==2.0.0 dash_core_components==2.0.0 dash_table==5.0.0 importlib-metadata==4.8.3;python_version<"3.7" +importlib-metadata;python_version>="3.7" contextvars==2.4;python_version<"3.7" typing_extensions>=4.1.1 requests diff --git a/tests/integration/multi_page/test_pages_layout.py b/tests/integration/multi_page/test_pages_layout.py index cb9f89f94b..355ec50d40 100644 --- a/tests/integration/multi_page/test_pages_layout.py +++ b/tests/integration/multi_page/test_pages_layout.py @@ -60,38 +60,33 @@ def test_pala001_layout(dash_duo, clear_pages_state): assert dash_duo.driver.title == page["title"], "check that page title updates" # test redirects - dash_duo.wait_for_page(url=f"http://localhost:{dash_duo.server.port}/v2") + dash_duo.wait_for_page(url=f"{dash_duo.server_url}/v2") dash_duo.wait_for_text_to_equal("#text_redirect", "text for redirect") - dash_duo.wait_for_page(url=f"http://localhost:{dash_duo.server.port}/old-home-page") + dash_duo.wait_for_page(url=f"{dash_duo.server_url}/old-home-page") dash_duo.wait_for_text_to_equal("#text_redirect", "text for redirect") - assert ( - dash_duo.driver.current_url - == f"http://localhost:{dash_duo.server.port}/redirect" - ) + assert dash_duo.driver.current_url == f"{dash_duo.server_url}/redirect" # test redirect with button and user defined dcc.Location # note: dcc.Location must be defined in app.py - dash_duo.wait_for_page(url=f"http://localhost:{dash_duo.server.port}/page1") + dash_duo.wait_for_page(url=f"{dash_duo.server_url}/page1") dash_duo.find_element("#btn1").click() dash_duo.wait_for_text_to_equal("#text_page2", "text for page2") # test query strings - dash_duo.wait_for_page( - url=f"http://localhost:{dash_duo.server.port}/query-string?velocity=10" - ) + dash_duo.wait_for_page(url=f"{dash_duo.server_url}/query-string?velocity=10") assert ( dash_duo.find_element("#velocity").get_attribute("value") == "10" ), "query string passed to layout" # test path variables - dash_duo.wait_for_page(url=f"http://localhost:{dash_duo.server.port}/a/none/b/none") + dash_duo.wait_for_page(url=f"{dash_duo.server_url}/a/none/b/none") dash_duo.wait_for_text_to_equal("#path_vars", "variables from pathname:none none") - dash_duo.wait_for_page(url=f"http://localhost:{dash_duo.server.port}/a/var1/b/var2") + dash_duo.wait_for_page(url=f"{dash_duo.server_url}/a/var1/b/var2") dash_duo.wait_for_text_to_equal("#path_vars", "variables from pathname:var1 var2") # test page not found - dash_duo.wait_for_page(url=f"http://localhost:{dash_duo.server.port}/find_me") + dash_duo.wait_for_page(url=f"{dash_duo.server_url}/find_me") dash_duo.wait_for_text_to_equal("#text_not_found_404", "text for not_found_404") # test `validation_layout` exists when suppress_callback_exceptions=False` @@ -122,20 +117,20 @@ def test_pala002_meta_tags_default(dash_duo, clear_pages_state): {"property": "twitter:card", "content": "summary_large_image"}, { "property": "twitter:url", - "content": f"http://localhost:{dash_duo.server.port}/", + "content": f"{dash_duo.server_url}/", }, {"property": "twitter:title", "content": "Multi layout2"}, {"property": "twitter:description", "content": ""}, { "property": "twitter:image", - "content": f"http://localhost:{dash_duo.server.port}/assets/app.jpeg", + "content": f"{dash_duo.server_url}/assets/app.jpeg", }, {"property": "og:title", "content": "Multi layout2"}, {"property": "og:type", "content": "website"}, {"property": "og:description", "content": ""}, { "property": "og:image", - "content": f"http://localhost:{dash_duo.server.port}/assets/app.jpeg", + "content": f"{dash_duo.server_url}/assets/app.jpeg", }, ] @@ -150,7 +145,7 @@ def test_pala003_meta_tags_custom(dash_duo, clear_pages_state): {"property": "twitter:card", "content": "summary_large_image"}, { "property": "twitter:url", - "content": f"http://localhost:{dash_duo.server.port}/", + "content": f"{dash_duo.server_url}/", }, {"property": "twitter:title", "content": "Supplied Title"}, { @@ -159,14 +154,14 @@ def test_pala003_meta_tags_custom(dash_duo, clear_pages_state): }, { "property": "twitter:image", - "content": f"http://localhost:{dash_duo.server.port}/assets/birds.jpeg", + "content": f"{dash_duo.server_url}/assets/birds.jpeg", }, {"property": "og:title", "content": "Supplied Title"}, {"property": "og:type", "content": "website"}, {"property": "og:description", "content": "This is the supplied description"}, { "property": "og:image", - "content": f"http://localhost:{dash_duo.server.port}/assets/birds.jpeg", + "content": f"{dash_duo.server_url}/assets/birds.jpeg", }, ] @@ -224,19 +219,15 @@ def layout1(hash: str = None, language: str = "en", **kwargs): def test_pala005_routing_inputs(dash_duo, clear_pages_state): dash_duo.start_server(get_routing_inputs_app()) - dash_duo.wait_for_page(url=f"http://localhost:{dash_duo.server.port}#123") + dash_duo.wait_for_page(url=f"{dash_duo.server_url}#123") dash_duo.wait_for_text_to_equal("#contents", "Home") - dash_duo.wait_for_page(url=f"http://localhost:{dash_duo.server.port}/") + dash_duo.wait_for_page(url=f"{dash_duo.server_url}/") dash_duo.wait_for_text_to_equal("#contents", "Home") - dash_duo.wait_for_page( - url=f"http://localhost:{dash_duo.server.port}/function-layout" - ) + dash_duo.wait_for_page(url=f"{dash_duo.server_url}/function-layout") dash_duo.wait_for_text_to_equal("#contents", "Hash says:") # hash is a State therefore navigating to the same page with hash will not # re-render the layout function - dash_duo.wait_for_page( - url=f"http://localhost:{dash_duo.server.port}/function-layout#123" - ) + dash_duo.wait_for_page(url=f"{dash_duo.server_url}/function-layout#123") dash_duo.wait_for_text_to_equal("#contents", "Hash says:") # Refreshing the page re-runs the layout function dash_duo.driver.refresh() From b9ecd8691919d7b9afc572b99c18221df429d9f4 Mon Sep 17 00:00:00 2001 From: RenaudLN Date: Wed, 4 Oct 2023 16:19:04 +0200 Subject: [PATCH 7/7] updated changelog and docstring --- CHANGELOG.md | 2 +- dash/dash.py | 13 ++++++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ca5b3b204b..98009191d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ This project adheres to [Semantic Versioning](https://semver.org/). ## Added - [#2630](https://github.com/plotly/dash/pull/2630) New layout hooks in the renderer -- [#2647](https://github.com/plotly/dash/pull/2647) `routing_callback_inputs` allowing to pass more State arguments to the pages routing callback +- [#2647](https://github.com/plotly/dash/pull/2647) `routing_callback_inputs` allowing to pass more Input and/or State arguments to the pages routing callback ## [2.12.1] - 2023-08-16 diff --git a/dash/dash.py b/dash/dash.py index a3b0f74ce5..b463aa09b6 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -352,6 +352,7 @@ class Dash: add new States to the routing callback, to pass additional data to the layout functions. The syntax for this parameter is a dict of State objects: `routing_callback_inputs={"language": Input("language", "value")}` + NOTE: the keys "pathname_" and "search_" are reserved for internal use. """ _plotlyjs_url: str @@ -2083,14 +2084,16 @@ def router(): return self._got_first_request["pages"] = True + inputs = { + "pathname_": Input(_ID_LOCATION, "pathname"), + "search_": Input(_ID_LOCATION, "search"), + } + inputs.update(self.routing_callback_inputs) + @self.callback( Output(_ID_CONTENT, "children"), Output(_ID_STORE, "data"), - inputs={ - "pathname_": Input(_ID_LOCATION, "pathname"), - "search_": Input(_ID_LOCATION, "search"), - **self.routing_callback_inputs, - }, + inputs=inputs, prevent_initial_call=True, ) def update(pathname_, search_, **states):