Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions components/dash-core-components/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.*"
Expand Down Expand Up @@ -126,6 +126,6 @@
"react-dom": "16 - 19"
},
"browserslist": [
"last 9 years and not dead"
"last 11 years and not dead"
]
}
2 changes: 1 addition & 1 deletion components/dash-table/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,6 @@
"npm": ">=6.1.0"
},
"browserslist": [
"last 9 years and not dead"
"last 11 years and not dead"
]
}
2 changes: 1 addition & 1 deletion dash/dash-renderer/babel.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
2 changes: 1 addition & 1 deletion dash/dash-renderer/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,6 @@
],
"prettier": "@plotly/prettier-config-dash",
"browserslist": [
"last 10 years and not dead"
"last 11 years and not dead"
]
}
19 changes: 13 additions & 6 deletions dash/dash.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down Expand Up @@ -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",
)

Expand Down Expand Up @@ -1465,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", [])
Expand All @@ -1486,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:
Expand Down
5 changes: 1 addition & 4 deletions dash/development/_py_components_generation.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,17 +24,14 @@
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[
ComponentSingleType,
typing.Sequence[ComponentSingleType],
]

NumberType = typing.Union[
typing.SupportsFloat, typing.SupportsInt, typing.SupportsComplex
]


"""

Expand Down
20 changes: 20 additions & 0 deletions dash/development/base_component.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,26 @@ 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 # pylint: disable=import-outside-toplevel

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"
Expand Down
228 changes: 228 additions & 0 deletions dash/layout.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading