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
2 changes: 2 additions & 0 deletions dash/mcp/primitives/tools/input_schemas/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,14 @@
from dash.mcp.types import MCPInput

from .base import InputSchemaSource
from .schema_pattern_matching import PatternMatchingSchema
from .schema_callback_type_annotations import AnnotationSchema
from .schema_component_proptypes_overrides import OverrideSchema
from .schema_component_proptypes import ComponentPropSchema
from .input_descriptions import get_property_description

_SOURCES: list[type[InputSchemaSource]] = [
PatternMatchingSchema,
AnnotationSchema,
OverrideSchema,
ComponentPropSchema,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@
from .description_component_props import ComponentPropsDescription
from .description_docstrings import DocstringPropDescription
from .description_html_labels import LabelDescription
from .description_pattern_matching import PatternMatchingDescription

_SOURCES: list[type[InputDescriptionSource]] = [
DocstringPropDescription,
LabelDescription,
ComponentPropsDescription,
PatternMatchingDescription,
]


Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
"""Description for pattern-matching callback inputs.

Explains that the input corresponds to a pattern-matching callback
(ALL, MATCH, ALLSMALLER) and describes the expected format.
See: https://dash.plotly.com/pattern-matching-callbacks
"""

from __future__ import annotations

from dash._layout_utils import _WILDCARD_VALUES, parse_wildcard_id
from dash.mcp.types import MCPInput

from .base import InputDescriptionSource


class PatternMatchingDescription(InputDescriptionSource):
"""Describe pattern-matching behavior for wildcard inputs."""

@classmethod
def describe(cls, param: MCPInput) -> list[str]:
dep_id = parse_wildcard_id(param["component_id"])
if dep_id is None:
return []

wildcard_key, wildcard_type = _find_wildcard(dep_id)
if wildcard_key is None:
return []

non_wildcard = {k: v for k, v in dep_id.items() if k != wildcard_key}
pattern_desc = ", ".join(f'{k}="{v}"' for k, v in non_wildcard.items())
prop = param["property"]

wildcard_descriptions = {
"ALL": (
f"Pattern-matching input (ALL): provide an array of `{prop}` values, "
f"one per component matching {{{pattern_desc}}}. "
f"All matching components are included."
),
"MATCH": (
f"Pattern-matching input (MATCH): provide the `{prop}` value "
f"for the specific component matching {{{pattern_desc}}} "
f"that triggered this callback."
),
"ALLSMALLER": (
f"Pattern-matching input (ALLSMALLER): provide an array of `{prop}` values "
f"from components matching {{{pattern_desc}}} "
f"whose `{wildcard_key}` is smaller than the triggering component's `{wildcard_key}`."
),
}

desc = wildcard_descriptions.get(wildcard_type)
return [desc] if desc else []


def _find_wildcard(dep_id: dict) -> tuple[str | None, str | None]:
"""Return (key, wildcard_type) for the first wildcard found."""
for key, value in dep_id.items():
if isinstance(value, list) and len(value) == 1:
if value[0] in _WILDCARD_VALUES:
return key, value[0]
return None, None
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
"""Schema for pattern-matching callback inputs (ALL, MATCH, ALLSMALLER).

When a callback input uses a wildcard ID, the callback receives a
list of values — one per matching component. This source detects
wildcard IDs and produces an array schema. If matching components
exist in the layout, the item type is inferred from a concrete match.
"""

from __future__ import annotations

from typing import Any

from dash._layout_utils import (
_WILDCARD_VALUES,
find_matching_components,
parse_wildcard_id,
)
from dash.mcp.types import MCPInput

from .base import InputSchemaSource


class PatternMatchingSchema(InputSchemaSource):
"""Return a schema for pattern-matching inputs.

For ALL/ALLSMALLER: array of ``{id, property, value}`` objects.
For MATCH: a single ``{id, property, value}`` object.
"""

@classmethod
def get_schema(cls, param: MCPInput) -> dict[str, Any] | None:
dep_id = parse_wildcard_id(param["component_id"])
if dep_id is None:
return None

wildcard_type = _get_wildcard_type(dep_id)
if wildcard_type is None:
return None

value_schema = _infer_value_schema(param)

item_schema: dict[str, Any] = {
"type": "object",
"properties": {
"id": {"type": "object"},
"property": {"type": "string"},
"value": value_schema or {},
},
"required": ["id", "property", "value"],
}

if wildcard_type == "MATCH":
return item_schema

return {"type": "array", "items": item_schema}


def _get_wildcard_type(dep_id: dict) -> str | None:
"""Return the wildcard type (ALL, MATCH, ALLSMALLER) or None."""
for value in dep_id.values():
if isinstance(value, list) and len(value) == 1:
if value[0] in _WILDCARD_VALUES:
return value[0]
return None


def _infer_value_schema(param: MCPInput) -> dict[str, Any] | None:
"""Infer the JSON Schema for the ``value`` field from a matching component."""
matches = find_matching_components(parse_wildcard_id(param["component_id"]))
if not matches:
return None

from . import get_input_schema

concrete_param: MCPInput = {
**param,
"component": matches[0],
"component_id": str(getattr(matches[0], "id", "")),
"component_type": getattr(matches[0], "_type", None),
}
schema = get_input_schema(concrete_param)
schema.pop("description", None)
return schema or None
121 changes: 121 additions & 0 deletions tests/unit/mcp/tools/input_schemas/test_pattern_matching.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
"""Tests for pattern-matching schema and description generation."""

from dash import Dash, html, Input, Output, ALL, MATCH

from tests.unit.mcp.conftest import _tools_list, _user_tool, _schema_for, _desc_for


class TestPatternMatchingSchema:
def test_all_produces_array_schema(self):
app = Dash(__name__)
app.layout = html.Div(
[
html.Div(id={"type": "item", "index": 0}, children="A"),
html.Div(id={"type": "item", "index": 1}, children="B"),
html.Div(id="result"),
]
)

@app.callback(
Output("result", "children"),
Input({"type": "item", "index": ALL}, "children"),
)
def combine(values):
return ", ".join(values)

tool = _user_tool(_tools_list(app))
schema = _schema_for(tool)
assert schema["type"] == "array"
assert schema["items"]["type"] == "object"
assert "value" in schema["items"]["properties"]

def test_match_produces_object_schema(self):
app = Dash(__name__)
app.layout = html.Div(
[
html.Div(id={"type": "item", "index": 0}, children="A"),
html.Div(id="result"),
]
)

@app.callback(
Output("result", "children"),
Input({"type": "item", "index": MATCH}, "children"),
)
def echo(value):
return value

tool = _user_tool(_tools_list(app))
schema = _schema_for(tool)
assert schema["type"] == "object"
assert "value" in schema["properties"]

def test_annotation_narrows_value_schema(self):
from dash import dcc

app = Dash(__name__)
app.layout = html.Div(
[
dcc.Dropdown(id={"type": "filter", "index": 0}, options=["a", "b"]),
dcc.Dropdown(id={"type": "filter", "index": 1}, options=["c", "d"]),
html.Div(id="result"),
]
)

@app.callback(
Output("result", "children"),
Input({"type": "filter", "index": ALL}, "options"),
)
def combine(options: list[str]):
return str(options)

tool = _user_tool(_tools_list(app))
schema = _schema_for(tool)
assert schema["type"] == "array"
value_schema = schema["items"]["properties"]["value"]
# Annotation narrows value to list[str] instead of the broad introspected type
assert value_schema == {"items": {"type": "string"}, "type": "array"}


class TestPatternMatchingDescription:
def test_all_description(self):
app = Dash(__name__)
app.layout = html.Div(
[
html.Div(id={"type": "item", "index": 0}),
html.Div(id="result"),
]
)

@app.callback(
Output("result", "children"),
Input({"type": "item", "index": ALL}, "children"),
)
def combine(values):
return str(values)

tool = _user_tool(_tools_list(app))
desc = _desc_for(tool)
assert "Pattern-matching input (ALL)" in desc
assert 'type="item"' in desc

def test_match_description(self):
app = Dash(__name__)
app.layout = html.Div(
[
html.Div(id={"type": "item", "index": 0}),
html.Div(id="result"),
]
)

@app.callback(
Output("result", "children"),
Input({"type": "item", "index": MATCH}, "children"),
)
def echo(value):
return value

tool = _user_tool(_tools_list(app))
desc = _desc_for(tool)
assert "Pattern-matching input (MATCH)" in desc
assert 'type="item"' in desc
Loading