gabotorresruiz commented on code in PR #44797:
URL: https://github.com/apache/superset/pull/44797#discussion_r4160578385


##########
superset/mcp_service/dashboard/schemas.py:
##########
@@ -371,6 +389,8 @@ def _require_identifier_or_permalink(self) -> 
"GetDashboardLayoutRequest":
         )
         if identifier_is_blank and permalink_is_blank:
             raise ValueError("Provide identifier or permalink_key")
+        if self.untabbed_only and (self.tabs_only or self.tab is not None):
+            raise ValueError("untabbed_only cannot be combined with tab or 
tabs_only")

Review Comment:
   Not a blocker, and not something this PR broke, but worth knowing before 
this ships as an agent recovery feature: this message never reaches the caller. 
`validation_message` maps Pydantic error types through a fixed `_REASONS` 
vocabulary (`superset/mcp_service/utils/validation.py:204`) that deliberately 
does not echo validator text, and `value_error` is not in that map. Against the 
running server, `{"identifier": 1, "untabbed_only": true, "tabs_only": true}` 
comes back as `Validation error in get_dashboard_layout: request: Invalid 
value; check the field schema`, and `tab: "   "` as `request.tab: Invalid 
value; check the field schema` rather than `tab must not be blank`. The 
pre-existing `Provide identifier or permalink_key` behaves exactly the same 
way, and `test_layout_rejects_untabbed_only_with_tab_options` asserts on the 
`ValidationError` directly rather than through `Client`, which is why it passes 
while the caller-visible text stays generic.
   
   Since `_resolve_tab` already returns an actionable `DashboardError` for 
`tab_not_found` and `ambiguous_tab`, would you rather do the exclusivity check 
in `_scope_layout` and return a `DashboardError` that names the flag to drop? 
Or do you read the field descriptions as carrying enough for this one?



##########
superset/mcp_service/dashboard/tool/get_dashboard_layout.py:
##########
@@ -39,13 +39,134 @@
     dashboard_layout_serializer,
     DashboardError,
     DashboardLayout,
+    DashboardLayoutScope,
+    DashboardTab,
+    DashboardTabSummary,
     GetDashboardLayoutRequest,
 )
 from superset.mcp_service.mcp_core import ModelGetInfoCore
 
 logger = logging.getLogger(__name__)
 
 
