From 216dcf8acb063d091b5c2020ebdb63d2898689c0 Mon Sep 17 00:00:00 2001 From: philippe Date: Thu, 20 Feb 2025 10:03:30 -0500 Subject: [PATCH 1/9] Expose stringifyId --- dash/dash-renderer/src/dashApi.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/dash/dash-renderer/src/dashApi.ts b/dash/dash-renderer/src/dashApi.ts index a0a914c5e4..75365f731c 100644 --- a/dash/dash-renderer/src/dashApi.ts +++ b/dash/dash-renderer/src/dashApi.ts @@ -3,6 +3,7 @@ import {DashContext, useDashContext} from './wrapper/DashContext'; import {getPath} from './actions/paths'; import {getStores} from './utils/stores'; import ExternalWrapper from './wrapper/ExternalWrapper'; +import {stringifyId} from './actions/dependencies'; /** * Get the dash props from a component path or id. @@ -32,5 +33,6 @@ function getLayout(componentPathOrId: string[] | string): any { ExternalWrapper, DashContext, useDashContext, - getLayout + getLayout, + stringifyId }; From e44f228a201b6bfd8c77297e7437ae23517cbcfd Mon Sep 17 00:00:00 2001 From: philippe Date: Thu, 20 Feb 2025 16:21:35 -0500 Subject: [PATCH 2/9] Improved error for removed attributes --- dash/_obsolete.py | 23 +++++++++++++++++++++++ dash/dash.py | 3 ++- dash/exceptions.py | 4 ++++ 3 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 dash/_obsolete.py diff --git a/dash/_obsolete.py b/dash/_obsolete.py new file mode 100644 index 0000000000..c2b682f8c9 --- /dev/null +++ b/dash/_obsolete.py @@ -0,0 +1,23 @@ +# pylint: disable=too-few-public-methods +from .exceptions import ObsoleteAttributeException + + +class ObsoleteAttribute: + def __init__(self, message: str, exc=ObsoleteAttributeException): + self.message = message + self.exc = exc + + +class ObsoleteChecker: + _obsolete_attributes = { + "run_server": ObsoleteAttribute("app.run_server has been replaced by app.run"), + "long_callback": ObsoleteAttribute( + "app.long_callback has been removed, use app.callback(..., background=True) instead" + ), + } + + def __getattr__(self, name: str): + if name in self._obsolete_attributes: + err = self._obsolete_attributes[name] + raise err.exc(err.message) + return getattr(self, name) diff --git a/dash/dash.py b/dash/dash.py index efdf7a2727..0056b5f455 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -66,6 +66,7 @@ from . import _get_app from ._grouping import map_grouping, grouping_len, update_args_group +from ._obsolete import ObsoleteChecker from . import _pages from ._pages import ( @@ -194,7 +195,7 @@ def _do_skip(error): # pylint: disable=too-many-instance-attributes # pylint: disable=too-many-arguments, too-many-locals -class Dash: +class Dash(ObsoleteChecker): """Dash is a framework for building analytical web applications. No JavaScript required. diff --git a/dash/exceptions.py b/dash/exceptions.py index a971d2e5a3..00bd2c1553 100644 --- a/dash/exceptions.py +++ b/dash/exceptions.py @@ -10,6 +10,10 @@ class ObsoleteKwargException(DashException): pass +class ObsoleteAttributeException(DashException): + pass + + class NoLayoutException(DashException): pass From 1cd9c05795c3606952c42869b97c9148e0946522 Mon Sep 17 00:00:00 2001 From: philippe Date: Thu, 20 Feb 2025 16:23:24 -0500 Subject: [PATCH 3/9] Fix ExternalWrapper children render --- .../src/components/ExternalComponent.js | 15 ++++++++++++++- .../src/wrapper/ExternalWrapper.tsx | 10 +++++++--- .../renderer/test_external_component.py | 16 +++++++++++++++- 3 files changed, 36 insertions(+), 5 deletions(-) diff --git a/@plotly/dash-test-components/src/components/ExternalComponent.js b/@plotly/dash-test-components/src/components/ExternalComponent.js index bb3369d87e..d82fbd93a0 100644 --- a/@plotly/dash-test-components/src/components/ExternalComponent.js +++ b/@plotly/dash-test-components/src/components/ExternalComponent.js @@ -2,7 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; -const ExternalComponent = ({ id, text, input_id }) => { +const ExternalComponent = ({ id, text, input_id, extra_component }) => { const ctx = window.dash_component_api.useDashContext(); const ExternalWrapper = window.dash_component_api.ExternalWrapper; @@ -15,6 +15,14 @@ const ExternalComponent = ({ id, text, input_id }) => { value={text} componentPath={[...ctx.componentPath, 'external']} /> + { + extra_component && + } ) } @@ -23,6 +31,11 @@ ExternalComponent.propTypes = { id: PropTypes.string, text: PropTypes.string, input_id: PropTypes.string, + extra_component: PropTypes.exact({ + type: PropTypes.string, + namespace: PropTypes.string, + props: PropTypes.object, + }), }; export default ExternalComponent; diff --git a/dash/dash-renderer/src/wrapper/ExternalWrapper.tsx b/dash/dash-renderer/src/wrapper/ExternalWrapper.tsx index 025bb2ea55..1198cd875b 100644 --- a/dash/dash-renderer/src/wrapper/ExternalWrapper.tsx +++ b/dash/dash-renderer/src/wrapper/ExternalWrapper.tsx @@ -3,7 +3,7 @@ import {useDispatch} from 'react-redux'; import {DashLayoutPath} from '../types/component'; import DashWrapper from './DashWrapper'; -import {insertComponent, removeComponent} from '../actions'; +import {insertComponent, removeComponent, updateProps} from '../actions'; type Props = { componentPath: DashLayoutPath; @@ -32,7 +32,7 @@ function ExternalWrapper({ component: { type: componentType, namespace: componentNamespace, - props: {} + props: props }, componentPath }) @@ -43,10 +43,14 @@ function ExternalWrapper({ }; }, []); + useEffect(() => { + dispatch(updateProps({itempath: componentPath, props})); + }, [props]); + if (!inserted) { return null; } // Render a wrapper with the actual props. - return ; + return ; } export default ExternalWrapper; diff --git a/tests/integration/renderer/test_external_component.py b/tests/integration/renderer/test_external_component.py index db095214a2..4876afa729 100644 --- a/tests/integration/renderer/test_external_component.py +++ b/tests/integration/renderer/test_external_component.py @@ -8,7 +8,19 @@ def test_rext001_render_external_component(dash_duo): [ dcc.Input(id="sync", value="synced"), html.Button("sync", id="sync-btn"), - ExternalComponent("ext", input_id="external", text="external"), + ExternalComponent( + id="ext", + input_id="external", + text="external", + extra_component={ + "type": "Div", + "namespace": "dash_html_components", + "props": { + "id": "extra", + "children": [html.Div("extra children", id="extra-children")], + }, + }, + ), ] ) @@ -25,3 +37,5 @@ def on_sync(_, value): dash_duo.wait_for_text_to_equal("#external", "external") dash_duo.find_element("#sync-btn").click() dash_duo.wait_for_text_to_equal("#external", "synced") + + dash_duo.wait_for_text_to_equal("#extra", "extra children") From f397f8a1997f1db8f61ef68cdddf96b8a8259a96 Mon Sep 17 00:00:00 2001 From: philippe Date: Fri, 21 Feb 2025 10:24:37 -0500 Subject: [PATCH 4/9] Fix ExternalWrapper pattern matching ids. --- dash/dash-renderer/src/actions/index.js | 10 +++++++++- .../src/wrapper/ExternalWrapper.tsx | 18 +++++++++++++----- .../renderer/test_external_component.py | 18 ++++++++++++++++-- 3 files changed, 38 insertions(+), 8 deletions(-) diff --git a/dash/dash-renderer/src/actions/index.js b/dash/dash-renderer/src/actions/index.js index e46644ff9d..86649c2d7d 100644 --- a/dash/dash-renderer/src/actions/index.js +++ b/dash/dash-renderer/src/actions/index.js @@ -6,7 +6,7 @@ import {getAction} from './constants'; import cookie from 'cookie'; import {validateCallbacksToLayout} from './dependencies'; import {includeObservers, getLayoutCallbacks} from './dependencies_ts'; -import {getPath} from './paths'; +import {computePaths, getPath} from './paths'; export const onError = createAction(getAction('ON_ERROR')); export const setAppLifecycle = createAction(getAction('SET_APP_LIFECYCLE')); @@ -21,6 +21,14 @@ export const updateProps = createAction(getAction('ON_PROP_CHANGE')); export const insertComponent = createAction(getAction('INSERT_COMPONENT')); export const removeComponent = createAction(getAction('REMOVE_COMPONENT')); +export const addComponentToLayout = payload => (dispatch, getState) => { + const {paths} = getState(); + dispatch(insertComponent(payload)); + dispatch( + setPaths(computePaths(payload.component, payload.componentPath, paths)) + ); +}; + export const dispatchError = dispatch => (message, lines) => dispatch( onError({ diff --git a/dash/dash-renderer/src/wrapper/ExternalWrapper.tsx b/dash/dash-renderer/src/wrapper/ExternalWrapper.tsx index 1198cd875b..79bcbb5162 100644 --- a/dash/dash-renderer/src/wrapper/ExternalWrapper.tsx +++ b/dash/dash-renderer/src/wrapper/ExternalWrapper.tsx @@ -1,9 +1,14 @@ import React, {useState, useEffect} from 'react'; -import {useDispatch} from 'react-redux'; +import {batch, useDispatch} from 'react-redux'; import {DashLayoutPath} from '../types/component'; import DashWrapper from './DashWrapper'; -import {insertComponent, removeComponent, updateProps} from '../actions'; +import { + addComponentToLayout, + notifyObservers, + removeComponent, + updateProps +} from '../actions'; type Props = { componentPath: DashLayoutPath; @@ -21,14 +26,14 @@ function ExternalWrapper({ componentPath, ...props }: Props) { - const dispatch = useDispatch(); + const dispatch: any = useDispatch(); const [inserted, setInserted] = useState(false); useEffect(() => { // Give empty props for the inserted components. // The props will come from the parent so they can be updated. dispatch( - insertComponent({ + addComponentToLayout({ component: { type: componentType, namespace: componentNamespace, @@ -44,7 +49,10 @@ function ExternalWrapper({ }, []); useEffect(() => { - dispatch(updateProps({itempath: componentPath, props})); + batch(() => { + dispatch(updateProps({itempath: componentPath, props})); + dispatch(notifyObservers({id: props.id, props})); + }); }, [props]); if (!inserted) { diff --git a/tests/integration/renderer/test_external_component.py b/tests/integration/renderer/test_external_component.py index 4876afa729..72657125f5 100644 --- a/tests/integration/renderer/test_external_component.py +++ b/tests/integration/renderer/test_external_component.py @@ -1,4 +1,4 @@ -from dash import Dash, html, dcc, html, Input, Output, State +from dash import Dash, html, dcc, html, Input, Output, State, MATCH from dash_test_components import ExternalComponent @@ -17,10 +17,13 @@ def test_rext001_render_external_component(dash_duo): "namespace": "dash_html_components", "props": { "id": "extra", - "children": [html.Div("extra children", id="extra-children")], + "children": [ + html.Div("extra children", id={"type": "extra", "index": 1}) + ], }, }, ), + html.Div(html.Div(id={"type": "output", "index": 1}), id="out"), ] ) @@ -33,9 +36,20 @@ def test_rext001_render_external_component(dash_duo): def on_sync(_, value): return value + @app.callback( + Output({"type": "output", "index": MATCH}, "children"), + Input({"type": "extra", "index": MATCH}, "n_clicks"), + prevent_initial_call=True, + ) + def click(*_): + return "clicked" + dash_duo.start_server(app) dash_duo.wait_for_text_to_equal("#external", "external") dash_duo.find_element("#sync-btn").click() dash_duo.wait_for_text_to_equal("#external", "synced") dash_duo.wait_for_text_to_equal("#extra", "extra children") + + dash_duo.find_element("#extra > div").click() + dash_duo.wait_for_text_to_equal("#out", "clicked") From 61ff4f6a101d4d284efc7bad998762b623b8b69b Mon Sep 17 00:00:00 2001 From: philippe Date: Fri, 21 Feb 2025 10:53:40 -0500 Subject: [PATCH 5/9] build From 707399bdc71666d8333e52c79a3db9279a92da83 Mon Sep 17 00:00:00 2001 From: philippe Date: Fri, 21 Feb 2025 11:43:05 -0500 Subject: [PATCH 6/9] Add custom_data hook --- dash/_callback_context.py | 5 +++++ dash/_hooks.py | 14 ++++++++++++++ dash/dash.py | 4 ++++ tests/integration/test_hooks.py | 24 +++++++++++++++++++++++- 4 files changed, 46 insertions(+), 1 deletion(-) diff --git a/dash/_callback_context.py b/dash/_callback_context.py index 42e4c506d9..17347d5245 100644 --- a/dash/_callback_context.py +++ b/dash/_callback_context.py @@ -304,6 +304,11 @@ def origin(self): """ return _get_from_context("origin", "") + @property + @has_context + def custom_data(self): + return _get_from_context("custom_data", {}) + callback_context = CallbackContext() diff --git a/dash/_hooks.py b/dash/_hooks.py index 7c4b2389f0..cfa3c498fe 100644 --- a/dash/_hooks.py +++ b/dash/_hooks.py @@ -45,6 +45,7 @@ def __init__(self) -> None: "error": [], "callback": [], "index": [], + "custom_data": [], } self._js_dist = [] self._css_dist = [] @@ -191,6 +192,19 @@ def wrap(func): return wrap + def custom_data(self, namespace, priority: _t.Optional[int] = None, final=False): + def wrap(func): + self.add_hook( + "custom_data", + func, + priority=priority, + final=final, + data={"namespace": namespace}, + ) + return func + + return wrap + hooks = _Hooks() diff --git a/dash/dash.py b/dash/dash.py index ff00b913b0..d890bb49c6 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -1389,6 +1389,10 @@ def dispatch(self): g.path = flask.request.full_path g.remote = flask.request.remote_addr g.origin = flask.request.origin + g.custom_data = AttributeDict({}) + + for hook in self._hooks.get_hooks("custom_data"): + g.custom_data[hook.data["namespace"]] = hook(g) except KeyError as missing_callback_function: msg = f"Callback function not found for output '{output}', perhaps you forgot to prepend the '@'?" diff --git a/tests/integration/test_hooks.py b/tests/integration/test_hooks.py index ca4143eadb..cbb1f44551 100644 --- a/tests/integration/test_hooks.py +++ b/tests/integration/test_hooks.py @@ -2,7 +2,7 @@ import requests import pytest -from dash import Dash, Input, Output, html, hooks, set_props +from dash import Dash, Input, Output, html, hooks, set_props, ctx @pytest.fixture @@ -14,6 +14,7 @@ def hook_cleanup(): hooks._ns["error"] = [] hooks._ns["callback"] = [] hooks._ns["index"] = [] + hooks._ns["custom_data"] = [] hooks._css_dist = [] hooks._js_dist = [] hooks._finals = {} @@ -188,3 +189,24 @@ def test_hook009_hook_clientside_callback(hook_cleanup, dash_duo): dash_duo.wait_for_element("#hook-start").click() dash_duo.wait_for_text_to_equal("#hook-output", "Called 1") + + +def test_hook010_hook_custom_data(hook_cleanup, dash_duo): + @hooks.custom_data("custom") + def custom_data(_): + return "custom-data" + + app = Dash() + app.layout = [html.Button("insert", id="btn"), html.Div(id="output")] + + @app.callback( + Output("output", "children"), + Input("btn", "n_clicks"), + prevent_initial_call=True, + ) + def cb(_): + return ctx.custom_data.custom + + dash_duo.start_server(app) + dash_duo.wait_for_element("#btn").click() + dash_duo.wait_for_text_to_equal("#output", "custom-data") From b415d22ad05ef8d3c5c064575bb89d68537fbcc6 Mon Sep 17 00:00:00 2001 From: philippe Date: Fri, 21 Feb 2025 11:54:02 -0500 Subject: [PATCH 7/9] Add docstring to custom_data hook. --- dash/_callback_context.py | 3 +++ dash/_hooks.py | 13 +++++++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/dash/_callback_context.py b/dash/_callback_context.py index 17347d5245..55d3cf0a49 100644 --- a/dash/_callback_context.py +++ b/dash/_callback_context.py @@ -307,6 +307,9 @@ def origin(self): @property @has_context def custom_data(self): + """ + Custom data set by hooks.custom_data. + """ return _get_from_context("custom_data", {}) diff --git a/dash/_hooks.py b/dash/_hooks.py index cfa3c498fe..1790aa3e3d 100644 --- a/dash/_hooks.py +++ b/dash/_hooks.py @@ -192,8 +192,17 @@ def wrap(func): return wrap - def custom_data(self, namespace, priority: _t.Optional[int] = None, final=False): - def wrap(func): + def custom_data( + self, namespace: str, priority: _t.Optional[int] = None, final=False + ): + """ + Add data to the callback_context.custom_data property under the namespace. + + The hook function takes the current context_value and before the ctx is set + and has access to the flask request context. + """ + + def wrap(func: _t.Callable[[_t.Dict], _t.Any]): self.add_hook( "custom_data", func, From 901f03a00d8112872f8b217bd724528ca40db1a7 Mon Sep 17 00:00:00 2001 From: philippe Date: Fri, 21 Feb 2025 12:23:53 -0500 Subject: [PATCH 8/9] Update changelog --- CHANGELOG.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 45914dda5e..839cb2e4d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ All notable changes to `dash` will be documented in this file. This project adheres to [Semantic Versioning](https://semver.org/). +## [3.0.0-rc3] - 2025-02-21 + +## Added + +- [#3121](https://github.com/plotly/dash/pull/3121) Restyle and add version checker to dev tools. +- [#3175](https://github.com/plotly/dash/pull/3175) Add `custom_data` hook. +- [#3175](https://github.com/plotly/dash/pull/3175) Improved error for removed Dash app attribute, run_server and long_callback +- [#3175](https://github.com/plotly/dash/pull/3175) Expose `stringifyId` in `window.dash_component_api`. + +## Fixed + +- [#3175](https://github.com/plotly/dash/pull/3175) Fix `ExternalWrapper` rendering children and support pattern matching ids. + ## [3.0.0-rc2] - 2025-02-18 ## Added From 4336b34de90763777e480b3624a48398690cfcbb Mon Sep 17 00:00:00 2001 From: philippe Date: Fri, 21 Feb 2025 12:44:21 -0500 Subject: [PATCH 9/9] Version 3.0.0rc3 --- dash/_dash_renderer.py | 4 ++-- dash/dash-renderer/package-lock.json | 4 ++-- dash/dash-renderer/package.json | 4 ++-- dash/version.py | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/dash/_dash_renderer.py b/dash/_dash_renderer.py index 41d4e6452f..36588b30b5 100644 --- a/dash/_dash_renderer.py +++ b/dash/_dash_renderer.py @@ -1,6 +1,6 @@ import os -__version__ = "2.0.1" +__version__ = "2.0.2" _available_react_versions = {"18.3.1", "18.2.0", "16.14.0"} _available_reactdom_versions = {"18.3.1", "18.2.0", "16.14.0"} @@ -64,7 +64,7 @@ def _set_react_version(v_react, v_reactdom=None): { "relative_package_path": "dash-renderer/build/dash_renderer.min.js", "dev_package_path": "dash-renderer/build/dash_renderer.dev.js", - "external_url": "https://unpkg.com/dash-renderer@2.0.1" + "external_url": "https://unpkg.com/dash-renderer@2.0.2" "/build/dash_renderer.min.js", "namespace": "dash", }, diff --git a/dash/dash-renderer/package-lock.json b/dash/dash-renderer/package-lock.json index ed4d4819c4..a4821a1f19 100644 --- a/dash/dash-renderer/package-lock.json +++ b/dash/dash-renderer/package-lock.json @@ -1,12 +1,12 @@ { "name": "dash-renderer", - "version": "2.0.1", + "version": "2.0.2", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "dash-renderer", - "version": "2.0.1", + "version": "2.0.2", "license": "MIT", "dependencies": { "@babel/polyfill": "^7.12.1", diff --git a/dash/dash-renderer/package.json b/dash/dash-renderer/package.json index 5ab97ae90c..2d481f8522 100644 --- a/dash/dash-renderer/package.json +++ b/dash/dash-renderer/package.json @@ -1,6 +1,6 @@ { "name": "dash-renderer", - "version": "2.0.1", + "version": "2.0.2", "description": "render dash components in react", "main": "build/dash_renderer.min.js", "scripts": { @@ -13,7 +13,7 @@ "build:dev": "webpack", "build:local": "renderer build local", "build": "renderer build && npm run prepublishOnly", - "postbuild": "es-check es2018 ../deps/*.js build/*.js", + "postbuild": "es-check es2015 ../deps/*.js build/*.js", "test": "karma start karma.conf.js --single-run", "format": "run-s private::format.*", "lint": "run-s private::lint.* --continue-on-error" diff --git a/dash/version.py b/dash/version.py index f7bbf541c6..0b609eb386 100644 --- a/dash/version.py +++ b/dash/version.py @@ -1 +1 @@ -__version__ = "3.0.0rc2" +__version__ = "3.0.0rc3"