From c19e5c2e4b77fb9fdc96812a6d7a09e3976aded4 Mon Sep 17 00:00:00 2001 From: Adrian Borrmann Date: Wed, 1 Apr 2026 09:38:24 -0600 Subject: [PATCH 1/8] Add layout traversal utilities for Dash component trees --- dash/layout.py | 228 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 228 insertions(+) create mode 100644 dash/layout.py diff --git a/dash/layout.py b/dash/layout.py new file mode 100644 index 0000000000..fdca86edca --- /dev/null +++ b/dash/layout.py @@ -0,0 +1,228 @@ +"""Reusable layout utilities for traversing and inspecting Dash component trees.""" + +from __future__ import annotations + +import json +from typing import Any, Generator + +from dash import get_app +from dash._pages import PAGE_REGISTRY +from dash.dependencies import Wildcard +from dash.development.base_component import Component + +_WILDCARD_VALUES = frozenset(w.value for w in Wildcard) + + +def traverse( + start: Component | None = None, +) -> Generator[tuple[Component, tuple[Component, ...]], None, None]: + """Yield ``(component, ancestors)`` for every Component in the tree. + + If ``start`` is ``None``, the full app layout is resolved via + ``dash.get_app()``, preferring ``validation_layout`` for completeness. + """ + if start is None: + app = get_app() + start = getattr(app, "validation_layout", None) or app.get_layout() + + yield from _walk(start, ()) + + +def _walk( + node: Any, + ancestors: tuple[Component, ...], +) -> Generator[tuple[Component, tuple[Component, ...]], None, None]: + if node is None: + return + if isinstance(node, (list, tuple)): + for item in node: + yield from _walk(item, ancestors) + return + if not isinstance(node, Component): + return + + yield node, ancestors + + child_ancestors = (*ancestors, node) + for _prop_name, child in iter_children(node): + yield from _walk(child, child_ancestors) + + +def iter_children( + component: Component, +) -> Generator[tuple[str, Component], None, None]: + """Yield ``(prop_name, child_component)`` for all component-valued props. + + Walks ``children`` plus any props declared in the component's + ``_children_props`` list. Supports nested path expressions like + ``control_groups[].children`` and ``insights.title``. + """ + props_to_walk = ["children"] + getattr(component, "_children_props", []) + for prop_path in props_to_walk: + for child in get_children(component, prop_path): + yield prop_path, child + + +def get_children(component: Any, prop_path: str) -> list[Component]: + """Resolve a ``_children_props`` path expression to child Components. + + Mirrors the dash-renderer's path parsing in ``DashWrapper.tsx``. + Supports: + - ``"children"`` — simple prop + - ``"control_groups[].children"`` — array, then sub-prop per element + - ``"insights.title"`` — nested object prop + """ + clean_path = prop_path.replace("[]", "").replace("{}", "") + + if "." not in prop_path: + return _collect_components(getattr(component, clean_path, None)) + + parts = prop_path.split(".") + array_idx = next((i for i, p in enumerate(parts) if "[]" in p), len(parts)) + front = [p.replace("[]", "").replace("{}", "") for p in parts[: array_idx + 1]] + back = [p.replace("{}", "") for p in parts[array_idx + 1 :]] + + node = _resolve_path(component, front) + if node is None: + return [] + + if back and isinstance(node, (list, tuple)): + results: list[Component] = [] + for element in node: + child = _resolve_path(element, back) + results.extend(_collect_components(child)) + return results + + return _collect_components(node) + + +def _resolve_path(node: Any, keys: list[str]) -> Any: + """Walk a chain of keys through Components and dicts.""" + for key in keys: + if isinstance(node, Component): + node = getattr(node, key, None) + elif isinstance(node, dict): + node = node.get(key) + else: + return None + if node is None: + return None + return node + + +def _collect_components(value: Any) -> list[Component]: + """Extract Components from a value (single, list, or None).""" + if value is None: + return [] + if isinstance(value, Component): + return [value] + if isinstance(value, (list, tuple)): + return [item for item in value if isinstance(item, (Component, list, tuple))] + return [] + + +def find_component( + component_id: str | dict, + layout: Component | None = None, + page: str | None = None, +) -> Component | None: + """Find a component by ID. + + If neither ``layout`` nor ``page`` is provided, searches the full + app layout (preferring ``validation_layout`` for completeness). + """ + if page is not None: + layout = _resolve_page_layout(page) + + if layout is None: + app = get_app() + layout = getattr(app, "validation_layout", None) or app.get_layout() + + for comp, _ in traverse(layout): + if getattr(comp, "id", None) == component_id: + return comp + return None + + +def parse_wildcard_id(pid: Any) -> dict | None: + """Parse a component ID and return it as a dict if it contains a wildcard. + + Accepts string (JSON-encoded) or dict IDs. Returns ``None`` + if the ID is not a wildcard pattern. + + Example:: + + >>> parse_wildcard_id('{"type":"input","index":["ALL"]}') + {"type": "input", "index": ["ALL"]} + >>> parse_wildcard_id("my-dropdown") + None + """ + if isinstance(pid, str) and pid.startswith("{"): + try: + pid = json.loads(pid) + except (json.JSONDecodeError, ValueError): + return None + if not isinstance(pid, dict): + return None + for v in pid.values(): + if isinstance(v, list) and len(v) == 1 and v[0] in _WILDCARD_VALUES: + return pid + return None + + +def find_matching_components(pattern: dict) -> list[Component]: + """Find all components whose dict ID matches a wildcard pattern. + + Non-wildcard keys must match exactly. Wildcard keys are ignored. + """ + non_wildcard_keys = { + k: v + for k, v in pattern.items() + if not (isinstance(v, list) and len(v) == 1 and v[0] in _WILDCARD_VALUES) + } + matches = [] + for comp, _ in traverse(): + comp_id = getattr(comp, "id", None) + if not isinstance(comp_id, dict): + continue + if all(comp_id.get(k) == v for k, v in non_wildcard_keys.items()): + matches.append(comp) + return matches + + +def extract_text(component: Component) -> str: + """Recursively extract plain text from a component's children tree. + + Mimics the browser's ``element.textContent``. + """ + children = getattr(component, "children", None) + if children is None: + return "" + if isinstance(children, str): + return children + if isinstance(children, Component): + return extract_text(children) + if isinstance(children, (list, tuple)): + parts: list[str] = [] + for child in children: + if isinstance(child, str): + parts.append(child) + elif isinstance(child, Component): + parts.append(extract_text(child)) + return "".join(parts).strip() + return "" + + +def _resolve_page_layout(page: str) -> Any | None: + if not PAGE_REGISTRY: + return None + for _module, page_info in PAGE_REGISTRY.items(): + if page_info.get("path") == page: + page_layout = page_info.get("layout") + if callable(page_layout): + try: + page_layout = page_layout() + except (TypeError, RuntimeError): + return None + return page_layout + return None From 9283b66ba9a30c8ba270edc35e5ad7eac0a81d0e Mon Sep 17 00:00:00 2001 From: Adrian Borrmann Date: Wed, 1 Apr 2026 10:01:59 -0600 Subject: [PATCH 2/8] Make Dash components compatible with Pydantic types --- dash/development/_py_components_generation.py | 5 +- dash/development/base_component.py | 17 ++++ dash/types.py | 67 ++++++++++++++- requirements/install.txt | 1 + tests/unit/test_layout.py | 83 +++++++++++++++++++ tests/unit/test_pydantic_types.py | 36 ++++++++ 6 files changed, 204 insertions(+), 5 deletions(-) create mode 100644 tests/unit/test_layout.py create mode 100644 tests/unit/test_pydantic_types.py diff --git a/dash/development/_py_components_generation.py b/dash/development/_py_components_generation.py index 73545ea4a5..b597283a04 100644 --- a/dash/development/_py_components_generation.py +++ b/dash/development/_py_components_generation.py @@ -24,6 +24,7 @@ import typing # noqa: F401 from typing_extensions import TypedDict, NotRequired, Literal # noqa: F401 from dash.development.base_component import Component, _explicitize_args +from dash.types import NumberType # noqa: F401 {custom_imports} ComponentSingleType = typing.Union[str, int, float, Component, None] ComponentType = typing.Union[ @@ -31,10 +32,6 @@ typing.Sequence[ComponentSingleType], ] -NumberType = typing.Union[ - typing.SupportsFloat, typing.SupportsInt, typing.SupportsComplex -] - """ diff --git a/dash/development/base_component.py b/dash/development/base_component.py index 02579ff2e2..5382c5aafc 100644 --- a/dash/development/base_component.py +++ b/dash/development/base_component.py @@ -117,6 +117,23 @@ class Component(metaclass=ComponentMeta): _valid_wildcard_attributes: typing.List[str] available_wildcard_properties: typing.List[str] + @classmethod + def __get_pydantic_core_schema__(cls, source_type, handler): + from pydantic_core import core_schema + return core_schema.any_schema() + + @classmethod + def __get_pydantic_json_schema__(cls, schema, handler): + namespaces = list(ComponentRegistry.namespace_to_package.keys()) + return { + "type": "object", + "properties": { + "type": {"type": "string"}, + "namespace": {"type": "string", "enum": namespaces} if namespaces else {"type": "string"}, + "props": {"type": "object"}, + }, + } + class _UNDEFINED: def __repr__(self): return "undefined" diff --git a/dash/types.py b/dash/types.py index 9a39adb43e..43bf16dc30 100644 --- a/dash/types.py +++ b/dash/types.py @@ -1,4 +1,29 @@ -from typing_extensions import TypedDict, NotRequired +import typing +from typing import Any, Dict, List, Union + +from pydantic import Field, GetCoreSchemaHandler, GetJsonSchemaHandler +from pydantic_core import core_schema +from typing_extensions import Annotated, TypedDict, NotRequired + + +class _NumberSchema: # pylint: disable=too-few-public-methods + @classmethod + def __get_pydantic_core_schema__( + cls, _source_type: Any, _handler: GetCoreSchemaHandler + ) -> Any: + return core_schema.float_schema() + + @classmethod + def __get_pydantic_json_schema__( + cls, _schema: Any, _handler: GetJsonSchemaHandler + ) -> dict: + return {"type": "number"} + + +NumberType = Annotated[ + Union[typing.SupportsFloat, typing.SupportsInt, typing.SupportsComplex], + _NumberSchema, +] class RendererHooks(TypedDict): # pylint: disable=too-many-ancestors @@ -8,3 +33,43 @@ class RendererHooks(TypedDict): # pylint: disable=too-many-ancestors request_post: NotRequired[str] callback_resolved: NotRequired[str] request_refresh_jwt: NotRequired[str] + + +class CallbackDependency(TypedDict): + id: Union[str, Dict[str, Any]] + property: str + + +class CallbackInput(TypedDict): + id: Union[str, Dict[str, Any]] + property: str + value: Any + + +class CallbackDispatchBody(TypedDict): + output: str + outputs: List[CallbackDependency] + inputs: List[CallbackInput] + state: List[CallbackInput] + changedPropIds: List[str] + + +CallbackOutput = Annotated[ + Dict[str, Any], + Field( + description="The return values of the callback. A mapping of component & property names to their updated values." + ), +] + +CallbackSideOutput = Annotated[ + Dict[str, Any], + Field( + description="Side-effect updates that the callback performed but did not declare ahead of time. A mapping of component & property names to their updated values." + ), +] + + +class CallbackDispatchResponse(TypedDict): + multi: NotRequired[bool] + response: NotRequired[Dict[str, CallbackOutput]] + sideUpdate: NotRequired[Dict[str, CallbackSideOutput]] diff --git a/requirements/install.txt b/requirements/install.txt index df0e1299e3..5b425cf5a9 100644 --- a/requirements/install.txt +++ b/requirements/install.txt @@ -7,3 +7,4 @@ requests retrying nest-asyncio setuptools +pydantic>=2.12.5 diff --git a/tests/unit/test_layout.py b/tests/unit/test_layout.py new file mode 100644 index 0000000000..76a72f7fb4 --- /dev/null +++ b/tests/unit/test_layout.py @@ -0,0 +1,83 @@ +"""Tests for dash.layout — layout traversal and component lookup utilities.""" + +import pytest + +from dash import html, dcc +from dash.layout import ( + traverse, + find_component, + extract_text, + parse_wildcard_id, +) + + +@pytest.fixture +def sample_layout(): + return html.Div( + [ + html.Label("Name:", htmlFor="name-input"), + " ", + dcc.Input(id="name-input", value="World"), + html.Div( + [html.Span(id="deep-child", children="deep text")], + id="inner", + ), + ], + id="root", + ) + + +class TestTraverse: + def test_yields_all_components_with_correct_ancestors(self, sample_layout): + results = { + getattr(c, "id", None): len(ancestors) + for c, ancestors in traverse(sample_layout) + } + assert results["root"] == 0 + assert results["name-input"] == 1 + assert results["deep-child"] == 2 + + def test_empty_layout(self): + results = list(traverse(html.Div())) + assert len(results) == 1 # just the Div itself + + +class TestFindComponent: + def test_finds_by_string_id(self, sample_layout): + comp = find_component("deep-child", layout=sample_layout) + assert comp is not None and comp.id == "deep-child" + + def test_returns_none_for_missing_id(self, sample_layout): + assert find_component("nope", layout=sample_layout) is None + + def test_finds_by_dict_id(self): + layout = html.Div([html.Div(id={"type": "item", "index": 0})]) + assert find_component({"type": "item", "index": 0}, layout=layout) is not None + + +class TestExtractText: + def test_extracts_all_text_content(self, sample_layout): + assert extract_text(sample_layout) == "Name: deep text" + + def test_none_children(self): + assert extract_text(html.Div()) == "" + + +class TestParseWildcardId: + @pytest.mark.parametrize("wildcard", ["ALL", "MATCH", "ALLSMALLER"]) + def test_returns_dict_for_wildcard(self, wildcard): + result = parse_wildcard_id({"type": "input", "index": [wildcard]}) + assert result == {"type": "input", "index": [wildcard]} + + def test_parses_json_string(self): + result = parse_wildcard_id('{"type":"input","index":["ALL"]}') + assert result == {"type": "input", "index": ["ALL"]} + + def test_returns_none_for_plain_string(self): + assert parse_wildcard_id("my-dropdown") is None + + def test_returns_none_for_non_wildcard_dict(self): + assert parse_wildcard_id({"type": "input", "index": 0}) is None + + def test_returns_none_for_invalid_json(self): + assert parse_wildcard_id("{not valid}") is None diff --git a/tests/unit/test_pydantic_types.py b/tests/unit/test_pydantic_types.py new file mode 100644 index 0000000000..75d1dc7f41 --- /dev/null +++ b/tests/unit/test_pydantic_types.py @@ -0,0 +1,36 @@ +"""Tests for dash.types — Pydantic-compatible types and schemas.""" + +from pydantic import TypeAdapter + +from dash.types import NumberType, CallbackDispatchBody, CallbackDispatchResponse +from dash.development.base_component import Component + + +class TestNumberType: + def test_json_schema_is_number(self): + schema = TypeAdapter(NumberType).json_schema() + assert schema["type"] == "number" + + +class TestComponentPydanticSchema: + def test_produces_object_schema(self): + schema = TypeAdapter(Component).json_schema() + assert schema["type"] == "object" + assert "properties" in schema + + def test_schema_has_type_and_props(self): + schema = TypeAdapter(Component).json_schema() + props = schema["properties"] + assert "type" in props + assert "props" in props + + +class TestCallbackDispatchTypes: + def test_dispatch_body_schema(self): + schema = TypeAdapter(CallbackDispatchBody).json_schema() + assert "output" in schema["properties"] + assert "inputs" in schema["properties"] + + def test_dispatch_response_schema(self): + schema = TypeAdapter(CallbackDispatchResponse).json_schema() + assert "response" in schema["properties"] From 402d8b96286307a2d25e7278309d8567c8fefafd Mon Sep 17 00:00:00 2001 From: Adrian Borrmann Date: Wed, 1 Apr 2026 10:31:58 -0600 Subject: [PATCH 3/8] Extract get_layout() from serve_layout() --- dash/dash.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/dash/dash.py b/dash/dash.py index 122cf54dd6..1418483187 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -907,15 +907,22 @@ def index_string(self, value: str) -> None: self._index_string = value @with_app_context - def serve_layout(self): - layout = self._layout_value() + def get_layout(self): + """Return the resolved layout with all hooks applied. + This is the canonical way to obtain the app's layout — it + calls the layout function (if callable), includes extra + components, and runs layout hooks. + """ + layout = self._layout_value() for hook in self._hooks.get_hooks("layout"): layout = hook(layout) + return layout + def serve_layout(self): # TODO - Set browser cache limit - pass hash into frontend return flask.Response( - to_json(layout), + to_json(self.get_layout()), mimetype="application/json", ) From f82288da0a7253cfdd0f40485355a167e3f586e5 Mon Sep 17 00:00:00 2001 From: Adrian Borrmann Date: Wed, 1 Apr 2026 10:46:25 -0600 Subject: [PATCH 4/8] Fix build issues for dash-table and dash-core-components --- components/dash-core-components/package.json | 4 ++-- components/dash-table/package.json | 2 +- dash/dash-renderer/babel.config.js | 2 +- dash/dash-renderer/package.json | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/components/dash-core-components/package.json b/components/dash-core-components/package.json index ac9d88c80c..e430a00b6a 100644 --- a/components/dash-core-components/package.json +++ b/components/dash-core-components/package.json @@ -27,7 +27,7 @@ "build:js": "webpack --mode production", "build:backends": "dash-generate-components ./src/components dash_core_components -p package-info.json && cp dash_core_components_base/** dash_core_components/ && dash-generate-components ./src/components dash_core_components -p package-info.json -k RangeSlider,Slider,Dropdown,RadioItems,Checklist,DatePickerSingle,DatePickerRange,Input,Link --r-prefix 'dcc' --r-suggests 'dash,dashHtmlComponents,jsonlite,plotly' --jl-prefix 'dcc' && black dash_core_components", "build": "run-s prepublishOnly build:js build:backends", - "postbuild": "es-check es2015 dash_core_components/*.js", + "postbuild": "es-check es2017 dash_core_components/*.js", "build:watch": "watch 'npm run build' src", "format": "run-s private::format.*", "lint": "run-s private::lint.*" @@ -126,6 +126,6 @@ "react-dom": "16 - 19" }, "browserslist": [ - "last 9 years and not dead" + "last 11 years and not dead" ] } diff --git a/components/dash-table/package.json b/components/dash-table/package.json index b5d65499e9..fd905c6f40 100644 --- a/components/dash-table/package.json +++ b/components/dash-table/package.json @@ -119,6 +119,6 @@ "npm": ">=6.1.0" }, "browserslist": [ - "last 9 years and not dead" + "last 11 years and not dead" ] } diff --git a/dash/dash-renderer/babel.config.js b/dash/dash-renderer/babel.config.js index d7b0c89e8e..6e6cc5d957 100644 --- a/dash/dash-renderer/babel.config.js +++ b/dash/dash-renderer/babel.config.js @@ -3,7 +3,7 @@ module.exports = { '@babel/preset-typescript', ['@babel/preset-env', { "targets": { - "browsers": ["last 10 years and not dead"] + "browsers": ["last 11 years and not dead"] } }], '@babel/preset-react' diff --git a/dash/dash-renderer/package.json b/dash/dash-renderer/package.json index f92d22cfc5..ce0c26b4a6 100644 --- a/dash/dash-renderer/package.json +++ b/dash/dash-renderer/package.json @@ -89,6 +89,6 @@ ], "prettier": "@plotly/prettier-config-dash", "browserslist": [ - "last 10 years and not dead" + "last 11 years and not dead" ] } From 0efcec5edea9431d5337ab2c735f7ec77255322a Mon Sep 17 00:00:00 2001 From: Adrian Borrmann Date: Wed, 1 Apr 2026 15:13:51 -0600 Subject: [PATCH 5/8] Add CallbackDispatchBody type hints to dispatch methods --- dash/dash.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/dash/dash.py b/dash/dash.py index 1418483187..9fa9f1e8e6 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -81,7 +81,7 @@ _import_layouts_from_pages, ) from ._jupyter import jupyter_dash, JupyterDisplayMode -from .types import RendererHooks +from .types import CallbackDispatchBody, RendererHooks RouteCallable = Callable[..., Any] @@ -1472,7 +1472,7 @@ def callback(self, *_args, **_kwargs) -> Callable[..., Any]: ) # pylint: disable=R0915 - def _initialize_context(self, body): + def _initialize_context(self, body: CallbackDispatchBody): """Initialize the global context for the request.""" g = AttributeDict({}) g.inputs_list = body.get("inputs", []) @@ -1493,7 +1493,7 @@ def _initialize_context(self, body): g.updated_props = {} return g - def _prepare_callback(self, g, body): + def _prepare_callback(self, g, body: CallbackDispatchBody): """Prepare callback-related data.""" output = body["output"] try: From 200240c759a59406d4d9433f6771bbb7cda31251 Mon Sep 17 00:00:00 2001 From: Adrian Borrmann Date: Wed, 1 Apr 2026 16:28:56 -0600 Subject: [PATCH 6/8] Use python3.8 compatible pydantic --- requirements/install.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/install.txt b/requirements/install.txt index 5b425cf5a9..89bd8a5595 100644 --- a/requirements/install.txt +++ b/requirements/install.txt @@ -7,4 +7,4 @@ requests retrying nest-asyncio setuptools -pydantic>=2.12.5 +pydantic>=2.10 From a01a01622c77cd8c9ed8a768449cd88f072ea7f4 Mon Sep 17 00:00:00 2001 From: Adrian Borrmann Date: Wed, 1 Apr 2026 16:43:50 -0600 Subject: [PATCH 7/8] lint --- dash/development/base_component.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/dash/development/base_component.py b/dash/development/base_component.py index 5382c5aafc..32357c94ea 100644 --- a/dash/development/base_component.py +++ b/dash/development/base_component.py @@ -120,6 +120,7 @@ class Component(metaclass=ComponentMeta): @classmethod def __get_pydantic_core_schema__(cls, source_type, handler): from pydantic_core import core_schema + return core_schema.any_schema() @classmethod @@ -129,7 +130,9 @@ def __get_pydantic_json_schema__(cls, schema, handler): "type": "object", "properties": { "type": {"type": "string"}, - "namespace": {"type": "string", "enum": namespaces} if namespaces else {"type": "string"}, + "namespace": {"type": "string", "enum": namespaces} + if namespaces + else {"type": "string"}, "props": {"type": "object"}, }, } From 26fc936fd9c9e34c8bd22f1b4f890426593dd7ad Mon Sep 17 00:00:00 2001 From: Adrian Borrmann Date: Wed, 1 Apr 2026 17:03:03 -0600 Subject: [PATCH 8/8] Fix lint error on CI --- dash/development/base_component.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/dash/development/base_component.py b/dash/development/base_component.py index 32357c94ea..a7aec5a3f0 100644 --- a/dash/development/base_component.py +++ b/dash/development/base_component.py @@ -118,13 +118,13 @@ class Component(metaclass=ComponentMeta): available_wildcard_properties: typing.List[str] @classmethod - def __get_pydantic_core_schema__(cls, source_type, handler): - from pydantic_core import core_schema + def __get_pydantic_core_schema__(cls, _source_type, _handler): + from pydantic_core import core_schema # pylint: disable=import-outside-toplevel return core_schema.any_schema() @classmethod - def __get_pydantic_json_schema__(cls, schema, handler): + def __get_pydantic_json_schema__(cls, _schema, _handler): namespaces = list(ComponentRegistry.namespace_to_package.keys()) return { "type": "object",