+def _resolve_tab(
+    tabs: list[DashboardTab], selector: str
+) -> DashboardTab | DashboardError:
+    """Match a tab by ID first, then by exact title."""
+    if not tabs:
+        return DashboardError.create(
+            "This dashboard has no tabs. Omit tab to get the full layout.",
+            "tab_not_found",
+        )
+    matches = [tab for tab in tabs if tab.id == selector] or [
+        tab for tab in tabs if tab.name == selector
+    ]
+    if not matches:
+        return DashboardError.create(
+            "Tab not found. Use tabs_only=true to discover tab IDs and 
titles.",
+            "tab_not_found",
+        )
+    if len(matches) > 1:
+        return DashboardError.create(
+            "Multiple tabs have that title. Use tabs_only=true to discover "
+            "their IDs, then pass a unique tab ID.",
+            "ambiguous_tab",
+        )
+    return matches[0]
+
+
+def _subtree_ids(tab_id: str, children: dict[str | None, list[str]]) -> 
set[str]:
+    """Return a tab ID and the IDs of every tab nested under it."""
+    selected_ids: set[str] = set()
+    pending = [tab_id]
+    while pending:
+        current = pending.pop()
+        if current not in selected_ids:
+            selected_ids.add(current)
+            pending.extend(children.get(current, []))
+    return selected_ids
+
+
+def _tab_summaries(
+    tabs: list[DashboardTab], children: dict[str | None, list[str]]
+) -> list[DashboardTabSummary]:
+    """Summarize tabs with absolute depths from the full dashboard tree."""
+    # The parser only emits reachable tabs and assigns each tab a single
+    # enclosing parent, so walking from the top-level tabs reaches every tab.
+    depths: dict[str, int] = {}
+    stack = [(tab_id, 0) for tab_id in children.get(None, [])]
+    while stack:
+        tab_id, depth = stack.pop()
+        depths[tab_id] = depth
+        stack.extend((child, depth + 1) for child in children.get(tab_id, []))
+    return [
+        DashboardTabSummary(
+            id=tab.id,
+            name=tab.name,
+            parent_tab_id=tab.parent_tab_id,
+            depth=depths[tab.id],
+            chart_count=len(tab.chart_ids),
+        )
+        for tab in tabs
+    ]
+
+
+def _scope_layout(
+    layout: DashboardLayout, request: GetDashboardLayoutRequest
+) -> DashboardLayout | DashboardError:
+    """Project the parsed layout without changing permalink or ancestry 
context.
+
+    Scoping only ever removes tabs and chart placements from the parsed layout,
+    so a scoped response never contains a chart the full layout would not.
+    """
+    if not request.tabs_only and request.tab is None and not 
request.untabbed_only:
+        return layout
+
+    if request.untabbed_only:
+        return layout.model_copy(
+            update={
+                "tabs": [],
+                "charts": [chart for chart in layout.charts if chart.tab_id is 
None],
+                "scope": DashboardLayoutScope(untabbed_only=True),
+            }
+        )
+
+    tabs = layout.tabs
+    children: dict[str | None, list[str]] = {}
+    for tab in tabs:
+        children.setdefault(tab.parent_tab_id, []).append(tab.id)
+
+    selected_tab_id: str | None = None
+    if request.tab is not None:
+        selected = _resolve_tab(tabs, request.tab)
+        if isinstance(selected, DashboardError):
+            return selected
+        selected_tab_id = selected.id
+        selected_ids = _subtree_ids(selected.id, children)
+        tabs = [tab for tab in tabs if tab.id in selected_ids]
+
+    scope = DashboardLayoutScope(tabs_only=request.tabs_only, 
tab_id=selected_tab_id)
+    if request.tabs_only:

Review Comment:
   Small asymmetry I noticed while walking the edge inputs. On a dashboard with 
no tabs, `tab="Anything"` returns the very helpful `tab_not_found` with `This 
dashboard has no tabs. Omit tab to get the full layout.`, but `tabs_only=true` 
on that same dashboard returns a success payload with `tabs: []`, `tab_tree: 
[]` and `charts: []`, so the only thing separating "this dashboard has no tabs" 
from "this dashboard is empty" is `untabbed_chart_count`. I confirmed both 
against a flat dashboard on the running server.
   
   Not a blocker, the field descriptions do say `tabs_only` empties `tabs` and 
`charts` by request. Would the same `DashboardError` shape, pointing at 
`untabbed_only`, be friendlier than an all empty payload here?



##########
superset/mcp_service/dashboard/tool/get_dashboard_layout.py:
##########
@@ -39,13 +39,134 @@
     dashboard_layout_serializer,
     DashboardError,
     DashboardLayout,
+    DashboardLayoutScope,
+    DashboardTab,
+    DashboardTabSummary,
     GetDashboardLayoutRequest,
 )
 from superset.mcp_service.mcp_core import ModelGetInfoCore
 
 logger = logging.getLogger(__name__)
 
 
