Skip to content

Commit 7354caf

Browse files
authored
Merge pull request #3710 from plotly/feature/mcp-framework-foundations
MCP Server Part 1: framework utilities and types
2 parents c1a9cd9 + 3f9fbca commit 7354caf

File tree

12 files changed

+461
-16
lines changed

12 files changed

+461
-16
lines changed

components/dash-core-components/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
"build:js": "webpack --mode production",
2828
"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",
2929
"build": "run-s prepublishOnly build:js build:backends",
30-
"postbuild": "es-check es2015 dash_core_components/*.js",
30+
"postbuild": "es-check es2017 dash_core_components/*.js",
3131
"build:watch": "watch 'npm run build' src",
3232
"format": "run-s private::format.*",
3333
"lint": "run-s private::lint.*"
@@ -126,6 +126,6 @@
126126
"react-dom": "16 - 19"
127127
},
128128
"browserslist": [
129-
"last 9 years and not dead"
129+
"last 11 years and not dead"
130130
]
131131
}

components/dash-table/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,6 @@
119119
"npm": ">=6.1.0"
120120
},
121121
"browserslist": [
122-
"last 9 years and not dead"
122+
"last 11 years and not dead"
123123
]
124124
}

dash/_layout_utils.py

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
"""Reusable layout utilities for traversing and inspecting Dash component trees."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
from typing import Any, Generator
7+
8+
from dash import get_app
9+
from dash._pages import PAGE_REGISTRY
10+
from dash.dependencies import Wildcard
11+
from dash.development.base_component import Component
12+
13+
_WILDCARD_VALUES = frozenset(w.value for w in Wildcard)
14+
15+
16+
def traverse(
17+
start: Component | None = None,
18+
) -> Generator[tuple[Component, tuple[Component, ...]], None, None]:
19+
"""Yield ``(component, ancestors)`` for every Component in the tree.
20+
21+
If ``start`` is ``None``, the full app layout is resolved via
22+
``dash.get_app()``, preferring ``validation_layout`` for completeness.
23+
"""
24+
if start is None:
25+
app = get_app()
26+
start = getattr(app, "validation_layout", None) or app.get_layout()
27+
28+
yield from _walk(start, ())
29+
30+
31+
def _walk(
32+
node: Any,
33+
ancestors: tuple[Component, ...],
34+
) -> Generator[tuple[Component, tuple[Component, ...]], None, None]:
35+
if node is None:
36+
return
37+
if isinstance(node, (list, tuple)):
38+
for item in node:
39+
yield from _walk(item, ancestors)
40+
return
41+
if not isinstance(node, Component):
42+
return
43+
44+
yield node, ancestors
45+
46+
child_ancestors = (*ancestors, node)
47+
for _prop_name, child in iter_children(node):
48+
yield from _walk(child, child_ancestors)
49+
50+
51+
def iter_children(
52+
component: Component,
53+
) -> Generator[tuple[str, Component], None, None]:
54+
"""Yield ``(prop_name, child_component)`` for all component-valued props.
55+
56+
Walks ``children`` plus any props declared in the component's
57+
``_children_props`` list. Supports nested path expressions like
58+
``control_groups[].children`` and ``insights.title``.
59+
"""
60+
props_to_walk = ["children"] + getattr(component, "_children_props", [])
61+
for prop_path in props_to_walk:
62+
for child in get_children(component, prop_path):
63+
yield prop_path, child
64+
65+
66+
def get_children(component: Any, prop_path: str) -> list[Component]:
67+
"""Resolve a ``_children_props`` path expression to child Components.
68+
69+
Mirrors the dash-renderer's path parsing in ``DashWrapper.tsx``.
70+
Supports:
71+
- ``"children"`` — simple prop
72+
- ``"control_groups[].children"`` — array, then sub-prop per element
73+
- ``"insights.title"`` — nested object prop
74+
"""
75+
clean_path = prop_path.replace("[]", "").replace("{}", "")
76+
77+
if "." not in prop_path:
78+
return _collect_components(getattr(component, clean_path, None))
79+
80+
parts = prop_path.split(".")
81+
array_idx = next((i for i, p in enumerate(parts) if "[]" in p), len(parts))
82+
front = [p.replace("[]", "").replace("{}", "") for p in parts[: array_idx + 1]]
83+
back = [p.replace("{}", "") for p in parts[array_idx + 1 :]]
84+
85+
node = _resolve_path(component, front)
86+
if node is None:
87+
return []
88+
89+
if back and isinstance(node, (list, tuple)):
90+
results: list[Component] = []
91+
for element in node:
92+
child = _resolve_path(element, back)
93+
results.extend(_collect_components(child))
94+
return results
95+
96+
return _collect_components(node)
97+
98+
99+
def _resolve_path(node: Any, keys: list[str]) -> Any:
100+
"""Walk a chain of keys through Components and dicts."""
101+
for key in keys:
102+
if isinstance(node, Component):
103+
node = getattr(node, key, None)
104+
elif isinstance(node, dict):
105+
node = node.get(key)
106+
else:
107+
return None
108+
if node is None:
109+
return None
110+
return node
111+
112+
113+
def _collect_components(value: Any) -> list[Component]:
114+
"""Extract Components from a value (single, list, or None)."""
115+
if value is None:
116+
return []
117+
if isinstance(value, Component):
118+
return [value]
119+
if isinstance(value, (list, tuple)):
120+
return [item for item in value if isinstance(item, (Component, list, tuple))]
121+
return []
122+
123+
124+
def find_component(
125+
component_id: str | dict,
126+
layout: Component | None = None,
127+
page: str | None = None,
128+
) -> Component | None:
129+
"""Find a component by ID.
130+
131+
If neither ``layout`` nor ``page`` is provided, searches the full
132+
app layout (preferring ``validation_layout`` for completeness).
133+
"""
134+
if page is not None:
135+
layout = _resolve_page_layout(page)
136+
137+
if layout is None:
138+
app = get_app()
139+
layout = getattr(app, "validation_layout", None) or app.get_layout()
140+
141+
for comp, _ in traverse(layout):
142+
if getattr(comp, "id", None) == component_id:
143+
return comp
144+
return None
145+
146+
147+
def parse_wildcard_id(pid: Any) -> dict | None:
148+
"""Parse a component ID and return it as a dict if it contains a wildcard.
149+
150+
Accepts string (JSON-encoded) or dict IDs. Returns ``None``
151+
if the ID is not a wildcard pattern.
152+
153+
Example::
154+
155+
>>> parse_wildcard_id('{"type":"input","index":["ALL"]}')
156+
{"type": "input", "index": ["ALL"]}
157+
>>> parse_wildcard_id("my-dropdown")
158+
None
159+
"""
160+
if isinstance(pid, str) and pid.startswith("{"):
161+
try:
162+
pid = json.loads(pid)
163+
except (json.JSONDecodeError, ValueError):
164+
return None
165+
if not isinstance(pid, dict):
166+
return None
167+
for v in pid.values():
168+
if isinstance(v, list) and len(v) == 1 and v[0] in _WILDCARD_VALUES:
169+
return pid
170+
return None
171+
172+
173+
def find_matching_components(pattern: dict) -> list[Component]:
174+
"""Find all components whose dict ID matches a wildcard pattern.
175+
176+
Non-wildcard keys must match exactly. Wildcard keys are ignored.
177+
"""
178+
non_wildcard_keys = {
179+
k: v
180+
for k, v in pattern.items()
181+
if not (isinstance(v, list) and len(v) == 1 and v[0] in _WILDCARD_VALUES)
182+
}
183+
matches = []
184+
for comp, _ in traverse():
185+
comp_id = getattr(comp, "id", None)
186+
if not isinstance(comp_id, dict):
187+
continue
188+
if all(comp_id.get(k) == v for k, v in non_wildcard_keys.items()):
189+
matches.append(comp)
190+
return matches
191+
192+
193+
def extract_text(component: Component) -> str:
194+
"""Recursively extract plain text from a component's children tree.
195+
196+
Mimics the browser's ``element.textContent``.
197+
"""
198+
children = getattr(component, "children", None)
199+
if children is None:
200+
return ""
201+
if isinstance(children, str):
202+
return children
203+
if isinstance(children, Component):
204+
return extract_text(children)
205+
if isinstance(children, (list, tuple)):
206+
parts: list[str] = []
207+
for child in children:
208+
if isinstance(child, str):
209+
parts.append(child)
210+
elif isinstance(child, Component):
211+
parts.append(extract_text(child))
212+
return "".join(parts).strip()
213+
return ""
214+
215+
216+
def _resolve_page_layout(page: str) -> Any | None:
217+
if not PAGE_REGISTRY:
218+
return None
219+
for _module, page_info in PAGE_REGISTRY.items():
220+
if page_info.get("path") == page:
221+
page_layout = page_info.get("layout")
222+
if callable(page_layout):
223+
try:
224+
page_layout = page_layout()
225+
except (TypeError, RuntimeError):
226+
return None
227+
return page_layout
228+
return None

dash/dash-renderer/babel.config.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ module.exports = {
33
'@babel/preset-typescript',
44
['@babel/preset-env', {
55
"targets": {
6-
"browsers": ["last 10 years and not dead"]
6+
"browsers": ["last 11 years and not dead"]
77
}
88
}],
99
'@babel/preset-react'

dash/dash-renderer/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,6 @@
8989
],
9090
"prettier": "@plotly/prettier-config-dash",
9191
"browserslist": [
92-
"last 10 years and not dead"
92+
"last 11 years and not dead"
9393
]
9494
}

dash/dash.py

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@
8181
_import_layouts_from_pages,
8282
)
8383
from ._jupyter import jupyter_dash, JupyterDisplayMode
84-
from .types import RendererHooks
84+
from .types import CallbackDispatchBody, RendererHooks
8585

8686
RouteCallable = Callable[..., Any]
8787

@@ -907,15 +907,22 @@ def index_string(self, value: str) -> None:
907907
self._index_string = value
908908

909909
@with_app_context
910-
def serve_layout(self):
911-
layout = self._layout_value()
910+
def get_layout(self):
911+
"""Return the resolved layout with all hooks applied.
912912
913+
This is the canonical way to obtain the app's layout — it
914+
calls the layout function (if callable), includes extra
915+
components, and runs layout hooks.
916+
"""
917+
layout = self._layout_value()
913918
for hook in self._hooks.get_hooks("layout"):
914919
layout = hook(layout)
920+
return layout
915921

922+
def serve_layout(self):
916923
# TODO - Set browser cache limit - pass hash into frontend
917924
return flask.Response(
918-
to_json(layout),
925+
to_json(self.get_layout()),
919926
mimetype="application/json",
920927
)
921928

@@ -1465,7 +1472,7 @@ def callback(self, *_args, **_kwargs) -> Callable[..., Any]:
14651472
)
14661473

14671474
# pylint: disable=R0915
1468-
def _initialize_context(self, body):
1475+
def _initialize_context(self, body: CallbackDispatchBody):
14691476
"""Initialize the global context for the request."""
14701477
g = AttributeDict({})
14711478
g.inputs_list = body.get("inputs", [])
@@ -1486,7 +1493,7 @@ def _initialize_context(self, body):
14861493
g.updated_props = {}
14871494
return g
14881495

1489-
def _prepare_callback(self, g, body):
1496+
def _prepare_callback(self, g, body: CallbackDispatchBody):
14901497
"""Prepare callback-related data."""
14911498
output = body["output"]
14921499
try:

dash/development/_py_components_generation.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,17 +24,22 @@
2424
import typing # noqa: F401
2525
from typing_extensions import TypedDict, NotRequired, Literal # noqa: F401
2626
from dash.development.base_component import Component, _explicitize_args
27+
try:
28+
from dash.types import NumberType # noqa: F401
29+
except ImportError:
30+
# Backwards compatibility for dash<=4.1.0
31+
if typing.TYPE_CHECKING:
32+
raise
33+
NumberType = typing.Union[ # noqa: F401
34+
typing.SupportsFloat, typing.SupportsInt, typing.SupportsComplex
35+
]
2736
{custom_imports}
2837
ComponentSingleType = typing.Union[str, int, float, Component, None]
2938
ComponentType = typing.Union[
3039
ComponentSingleType,
3140
typing.Sequence[ComponentSingleType],
3241
]
3342
34-
NumberType = typing.Union[
35-
typing.SupportsFloat, typing.SupportsInt, typing.SupportsComplex
36-
]
37-
3843
3944
"""
4045

dash/development/base_component.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,26 @@ class Component(metaclass=ComponentMeta):
117117
_valid_wildcard_attributes: typing.List[str]
118118
available_wildcard_properties: typing.List[str]
119119

120+
@classmethod
121+
def __get_pydantic_core_schema__(cls, _source_type, _handler):
122+
from pydantic_core import core_schema # pylint: disable=import-outside-toplevel
123+
124+
return core_schema.any_schema()
125+
126+
@classmethod
127+
def __get_pydantic_json_schema__(cls, _schema, _handler):
128+
namespaces = list(ComponentRegistry.namespace_to_package.keys())
129+
return {
130+
"type": "object",
131+
"properties": {
132+
"type": {"type": "string"},
133+
"namespace": {"type": "string", "enum": namespaces}
134+
if namespaces
135+
else {"type": "string"},
136+
"props": {"type": "object"},
137+
},
138+
}
139+
120140
class _UNDEFINED:
121141
def __repr__(self):
122142
return "undefined"

0 commit comments

Comments
 (0)