+def _resolve_tab(
+    tabs: list[DashboardTab], selector: str
+) -> DashboardTab | DashboardError:
+    """Match a tab by ID first, then by exact title."""
+    if not tabs:
+        return DashboardError.create(
+            "This dashboard has no tabs. Omit tab to get the full layout.",
+            "tab_not_found",
+        )
+    matches = [tab for tab in tabs if tab.id == selector] or [
+        tab for tab in tabs if tab.name == selector
+    ]
+    if not matches:
+        return DashboardError.create(
+            "Tab not found. Use tabs_only=true to discover tab IDs and 
titles.",
+            "tab_not_found",
+        )
+    if len(matches) > 1:
+        return DashboardError.create(
+            "Multiple tabs have that title. Use tabs_only=true to discover "
+            "their IDs, then pass a unique tab ID.",
+            "ambiguous_tab",
+        )
+    return matches[0]
+
+
+def _subtree_ids(tab_id: str, children: dict[str | None, list[str]]) -> 
set[str]:
+    """Return a tab ID and the IDs of every tab nested under it."""
+    selected_ids: set[str] = set()
+    pending = [tab_id]
+    while pending:
+        current = pending.pop()
+        if current not in selected_ids:
+            selected_ids.add(current)
+            pending.extend(children.get(current, []))
+    return selected_ids
+
+
+def _tab_summaries(
+    tabs: list[DashboardTab], children: dict[str | None, list[str]]
+) -> list[DashboardTabSummary]:
+    """Summarize tabs with absolute depths from the full dashboard tree."""
+    # The parser only emits reachable tabs and assigns each tab a single
+    # enclosing parent, so walking from the top-level tabs reaches every tab.
+    depths: dict[str, int] = {}
+    stack = [(tab_id, 0) for tab_id in children.get(None, [])]
+    while stack:
+        tab_id, depth = stack.pop()
+        depths[tab_id] = depth
+        stack.extend((child, depth + 1) for child in children.get(tab_id, []))
+    return [
+        DashboardTabSummary(
+            id=tab.id,
+            name=tab.name,
+            parent_tab_id=tab.parent_tab_id,
+            depth=depths[tab.id],
+            chart_count=len(tab.chart_ids),
+        )
+        for tab in tabs
+    ]
+
+
+def _scope_layout(
+    layout: DashboardLayout, request: GetDashboardLayoutRequest
+) -> DashboardLayout | DashboardError:
+    """Project the parsed layout without changing permalink or ancestry 
context.
+
+    Scoping only ever removes tabs and chart placements from the parsed layout,
+    so a scoped response never contains a chart the full layout would not.
+    """
+    if not request.tabs_only and request.tab is None and not 
request.untabbed_only:
+        return layout
+
+    if request.untabbed_only:
+        return layout.model_copy(
+            update={
+                "tabs": [],
+                "charts": [chart for chart in layout.charts if chart.tab_id is 
None],
+                "scope": DashboardLayoutScope(untabbed_only=True),
+            }
+        )
+
+    tabs = layout.tabs
+    children: dict[str | None, list[str]] = {}
+    for tab in tabs:
+        children.setdefault(tab.parent_tab_id, []).append(tab.id)
+
+    selected_tab_id: str | None = None
+    if request.tab is not None:
+        selected = _resolve_tab(tabs, request.tab)
+        if isinstance(selected, DashboardError):
+            return selected
+        selected_tab_id = selected.id
+        selected_ids = _subtree_ids(selected.id, children)
+        tabs = [tab for tab in tabs if tab.id in selected_ids]
+
+    scope = DashboardLayoutScope(tabs_only=request.tabs_only, 
tab_id=selected_tab_id)
+    if request.tabs_only:
+        return layout.model_copy(
+            update={
+                "tabs": [],
+                "tab_tree": _tab_summaries(tabs, children),
+                "charts": [],
+                "scope": scope,
+            }
+        )
+
+    return layout.model_copy(
+        update={
+            "tabs": tabs,
+            "charts": [
+                chart for chart in layout.charts if chart.tab_id in 
selected_ids

Review Comment:
   Just a small NIT, and I know Bito asked for the opposite on the thread 
above. `selected_ids` is now bound only inside `if request.tab is not None`, so 
this line is correct only because the `tabs_only` branch returns above it and 
the no-scope case returns at the top of the function. The empty set default 
Bito objected to was misleading, but a meaningful one is not:
   
   ```python
       selected_ids: set[str] = {tab.id for tab in tabs}
       if request.tab is not None:
           ...
   ```
   
   That keeps the "all tabs" reading if a future branch ever reaches this 
return with `tab` unset, instead of an `UnboundLocalError`. Entirely your call.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to