Source code for recon_gen.common.tree.structure

"""Structural tree types — ``GridSlot`` / ``Sheet`` / ``Analysis`` /
``Dashboard`` / ``App``.

The skeleton the rest of the tree hangs off. Authors construct an
``App``, attach an ``Analysis`` (which holds the sheet tree),
optionally attach a ``Dashboard``, and call ``app.emit_analysis()``
/ ``app.emit_dashboard()`` to get the ``models.py`` instances ready
for deploy.
"""

from __future__ import annotations

from dataclasses import dataclass, field
from typing import Literal

from typing import Protocol, runtime_checkable

from recon_gen.common.config import Config
from recon_gen.common.dataset_contract import get_contract
from recon_gen.common.ids import FilterGroupId, SheetId, VisualId
from recon_gen.common.models import (
    AnalysisDefinition,
    DashboardPublishOptions,
    GridLayoutConfiguration,
    GridLayoutElement,
    Layout,
    LayoutConfiguration,
    ResourcePermission,
    SheetDefinition,
)
from recon_gen.common.models import Analysis as ModelAnalysis
from recon_gen.common.models import Dashboard as ModelDashboard
from recon_gen.common.tree._helpers import (
    ANALYSIS_ACTIONS,
    AUTO,
    AutoResolved,
    DASHBOARD_ACTIONS,
    GridLayoutElementType,
    _AutoSentinel,
    auto_id,
)
from recon_gen.common.tree.actions import Action, Drill
from recon_gen.common.tree.formatting import CellFormat
from recon_gen.common.tree.calc_fields import CalcField
from recon_gen.common.tree.controls import (
    FilterControlLike,
    FilterCrossSheet,
    FilterDateTimePicker,
    FilterDropdown,
    FilterSlider,
    ParameterControlLike,
    ParameterDateTimePicker,
    ParameterDropdown,
    ParameterTextField,
    ParameterSlider,
    SelectableValues,
)
from recon_gen.common.tree.datasets import Column, Dataset
from recon_gen.common.tree.fields import Dim, FieldRef, Measure, row_one_calc_name
from recon_gen.common.tree.filters import FilterGroup, FilterLike
from recon_gen.common.tree.parameters import ParameterDeclLike
from recon_gen.common.tree.text_boxes import TextBox
from recon_gen.common.tree.visuals import (
    KPI,
    BarChart,
    KPIValueSignIndicator,
    KPIValueThresholdBanding,
    KPIValueZeroIndicator,
    LineChart,
    Sankey,
    Table,
    VisualLike,
)


# Field-well slot roles used by the auto-id resolver. The `role`
# letter goes into the auto-derived field_id so the synthesized id
# encodes which well a leaf came from.
_FIELD_SLOTS: tuple[tuple[str, str], ...] = (
    ("group_by", "g"),  # Table (aggregated)
    ("values", "v"),    # KPI / Table / BarChart
    ("columns", "u"),   # Table (unaggregated)
    ("category", "c"),  # BarChart
    ("colors", "k"),    # BarChart (color/group dim)
    ("source", "s"),    # Sankey
    ("target", "t"),    # Sankey
    ("weight", "w"),    # Sankey
)


def _resolve_field_ids(
    *, visual: VisualLike, visual_kind: str, sheet_idx: int, visual_idx: int,
) -> None:
    """Walk a visual's field-well slots and assign auto field_ids to
    any Dim/Measure leaves that left field_id unset.

    Iterates the fixed ``_FIELD_SLOTS`` table — each entry names an
    attribute and a one-letter role tag. Missing attributes (e.g. KPI
    has no ``group_by``) are skipped via ``getattr`` default ``None``.
    Slots may be a single leaf (Sankey ``source`` / ``target`` /
    ``weight``) or a list (KPI / Table / BarChart ``values``); both
    shapes are handled.
    """
    for attr, role in _FIELD_SLOTS:
        slot: object = getattr(visual, attr, None)
        if slot is None:
            continue
        leaves: list[object] = list(slot) if isinstance(slot, list) else [slot]  # type: ignore[arg-type]: list(object) is list of leaves; slot narrowed by isinstance
        for slot_idx, leaf in enumerate(leaves):
            if leaf is None:
                continue
            if isinstance(getattr(leaf, "field_id", None), _AutoSentinel):
                leaf.field_id = auto_id(  # type: ignore[attr-defined]: leaf is one of many typed visual-field shapes (CalcField/Aggregated/etc.); all carry field_id
                    f"f-{visual_kind}-s{sheet_idx}-v{visual_idx}-{role}{slot_idx}"
                )


# ---------------------------------------------------------------------------
# Layout — GridSlot references a LayoutNode by object (locked decision).
# LayoutNode hides QuickSight's split between Visuals and TextBoxes —
# both turn into a GridLayoutElement carrying an id + ElementType, but
# flow into different SheetDefinition fields at emit time.
# ---------------------------------------------------------------------------

[docs] @runtime_checkable class LayoutNode(Protocol): """Anything placeable in a sheet's grid layout. Both typed visual subtypes (``KPI`` / ``Table`` / ``BarChart`` / ``Sankey``) and the typed ``TextBox`` wrapper satisfy this Protocol. Each exposes ``element_id`` (the layout slot's ``ElementId``) and ``element_type`` (``"VISUAL"`` or ``"TEXT_BOX"``) — the slot reads them off the node at emit time. The Protocol means ``Sheet.place(node, ...)`` accepts both visuals and text boxes uniformly; QuickSight's two-list split (Visuals vs TextBoxes in ``SheetDefinition``) stays an emit-time concern that callers never see. """ @property def element_id(self) -> str: ... @property def element_type(self) -> GridLayoutElementType: ...
[docs] @dataclass(eq=False) class GridSlot: """One placement in a sheet's grid layout. Holds an OBJECT reference to the placed ``LayoutNode``. The element id and element type are read off the node at emit time — the slot is agnostic about whether it carries a visual or a text box. """ element: LayoutNode col_span: int row_span: int col_index: int row_index: int | None = None
[docs] def emit(self) -> GridLayoutElement: # v8.6.9 — Card layout padding (12px) on TEXT_BOX elements so # rendered prose doesn't sit flush against the card edges. # Visuals get QS's bare default (no padding) — they self-render # their own internal padding via ChartConfiguration title / # subtitle / data-area styling. padding = "12px" if self.element.element_type == "TEXT_BOX" else None return GridLayoutElement( ElementId=self.element.element_id, ElementType=self.element.element_type, ColumnSpan=self.col_span, RowSpan=self.row_span, ColumnIndex=self.col_index, RowIndex=self.row_index, Padding=padding, )
# --------------------------------------------------------------------------- # Sheet — child of Analysis. # ---------------------------------------------------------------------------
[docs] @dataclass(eq=False) class Sheet: """Tree node for one sheet on an Analysis / Dashboard. Sheet has four concerns: 1. **Metadata** — ``sheet_id``, ``name``, ``title``, ``description`` set at construction. 2. **Layout** — visuals + text boxes placed in a grid. Accessed via ``sheet.layout``: ``sheet.layout.row(height=...).add_kpi(width=, title=, values=, ...)`` for sequential rows, or ``sheet.layout. absolute(col_index=, row_index=, col_span=, row_span=).add_*(...)`` for explicit positioning. The layout's row tracks the column cursor so call sites don't compute ``col_index`` arithmetic by hand. 3. **Controls** — parameter / filter controls live above/aside the canvas (NOT in the grid). Added directly on Sheet via ``sheet.add_parameter_dropdown(...)`` / ``add_parameter_slider`` / ``add_parameter_datetime_picker`` / ``add_filter_dropdown`` / ``add_filter_slider`` / ``add_filter_datetime_picker`` / ``add_filter_cross_sheet`` — one shortcut per control kind. 4. **Scope wiring** — ``sheet.scope(filter_group, [v1, v2])`` scopes a filter group to specific visuals on this sheet. ``emit()`` returns the ``SheetDefinition`` ready to drop into ``AnalysisDefinition.Sheets``. """ sheet_id: SheetId name: str title: str description: str visuals: list[VisualLike] = field(default_factory=list[VisualLike]) parameter_controls: list[ParameterControlLike] = field( default_factory=list[ParameterControlLike], ) filter_controls: list[FilterControlLike] = field(default_factory=list[FilterControlLike]) text_boxes: list[TextBox] = field(default_factory=list[TextBox]) grid_slots: list[GridSlot] = field(default_factory=list["GridSlot"]) # Lazy layout — constructed on first ``sheet.layout`` access, then # cached so the row cursor advances across calls. Init=False keeps # it out of the dataclass constructor surface. _layout: "SheetLayout | None" = field(default=None, init=False, repr=False) def __post_init__(self) -> None: # b.15.invariant.sheet-description: every Sheet carries a # plain-language description (CLAUDE.md tree convention). # Catching a blank/missing description at construction time # beats letting a silently-described sheet through to a # rendered dashboard where reviewers spot it later. if not self.description.strip(): raise ValueError( f"Sheet(name={self.name!r}, sheet_id={self.sheet_id!r}): " f"description is required and must be non-blank — every " f"sheet carries a one-paragraph plain-language " f"description (CLAUDE.md tree convention). Got: " f"{self.description!r}" ) @property def layout(self) -> "SheetLayout": """L.1.21 — Layout namespace for grid placement. Visuals + text boxes are added through the layout (one of: ``sheet.layout.row(height=...).add_kpi(width=..., ...)`` or ``sheet.layout.absolute(col_index=..., row_index=..., col_span=..., row_span=...).add_kpi(...)``). The Sheet itself coordinates four concerns; layout is one of them, factored out so the call sites for "place a visual" don't crowd against "register a control" or "scope a filter group". Lazily constructed; the same SheetLayout instance returns on subsequent accesses so the row cursor advances across calls. """ if self._layout is None: self._layout = SheetLayout(sheet=self) return self._layout # ----------------------------------------------------------------------- # Control + scope shortcuts (L.1.21). Sheet's other concerns beyond # layout: parameter / filter controls (NOT placed in grid — they live # above/aside the canvas) and filter-group scoping (acts on visuals # from this sheet). # -----------------------------------------------------------------------
[docs] def add_parameter_dropdown( self, *, parameter: ParameterDeclLike, title: str, selectable_values: SelectableValues, type: Literal["SINGLE_SELECT", "MULTI_SELECT"] = "SINGLE_SELECT", hidden_select_all: bool = False, cascade_source: ParameterDropdown | None = None, cascade_match_column: Column | None = None, control_id: str | AutoResolved = AUTO, ) -> ParameterDropdown: """Construct + register a parameter dropdown control on this sheet. ``selectable_values`` is required: a parameter dropdown without a source list (StaticValues / LinkedValues) shows only the QuickSight empty-state "All" placeholder, so the user can't actually pick a value — the bound parameter stays unset and any CategoryFilter using it matches nothing. Caught the L1 Daily Statement account-dropdown footgun (v8.3.3 hotfix); the type makes it unrepresentable going forward. """ ctrl = ParameterDropdown( parameter=parameter, title=title, type=type, selectable_values=selectable_values, hidden_select_all=hidden_select_all, cascade_source=cascade_source, cascade_match_column=cascade_match_column, control_id=control_id, ) self.parameter_controls.append(ctrl) return ctrl
[docs] def add_parameter_slider( self, *, parameter: ParameterDeclLike, title: str, minimum_value: float, maximum_value: float, step_size: float, control_id: str | AutoResolved = AUTO, ) -> ParameterSlider: """Construct + register a parameter slider control on this sheet.""" ctrl = ParameterSlider( parameter=parameter, title=title, minimum_value=minimum_value, maximum_value=maximum_value, step_size=step_size, control_id=control_id, ) self.parameter_controls.append(ctrl) return ctrl
[docs] def add_parameter_datetime_picker( self, *, parameter: ParameterDeclLike, title: str, control_id: str | AutoResolved = AUTO, ) -> ParameterDateTimePicker: """Construct + register a parameter datetime picker control.""" ctrl = ParameterDateTimePicker( parameter=parameter, title=title, control_id=control_id, ) self.parameter_controls.append(ctrl) return ctrl
[docs] def add_parameter_text_field( self, *, parameter: ParameterDeclLike, title: str, control_id: str | AutoResolved = AUTO, ) -> ParameterTextField: """Construct + register a free-text parameter input control. Use when the parameter's option universe is unbounded / unknown at deploy time, or when LinkedValues / StaticValues paths fail (X.1.b L2FT cascade Value dropdown hit ``Sample values not found`` from QS's lazy sample-values fetch on cold per-CI-run dashboards). Text input has no equivalent fetch path. """ ctrl = ParameterTextField( parameter=parameter, title=title, control_id=control_id, ) self.parameter_controls.append(ctrl) return ctrl
[docs] def add_filter_dropdown( self, *, filter: FilterLike, title: str, type: Literal["SINGLE_SELECT", "MULTI_SELECT"] = "MULTI_SELECT", selectable_values: SelectableValues | None = None, control_id: str | AutoResolved = AUTO, ) -> FilterDropdown: """Construct + register a filter dropdown control on this sheet.""" ctrl = FilterDropdown( filter=filter, title=title, type=type, selectable_values=selectable_values, control_id=control_id, ) self.filter_controls.append(ctrl) return ctrl
[docs] def add_filter_slider( self, *, filter: FilterLike, title: str, minimum_value: float, maximum_value: float, step_size: float, type: Literal["SINGLE_POINT", "RANGE"] = "RANGE", control_id: str | AutoResolved = AUTO, ) -> FilterSlider: """Construct + register a filter slider control on this sheet.""" ctrl = FilterSlider( filter=filter, title=title, minimum_value=minimum_value, maximum_value=maximum_value, step_size=step_size, type=type, control_id=control_id, ) self.filter_controls.append(ctrl) return ctrl
[docs] def add_filter_datetime_picker( self, *, filter: FilterLike, title: str, type: Literal["SINGLE_VALUED", "DATE_RANGE"] = "DATE_RANGE", control_id: str | AutoResolved = AUTO, ) -> FilterDateTimePicker: """Construct + register a filter datetime picker control.""" ctrl = FilterDateTimePicker( filter=filter, title=title, type=type, control_id=control_id, ) self.filter_controls.append(ctrl) return ctrl
[docs] def add_filter_cross_sheet( self, *, filter: FilterLike, control_id: str | AutoResolved = AUTO, ) -> FilterCrossSheet: """Construct + register a cross-sheet filter control on this sheet.""" ctrl = FilterCrossSheet(filter=filter, control_id=control_id) self.filter_controls.append(ctrl) return ctrl
[docs] def scope( self, fg: FilterGroup, visuals: list[VisualLike], ) -> FilterGroup: """L.1.21 — scope a filter group to specific visuals on this sheet. Reads more naturally than ``fg.scope_visuals(sheet, visuals)`` since the sheet is the contextual subject. Runtime check stays — Python's type system can't track which ``Sheet`` instance a visual was registered on without dependent types. """ return fg.scope_visuals(self, visuals)
[docs] def find_visual( self, *, title: str | None = None, title_contains: str | None = None, visual_id: VisualId | str | None = None, ) -> VisualLike: """Look up a single visual on this sheet by title / partial title / visual id. Designed for e2e + introspection: pass any of the three lookup keys and get the matching node back. Raises if no match or multiple matches — the API forces unambiguity at the call site so tests can rely on the result. Auto-IDs (L.1.8.5) make this the right way to find a visual from outside the tree — IDs are not stable under tree restructuring, but titles + structural position are. """ matches: list[VisualLike] = [] for v in self.visuals: if visual_id is not None and v.visual_id == visual_id: matches.append(v) continue v_title = getattr(v, "title", None) if title is not None and v_title == title: matches.append(v) continue if title_contains is not None and v_title and title_contains in v_title: matches.append(v) continue if not matches: raise ValueError( f"No visual on sheet {self.sheet_id!r} matches " f"title={title!r} title_contains={title_contains!r} " f"visual_id={visual_id!r}" ) if len(matches) > 1: raise ValueError( f"Multiple visuals on sheet {self.sheet_id!r} match " f"title={title!r} title_contains={title_contains!r} " f"visual_id={visual_id!r} — got {len(matches)}; " f"narrow the criteria." ) return matches[0]
[docs] def emit(self) -> SheetDefinition: return SheetDefinition( SheetId=self.sheet_id, Name=self.name, Title=self.title, Description=self.description, ContentType="INTERACTIVE", Visuals=[v.emit() for v in self.visuals] if self.visuals else None, FilterControls=( [fc.emit() for fc in self.filter_controls] if self.filter_controls else [] ), ParameterControls=( [c.emit() for c in self.parameter_controls] if self.parameter_controls else None ), TextBoxes=( [tb.emit() for tb in self.text_boxes] if self.text_boxes else None ), Layouts=[ Layout( Configuration=LayoutConfiguration( GridLayout=GridLayoutConfiguration( Elements=[s.emit() for s in self.grid_slots], # M.4.4.10ab — QS UI emits this on every # GridLayout; its absence breaks the editor. CanvasSizeOptions={ "ScreenCanvasSizeOptions": { "ResizeOption": "FIXED", "OptimizedViewPortWidth": "1600px", }, }, ), ), ), ], )
# --------------------------------------------------------------------------- # L.1.21 — Layout DSL. Sheet's grid layout factored into a separate # ``SheetLayout`` namespace owned by Sheet. Two placement modes: # # - Row-based: ``sheet.layout.row(height=H)`` opens a row at the # current vertical cursor; subsequent ``row.add_<kind>(width=W, # ...)`` calls advance a column cursor and place the visual. # Refuses widths that exceed the 36-col grid. # - Absolute: ``sheet.layout.absolute(col_index=, row_index=, # col_span=, row_span=).add_<kind>(...)`` for one-off explicit # positioning (overlapping visuals, asymmetric grids). # # Both modes construct + register + place the visual atomically on # the parent Sheet — the analyst never holds a half-constructed visual # ref or computes col_index arithmetic by hand. # --------------------------------------------------------------------------- # QuickSight grid is 36 columns wide. Hardcoded here because the QS # model accepts it as a free int but the actual rendering breaks above # 36 (visuals get clipped). _GRID_WIDTH_COLS = 36
[docs] @dataclass(eq=False) class Row: """One horizontal band in a Sheet's grid layout. Tracks the column cursor as visuals + text boxes are added; refuses widths that overflow the 36-column grid. ``height`` becomes every visual's ``row_span``; the column cursor advances by each visual's ``width`` (col_span). A new row from ``sheet.layout.row()`` lands below the previous row — the SheetLayout tracks the vertical cursor. """ sheet: Sheet height: int row_index: int _col_cursor: int = field(default=0, init=False, repr=False) def _consume(self, width: int) -> int: """Allocate ``width`` columns at the current cursor; return the col_index for the slot. Raises if the row would overflow the 36-col grid.""" if self._col_cursor + width > _GRID_WIDTH_COLS: raise ValueError( f"Row width exceeded: {self._col_cursor} + {width} > " f"{_GRID_WIDTH_COLS} cols. Open a new row via " f"sheet.layout.row(height=...) or reduce the visual width." ) col_index = self._col_cursor self._col_cursor += width return col_index
[docs] def add_kpi( self, *, width: int, title: str, values: list[Measure] | None = None, subtitle: str, value_zero_indicator: KPIValueZeroIndicator | None = None, value_sign_indicator: KPIValueSignIndicator | None = None, value_threshold_banding: KPIValueThresholdBanding | None = None, visual_id: VisualId | AutoResolved = AUTO, ) -> KPI: """Construct + register + place a KPI in this row.""" col_index = self._consume(width) kpi = KPI( title=title, subtitle=subtitle, values=values or [], value_zero_indicator=value_zero_indicator, value_sign_indicator=value_sign_indicator, value_threshold_banding=value_threshold_banding, visual_id=visual_id, ) self.sheet.visuals.append(kpi) self.sheet.grid_slots.append(GridSlot( element=kpi, col_span=width, row_span=self.height, col_index=col_index, row_index=self.row_index, )) return kpi
[docs] def add_table( self, *, width: int, title: str, group_by: list[Dim] | None = None, values: list[Measure] | None = None, columns: list[Dim] | None = None, subtitle: str, sort_by: ( tuple[FieldRef, Literal["ASC", "DESC"]] | list[tuple[FieldRef, Literal["ASC", "DESC"]]] | None ) = None, actions: list[Action] | None = None, conditional_formatting: list[CellFormat] | None = None, visual_id: VisualId | AutoResolved = AUTO, ) -> Table: """Construct + register + place a Table in this row. Aggregated mode: pass ``group_by`` + ``values``. Unaggregated mode (raw column display): pass ``columns``. The two modes are mutually exclusive (Table.__post_init__ enforces this).""" col_index = self._consume(width) table = Table( title=title, subtitle=subtitle, group_by=group_by or [], values=values or [], columns=columns or [], sort_by=sort_by, actions=actions or [], conditional_formatting=conditional_formatting, visual_id=visual_id, ) self.sheet.visuals.append(table) self.sheet.grid_slots.append(GridSlot( element=table, col_span=width, row_span=self.height, col_index=col_index, row_index=self.row_index, )) return table
[docs] def add_bar_chart( self, *, width: int, title: str, category: list[Dim] | None = None, values: list[Measure] | None = None, colors: list[Dim] | None = None, subtitle: str, orientation: Literal["HORIZONTAL", "VERTICAL"] | None = None, bars_arrangement: Literal[ "CLUSTERED", "STACKED", "STACKED_PERCENT", ] | None = None, category_label: str | None = None, value_label: str | None = None, color_label: str | None = None, sort_by: tuple[FieldRef, Literal["ASC", "DESC"]] | None = None, actions: list[Action] | None = None, log_scale: bool = False, visual_id: VisualId | AutoResolved = AUTO, ) -> BarChart: """Construct + register + place a BarChart in this row.""" col_index = self._consume(width) bar = BarChart( title=title, subtitle=subtitle, category=category or [], values=values or [], colors=colors or [], orientation=orientation, bars_arrangement=bars_arrangement, category_label=category_label, value_label=value_label, color_label=color_label, sort_by=sort_by, actions=actions or [], log_scale=log_scale, visual_id=visual_id, ) self.sheet.visuals.append(bar) self.sheet.grid_slots.append(GridSlot( element=bar, col_span=width, row_span=self.height, col_index=col_index, row_index=self.row_index, )) return bar
[docs] def add_line_chart( self, *, width: int, title: str, category: list[Dim] | None = None, values: list[Measure] | None = None, colors: list[Dim] | None = None, subtitle: str, chart_type: Literal["LINE", "AREA", "STACKED_AREA"] | None = None, category_label: str | None = None, value_label: str | None = None, sort_by: tuple[FieldRef, Literal["ASC", "DESC"]] | None = None, actions: list[Action] | None = None, visual_id: VisualId | AutoResolved = AUTO, ) -> LineChart: """Construct + register + place a LineChart in this row.""" col_index = self._consume(width) line = LineChart( title=title, subtitle=subtitle, category=category or [], values=values or [], colors=colors or [], chart_type=chart_type, category_label=category_label, value_label=value_label, sort_by=sort_by, actions=actions or [], visual_id=visual_id, ) self.sheet.visuals.append(line) self.sheet.grid_slots.append(GridSlot( element=line, col_span=width, row_span=self.height, col_index=col_index, row_index=self.row_index, )) return line
[docs] def add_sankey( self, *, width: int, title: str, source: Dim, target: Dim, weight: Measure, subtitle: str, items_limit: int | None = None, actions: list[Action] | None = None, visual_id: VisualId | AutoResolved = AUTO, ) -> Sankey: """Construct + register + place a Sankey in this row.""" col_index = self._consume(width) sankey = Sankey( title=title, subtitle=subtitle, source=source, target=target, weight=weight, items_limit=items_limit, actions=actions or [], visual_id=visual_id, ) self.sheet.visuals.append(sankey) self.sheet.grid_slots.append(GridSlot( element=sankey, col_span=width, row_span=self.height, col_index=col_index, row_index=self.row_index, )) return sankey
[docs] def add_text_box( self, text_box: TextBox, *, width: int, ) -> TextBox: """Register + place a pre-constructed TextBox in this row. TextBox content is verbose XML (built via ``common/rich_text``), so the analyst constructs the TextBox separately and passes it here. Uniqueness is by object identity — placing the same TextBox in two rows is a programmer error caught by the row- cursor advance (you'd be calling _consume twice). """ col_index = self._consume(width) if text_box not in self.sheet.text_boxes: self.sheet.text_boxes.append(text_box) self.sheet.grid_slots.append(GridSlot( element=text_box, col_span=width, row_span=self.height, col_index=col_index, row_index=self.row_index, )) return text_box
[docs] @dataclass(eq=False) class AbsoluteSlot: """One explicit-position slot. Use for layouts that don't fit the row pattern — overlapping visuals, asymmetric grids, off-grid positioning. One-shot: construct via ``sheet.layout.absolute( col_index=, row_index=, col_span=, row_span=)`` then chain a single ``add_<kind>(...)`` to fill it. Re-using an AbsoluteSlot for multiple visuals would emit duplicate slot positions and isn't supported.""" sheet: Sheet col_span: int row_span: int col_index: int row_index: int | None def _place(self, element: LayoutNode) -> None: self.sheet.grid_slots.append(GridSlot( element=element, col_span=self.col_span, row_span=self.row_span, col_index=self.col_index, row_index=self.row_index, ))
[docs] def add_kpi( self, *, title: str, values: list[Measure] | None = None, subtitle: str, value_zero_indicator: KPIValueZeroIndicator | None = None, value_sign_indicator: KPIValueSignIndicator | None = None, value_threshold_banding: KPIValueThresholdBanding | None = None, visual_id: VisualId | AutoResolved = AUTO, ) -> KPI: kpi = KPI( title=title, subtitle=subtitle, values=values or [], value_zero_indicator=value_zero_indicator, value_sign_indicator=value_sign_indicator, value_threshold_banding=value_threshold_banding, visual_id=visual_id, ) self.sheet.visuals.append(kpi) self._place(kpi) return kpi
[docs] def add_table( self, *, title: str, group_by: list[Dim] | None = None, values: list[Measure] | None = None, columns: list[Dim] | None = None, subtitle: str, sort_by: ( tuple[FieldRef, Literal["ASC", "DESC"]] | list[tuple[FieldRef, Literal["ASC", "DESC"]]] | None ) = None, actions: list[Action] | None = None, conditional_formatting: list[CellFormat] | None = None, visual_id: VisualId | AutoResolved = AUTO, ) -> Table: table = Table( title=title, subtitle=subtitle, group_by=group_by or [], values=values or [], columns=columns or [], sort_by=sort_by, actions=actions or [], conditional_formatting=conditional_formatting, visual_id=visual_id, ) self.sheet.visuals.append(table) self._place(table) return table
[docs] def add_bar_chart( self, *, title: str, category: list[Dim] | None = None, values: list[Measure] | None = None, subtitle: str, orientation: Literal["HORIZONTAL", "VERTICAL"] | None = None, bars_arrangement: Literal[ "CLUSTERED", "STACKED", "STACKED_PERCENT", ] | None = None, sort_by: tuple[FieldRef, Literal["ASC", "DESC"]] | None = None, actions: list[Action] | None = None, log_scale: bool = False, visual_id: VisualId | AutoResolved = AUTO, ) -> BarChart: bar = BarChart( title=title, subtitle=subtitle, category=category or [], values=values or [], orientation=orientation, bars_arrangement=bars_arrangement, sort_by=sort_by, actions=actions or [], log_scale=log_scale, visual_id=visual_id, ) self.sheet.visuals.append(bar) self._place(bar) return bar
[docs] def add_line_chart( self, *, title: str, category: list[Dim] | None = None, values: list[Measure] | None = None, colors: list[Dim] | None = None, subtitle: str, chart_type: Literal["LINE", "AREA", "STACKED_AREA"] | None = None, category_label: str | None = None, value_label: str | None = None, sort_by: tuple[FieldRef, Literal["ASC", "DESC"]] | None = None, actions: list[Action] | None = None, visual_id: VisualId | AutoResolved = AUTO, ) -> LineChart: line = LineChart( title=title, subtitle=subtitle, category=category or [], values=values or [], colors=colors or [], chart_type=chart_type, category_label=category_label, value_label=value_label, sort_by=sort_by, actions=actions or [], visual_id=visual_id, ) self.sheet.visuals.append(line) self._place(line) return line
[docs] def add_sankey( self, *, title: str, source: Dim, target: Dim, weight: Measure, subtitle: str, items_limit: int | None = None, actions: list[Action] | None = None, visual_id: VisualId | AutoResolved = AUTO, ) -> Sankey: sankey = Sankey( title=title, subtitle=subtitle, source=source, target=target, weight=weight, items_limit=items_limit, actions=actions or [], visual_id=visual_id, ) self.sheet.visuals.append(sankey) self._place(sankey) return sankey
[docs] def add_text_box(self, text_box: TextBox) -> TextBox: if text_box not in self.sheet.text_boxes: self.sheet.text_boxes.append(text_box) self._place(text_box) return text_box
[docs] @dataclass(eq=False) class SheetLayout: """Layout namespace on a Sheet — manages rows + absolute placements. Tracks a vertical row cursor: each ``row(height=H)`` opens a new row at the current cursor and advances it by ``H`` for the next row. Absolute placements don't advance the cursor (they're independent of row flow). """ sheet: Sheet _row_cursor: int = field(default=0, init=False, repr=False)
[docs] def row(self, *, height: int) -> Row: """Open a new row at the current vertical cursor with the given height. The cursor advances by ``height`` so the next ``row()`` call lands below this one.""" row = Row(sheet=self.sheet, height=height, row_index=self._row_cursor) self._row_cursor += height return row
[docs] def absolute( self, *, col_index: int, row_index: int | None = None, col_span: int, row_span: int, ) -> AbsoluteSlot: """Open an explicit-position slot. Doesn't advance the row cursor — absolute placements are independent of row flow.""" return AbsoluteSlot( sheet=self.sheet, col_span=col_span, row_span=row_span, col_index=col_index, row_index=row_index, )
# --------------------------------------------------------------------------- # Analysis — owns the sheet tree; emits AnalysisDefinition. The wrapping # (AnalysisId / AwsAccountId / Permissions / ThemeArn) is supplied by # the App. # ---------------------------------------------------------------------------
[docs] @dataclass(eq=False) class Analysis: """Tree node for the Analysis-level structure. ``analysis_id_suffix`` is the part the App's ``cfg.prefixed()`` will prepend to (e.g. ``"investigation-analysis"`` becomes ``"qs-gen-investigation-analysis"``). Keeping the suffix on the tree node keeps the per-app naming under the tree's control while leaving the global resource-prefix in the Config. ``emit_definition()`` returns the ``models.AnalysisDefinition`` — the App combines this with metadata (``AwsAccountId``, ``ThemeArn``, ``Permissions``, dataset declarations) to produce the full ``models.Analysis`` ready for deploy. """ analysis_id_suffix: str name: str sheets: list[Sheet] = field(default_factory=list["Sheet"]) parameters: list[ParameterDeclLike] = field(default_factory=list[ParameterDeclLike]) filter_groups: list[FilterGroup] = field(default_factory=list[FilterGroup]) calc_fields: list[CalcField] = field(default_factory=list[CalcField]) # Phase BM — the BL.2 ``default_universal_date_range`` field # dissolved when date pushdown moved into the dataset SQL itself # (every date-scoped dataset now declares its own DateTime dataset # params with `StaticValues` defaults; App2's substitution path # already picks those up at render time without any analysis-side # bridge). # DataSetIdentifierDeclarations come from the App at emit time
[docs] def add_sheet(self, sheet: Sheet) -> Sheet: if any(s.sheet_id == sheet.sheet_id for s in self.sheets): raise ValueError( f"Sheet {sheet.sheet_id!r} is already on this Analysis" ) self.sheets.append(sheet) return sheet
[docs] def add_parameter[T: ParameterDeclLike](self, param: T) -> T: """Declare a parameter on this analysis. Construction-time check: parameter names are unique within the analysis. Catches the silent shadow bug where two declarations share a Name and only one wins at deploy time. Generic over the concrete subtype so the returned ref keeps its type (``StringParam`` / ``IntegerParam`` / ``DateTimeParam``) (PEP 695). """ if any(p.name == param.name for p in self.parameters): raise ValueError( f"Parameter {param.name!r} is already declared on this Analysis" ) self.parameters.append(param) return param
[docs] def add_filter_group(self, fg: FilterGroup) -> FilterGroup: """Register a filter group on this analysis. Construction-time check: explicit filter group IDs are unique. (Auto-IDs are unique by construction — assigned from the index in the analysis's filter_groups list — so the check only applies when callers passed an explicit id.) """ if not isinstance(fg.filter_group_id, _AutoSentinel) and any( existing.filter_group_id == fg.filter_group_id for existing in self.filter_groups ): raise ValueError( f"FilterGroup {fg.filter_group_id!r} is already on this Analysis" ) self.filter_groups.append(fg) return fg
[docs] def find_sheet( self, *, name: str | None = None, sheet_id: SheetId | str | None = None, ) -> Sheet: """Look up a single sheet on this analysis by name or sheet id. Raises on no-match or multi-match. Sheet IDs stay explicit (URL-facing per the L.1.8.5 mixed scheme) so passing ``sheet_id=`` is the most robust lookup; ``name=`` is the next-best for tests that don't want to hardcode IDs. """ matches: list[Sheet] = [] for s in self.sheets: if sheet_id is not None and s.sheet_id == sheet_id: matches.append(s) continue if name is not None and s.name == name: matches.append(s) continue if not matches: raise ValueError( f"No sheet on Analysis {self.name!r} matches " f"name={name!r} sheet_id={sheet_id!r}" ) if len(matches) > 1: raise ValueError( f"Multiple sheets on Analysis {self.name!r} match " f"name={name!r} sheet_id={sheet_id!r} — got {len(matches)}." ) return matches[0]
[docs] def find_filter_group( self, *, filter_group_id: FilterGroupId | str | None = None, ) -> FilterGroup: """Look up a single filter group by id (auto or explicit).""" matches = [ fg for fg in self.filter_groups if fg.filter_group_id == filter_group_id ] if not matches: raise ValueError( f"No filter group on Analysis {self.name!r} with " f"filter_group_id={filter_group_id!r}" ) return matches[0]
[docs] def find_calc_field(self, *, name: str) -> CalcField: """Look up a single calc field by name.""" matches = [c for c in self.calc_fields if c.name == name] if not matches: raise ValueError( f"No calc field on Analysis {self.name!r} named {name!r}" ) return matches[0]
[docs] def find_parameter(self, *, name: str) -> ParameterDeclLike: """Look up a single parameter declaration by name.""" matches = [p for p in self.parameters if p.name == name] if not matches: raise ValueError( f"No parameter on Analysis {self.name!r} named {name!r}" ) return matches[0]
[docs] def add_calc_field(self, calc: CalcField) -> CalcField: """Register a calculated field on this analysis. Construction-time check: calc field names are unique within the analysis. Two calc fields sharing a Name silently let one win at deploy time — same shadow-bug class as parameters / filter groups / datasets. """ if any(c.name == calc.name for c in self.calc_fields): raise ValueError( f"CalcField {calc.name!r} is already on this Analysis" ) self.calc_fields.append(calc) return calc
[docs] def datasets(self) -> set[Dataset]: """Walk the analysis tree and return every Dataset referenced by any visual, filter group, or registered calc field. Used by App.dataset_dependencies to derive the precise refresh set. Visuals using the spike-shape ``VisualNode`` factory wrapper don't expose their dataset refs (the factory hides them). Typed Visual subtypes (``KPI`` / ``Table`` / ``BarChart`` / ``Sankey``) all expose ``datasets()`` and contribute. The spike-shape gap closes once apps port to typed subtypes (L.2/L.3/L.4). Registered CalcFields contribute too — their ``Dataset`` ref becomes a dep even if no visual directly references the underlying columns. """ deps: set[Dataset] = set() for sheet in self.sheets: for visual in sheet.visuals: deps.update(visual.datasets()) # Parameter / filter controls with LinkedValues populate # from a Dataset — that's a dep too. for pctrl in sheet.parameter_controls: deps.update(pctrl.datasets()) for fctrl in sheet.filter_controls: deps.update(fctrl.datasets()) for fg in self.filter_groups: deps.update(fg.datasets()) for calc in self.calc_fields: deps.add(calc.dataset) return deps
[docs] def calc_fields_referenced(self) -> set[CalcField]: """Walk the analysis tree and return every CalcField referenced by any visual or filter group. Distinct from ``self.calc_fields`` (the registry): this returns only the calc fields actually used. Catches "calc field declared but never used" (registered but not in this set) and "calc field used but not declared" (in this set but not in the registry — App._validate_calc_field_ references raises on emit). """ deps: set[CalcField] = set() for sheet in self.sheets: for visual in sheet.visuals: deps.update(visual.calc_fields()) for fg in self.filter_groups: deps.update(fg.calc_fields()) return deps
[docs] def emit_definition( self, *, datasets: list[Dataset], ) -> AnalysisDefinition: return AnalysisDefinition( DataSetIdentifierDeclarations=[ d.emit_declaration() for d in datasets ], Sheets=[s.emit() for s in self.sheets], FilterGroups=( [fg.emit() for fg in self.filter_groups] if self.filter_groups else None ), CalculatedFields=( [c.emit() for c in self.calc_fields] if self.calc_fields else None ), ParameterDeclarations=( [p.emit() for p in self.parameters] if self.parameters else None ), # M.4.4.10ab — three top-level fields QS UI populates on # every analysis. Their absence loads but breaks the editor # when adding visuals/sheets. Shapes mirror the QS-UI # control analysis verified against on 2026-04-29. Options={ "WeekStart": "SUNDAY", "QBusinessInsightsStatus": "DISABLED", "ExcludedDataSetArns": [], "CustomActionDefaults": { "highlightOperation": { "Trigger": "DATA_POINT_CLICK", }, }, }, AnalysisDefaults={ "DefaultNewSheetConfiguration": { "InteractiveLayoutConfiguration": { "Grid": { "CanvasSizeOptions": { "ScreenCanvasSizeOptions": { "ResizeOption": "FIXED", "OptimizedViewPortWidth": "1600px", }, }, }, }, "SheetContentType": "INTERACTIVE", }, }, # QueryExecutionOptions: NOT EMITTED. boto3 1.42.97's model # claims the field is accepted on CreateAnalysis but the # serializer raises KeyError mid-serialize. Both with and # without parameter_validation=False. QS auto-fills the # field server-side to {QueryExecutionMode: AUTO} anyway — # the working hand-built control analysis showed the same # value on describe even though it was never sent. )
# --------------------------------------------------------------------------- # Dashboard — references an Analysis (object ref) so they share the # same definition. # ---------------------------------------------------------------------------
[docs] @dataclass(eq=False) class Dashboard: """Tree node for a Dashboard. Carries an object reference to the ``Analysis`` whose definition this Dashboard publishes. ``dashboard_id_suffix`` follows the same pattern as ``Analysis.analysis_id_suffix`` — App's ``cfg.prefixed()`` prepends the project resource prefix. ``analysis`` is the SAME tree node the App owns; the Dashboard re-emits the same definition the Analysis produces, which matches the existing ``build_dashboard(cfg)`` pattern in the per-app builders. """ dashboard_id_suffix: str name: str analysis: Analysis
# --------------------------------------------------------------------------- # App — top-level tree node. # ---------------------------------------------------------------------------
[docs] @dataclass(eq=False) class App: """Top-level tree node — coordinates an Analysis + Dashboard plus the deploy-time context (theme, dataset arns, permissions) drawn from the Config. Authors construct an App, attach the Analysis (which holds the sheet tree), optionally attach the Dashboard (most apps do — they publish what they author), and call ``emit_analysis()`` / ``emit_dashboard()`` to get the ``models.py`` instances ready for deploy. Datasets are registered on the App via ``add_dataset()`` and referenced from visuals / filters by object ref. At emit time the App walks the tree's ``dataset_dependencies()`` and includes only the datasets actually used in the emitted ``DataSetIdentifierDeclarations`` — selective by construction. Validation: if a visual or filter references a Dataset that isn't registered on the App, ``emit_analysis`` raises with the offending identifiers. """ name: str cfg: Config analysis: Analysis | None = None dashboard: Dashboard | None = None datasets: list[Dataset] = field(default_factory=list[Dataset]) # Bare-string column refs (``Dim(ds, "amount")`` instead of # ``ds["amount"].dim()``) are typo-prone — they bypass the dataset # contract validation. ``emit_analysis`` raises on any bare-string # column ref unless this flag is set. Test fixtures + datasets # without a registered contract (kitchen-sink) opt in via # ``allow_bare_strings=True``. allow_bare_strings: bool = False
[docs] def set_analysis(self, analysis: Analysis) -> Analysis: self.analysis = analysis return analysis
[docs] def create_dashboard( self, *, dashboard_id_suffix: str, name: str, ) -> Dashboard: """Construct + register a Dashboard against the App's already-set Analysis. The App owns the Analysis already; this shortcut prevents the analysis-mismatch bug class by construction — there's no opening to pass a different Analysis. """ if self.analysis is None: raise ValueError( "Cannot create_dashboard before set_analysis — " "the dashboard publishes the App's Analysis." ) dashboard = Dashboard( dashboard_id_suffix=dashboard_id_suffix, name=name, analysis=self.analysis, ) self.dashboard = dashboard return dashboard
[docs] def add_dataset(self, dataset: Dataset) -> Dataset: """Register a Dataset on the App. Construction-time check: dataset identifiers are unique within the app. Catches the silent shadow bug where two registrations share an identifier and only one wins at deploy. """ if any(d.identifier == dataset.identifier for d in self.datasets): raise ValueError( f"Dataset {dataset.identifier!r} is already registered on this App" ) self.datasets.append(dataset) return dataset
[docs] def dataset_dependencies(self) -> set[Dataset]: """The set of Datasets referenced anywhere in the App's tree. Walks the Analysis (sheets → visuals + filter_groups). Each typed Visual subtype + typed Filter wrapper exposes its own ``datasets()`` set; the App unions them. **Deployment side effect.** This set drives: - selective deploy (only re-create / refresh the datasets downstream of an actual change), - matview REFRESH ordering (REFRESH only the matviews backing datasets that the changed deploy surface depends on). Returns an empty set when the App has no Analysis. """ if self.analysis is None: return set() return self.analysis.datasets()
[docs] def find_sheet( self, *, name: str | None = None, sheet_id: SheetId | str | None = None, ) -> Sheet: """Convenience pass-through to ``app.analysis.find_sheet(...)``.""" if self.analysis is None: raise ValueError( f"App {self.name!r} has no Analysis — can't find sheets." ) return self.analysis.find_sheet(name=name, sheet_id=sheet_id)
[docs] def resolve_auto_ids(self) -> None: """Walk the tree and assign auto-IDs to nodes that left their IDs unset. Called from emit_analysis / emit_dashboard before any validation or emission, and exposed publicly so non-QS renderers (HTML, future X.4 editor) can resolve IDs without going through the full QS emit path. Idempotent — re-runs are no-ops once IDs are filled in. Mixed scheme (L.1.8.5 + L.1.16): URL-facing IDs (``SheetId``, ``ParameterName``) and analyst-facing identifiers (``Dataset`` identifier) stay explicit. Internal IDs the analyst never types — visual_id, filter_id, control_id, action_id, field_id, calc-field name — get tree-position-derived defaults when omitted. Auto-ID formats: - Visual: ``v-{kind}-s{sheet_idx}-{visual_idx}`` - FilterGroup: ``fg-{idx}`` — analysis-scoped - Filter: ``f-{kind}-fg{fg_idx}-{filt_idx}`` - ParameterControl: ``pc-{kind}-s{sheet_idx}-{ctrl_idx}`` - FilterControl: ``fc-{kind}-s{sheet_idx}-{ctrl_idx}`` - Drill action: ``act-s{sheet_idx}-v{visual_idx}-{action_idx}`` - Field-well leaf (Dim/Measure): ``f-{visual_kind}-s{sheet_idx} -v{visual_idx}-{role}{slot_idx}`` where role tags the field well slot (``g`` group_by, ``v`` values, ``c`` category, ``s`` source, ``t`` target, ``w`` weight) - CalcField: ``calc-{idx}`` — analysis-scoped Same-sheet drills also get their target_sheet back-filled here (Drill.target_sheet=AUTO means "the sheet that owns me"). """ if self.analysis is None: return for sheet_idx, sheet in enumerate(self.analysis.sheets): for visual_idx, visual in enumerate(sheet.visuals): kind = getattr(visual, "_AUTO_KIND", None) current = getattr(visual, "visual_id", None) if kind is not None and isinstance(current, _AutoSentinel): visual.visual_id = VisualId( auto_id(f"v-{kind}-s{sheet_idx}-{visual_idx}"), ) # Field-well leaves — Dim/Measure get position-indexed # field_ids. Walk the slots that exist on this visual # type; missing attributes (e.g. KPI has no group_by) # are skipped via getattr default. _resolve_field_ids( visual=visual, visual_kind=kind or "v", sheet_idx=sheet_idx, visual_idx=visual_idx, ) # Drill action IDs (sheet+visual scoped). Same-sheet # drills (target_sheet=AUTO at construction) get the # owning sheet back-filled here — the cycle closes the # same time IDs resolve. actions = getattr(visual, "actions", None) if actions: for action_idx, action in enumerate(actions): if isinstance(action.action_id, _AutoSentinel): action.action_id = auto_id( f"act-s{sheet_idx}-v{visual_idx}-{action_idx}" ) if hasattr(action, "target_sheet") and isinstance( action.target_sheet, _AutoSentinel, ): action.target_sheet = sheet # Parameter controls — auto-IDs scoped to the sheet. for ctrl_idx, ctrl in enumerate(sheet.parameter_controls): kind = getattr(ctrl, "_AUTO_KIND", None) if kind is not None and isinstance( getattr(ctrl, "control_id", None), _AutoSentinel, ): ctrl.control_id = auto_id( f"pc-{kind}-s{sheet_idx}-{ctrl_idx}" ) # Filter controls — auto-IDs scoped to the sheet. for ctrl_idx, ctrl in enumerate(sheet.filter_controls): kind = getattr(ctrl, "_AUTO_KIND", None) if kind is not None and isinstance( getattr(ctrl, "control_id", None), _AutoSentinel, ): ctrl.control_id = auto_id( f"fc-{kind}-s{sheet_idx}-{ctrl_idx}" ) for fg_idx, fg in enumerate(self.analysis.filter_groups): if isinstance(fg.filter_group_id, _AutoSentinel): fg.filter_group_id = FilterGroupId(auto_id(f"fg-{fg_idx}")) for filt_idx, filt in enumerate(fg.filters): kind = getattr(filt, "_AUTO_KIND", None) if kind is not None and isinstance( getattr(filt, "filter_id", None), _AutoSentinel, ): filt.filter_id = auto_id( f"f-{kind}-fg{fg_idx}-{filt_idx}" ) # BL.1 — auto-register the literal-1 CalcField that backs # ``Measure.kind == "count"`` row-count semantics. One CalcField # per ``Dataset`` referenced by a count Measure. Runs BEFORE # the calc-field name resolver below so the auto-registered # CalcFields' explicit names survive (they don't pass through # the auto-name sentinel path). See # ``recon_gen.common.tree.fields.row_one_calc_name``. count_datasets: set[Dataset] = set() for sheet in self.analysis.sheets: for visual in sheet.visuals: for attr, _role in _FIELD_SLOTS: slot: object = getattr(visual, attr, None) if slot is None: continue leaves: list[object] = ( list(slot) if isinstance(slot, list) else [slot] # type: ignore[arg-type]: list(object) is list of leaves; slot narrowed by isinstance ) for leaf in leaves: if isinstance(leaf, Measure) and leaf.kind == "count": count_datasets.add(leaf.dataset) existing_calc_names = { c.name for c in self.analysis.calc_fields if not isinstance(c.name, _AutoSentinel) } for dataset in sorted(count_datasets, key=lambda d: d.identifier): name = row_one_calc_name(dataset) if name not in existing_calc_names: self.analysis.calc_fields.append(CalcField( dataset=dataset, expression="1", name=name, )) # CalcField names — analysis-scoped position index. KEPT AS # SLUG: calc field names are analyst-facing (they show in the # field-well dropdowns and visual subtitles); UUIDs would be # unreadable. QS doesn't seem to require UUID-shape for these. for calc_idx, calc in enumerate(self.analysis.calc_fields): if isinstance(calc.name, _AutoSentinel): calc.name = f"calc-{calc_idx}"
def _validate_dataset_references(self) -> None: """Raise if the tree references any Dataset not registered on this App. Catches "visual references undeclared dataset" at emit time, where the existing string-keyed pattern would let the mismatch flow through to deploy.""" referenced = self.dataset_dependencies() registered = set(self.datasets) unregistered = referenced - registered if unregistered: ids = sorted(d.identifier for d in unregistered) raise ValueError( f"App {self.name!r} references unregistered datasets: " f"{ids} — register each via app.add_dataset() first." ) def _validate_parameter_references(self) -> None: """Raise if any ParameterDeclLike reference in the tree (control bindings, NumericRangeFilter parameter bounds) points at a parameter that isn't registered on the analysis. Same shadow-bug class as datasets and calc fields: a typed parameter ref with .name set but never registered on the analysis would emit a SourceParameterName / Parameter binding that QuickSight resolves to "no such parameter" silently — controls don't drive their bound parameter, filters don't narrow. DrillParam (in K.2 ``common/drill.py``) takes a string ParameterName — those aren't validated here. Closing that gap requires a typed-parameter-ref refactor of DrillParam, queued as L.1.x follow-up. """ if self.analysis is None: return registered_params = self.analysis.parameters bad: list[str] = [] def _check(param: ParameterDeclLike | None, where: str) -> None: if param is None: return if not any(p is param for p in registered_params): bad.append( f"{where} → parameter {param.name!r} not registered" ) for sheet in self.analysis.sheets: for ctrl in sheet.parameter_controls: p = getattr(ctrl, "parameter", None) _check(p, f"sheet {sheet.sheet_id!r} parameter control") for fg in self.analysis.filter_groups: for f in fg.filters: _check( getattr(f, "minimum_parameter", None), f"filter {f.filter_id!r} minimum_parameter", ) _check( getattr(f, "maximum_parameter", None), f"filter {f.filter_id!r} maximum_parameter", ) if bad: raise ValueError( f"App {self.name!r} has parameter references that aren't " f"registered on the analysis: {bad} — call " f"analysis.add_parameter() first." ) def _validate_filter_param_settability(self) -> None: """Raise if any filter binds a parameter the analyst can't set. A parameter-bound filter (CategoryFilter.with_parameter, TimeEqualityFilter, NumericRangeFilter with minimum_parameter/maximum_parameter) where the bound parameter has NO settable control AND NO non-empty default produces a WHERE clause that matches nothing at runtime. The visual renders with the dataset narrowed to zero rows — every KPI blank, every table empty, no error message anywhere. Settable means EITHER: - At least one ParameterControl on the analysis targets the parameter (any kind: Dropdown, Slider, DateTimePicker, etc.) - The parameter declaration carries a non-empty default (StringParam.default non-empty list, IntegerParam.default non-empty list, DateTimeParam.default always present) Caught the v8.3.3 Daily Statement bug class at the App.emit level (the ParameterDropdown type tightening catches the construction-time slice; this validator catches the structural slice — "no dropdown wired at all"). Pairs with the v8.3.3 type-system change as belt + suspenders. TimeRangeFilter's ``minimum``/``maximum`` dict-form ``{"Parameter": name}`` bindings are not walked here — those carry a string ParameterName, not a typed ParameterDeclLike, so the cross-reference would have to lookup-by-name. The existing shipped uses all bind to DateTimeParams with RollingDate defaults, so the gap is theoretical for now. """ if self.analysis is None: return # Set of parameter NAMES that have at least one control on the analysis. controlled_param_names: set[str] = set() for sheet in self.analysis.sheets: for ctrl in sheet.parameter_controls: p = getattr(ctrl, "parameter", None) if p is not None: controlled_param_names.add(str(p.name)) # Set of parameter NAMES that carry a non-empty default. # StringParam / IntegerParam: default is a list — non-empty means # the user picked at least one value at parameter declaration. # DateTimeParam: default is always a non-None DateTimeDefaultValues # (M.4.4.10d type-required), so always counts as defaulted. defaulted_param_names: set[str] = set() for p in self.analysis.parameters: d: object = getattr(p, "default", None) if d is None: continue if isinstance(d, list) and not d: continue defaulted_param_names.add(str(p.name)) # Walk every parameter-bound filter; collect any whose # parameter is neither controlled nor defaulted. bad: list[str] = [] def _check( param: ParameterDeclLike | None, where: str, ) -> None: if param is None: return name = str(param.name) if name in controlled_param_names or name in defaulted_param_names: return bad.append( f"{where} → parameter {name!r} has no control on the " f"analysis AND no non-empty default — analyst can't set " f"it, so the filter matches nothing at runtime" ) from recon_gen.common.tree.filters import _ParameterBinding for fg in self.analysis.filter_groups: for f in fg.filters: # CategoryFilter discriminates via .binding; only the # parameter-bound variant carries a ParameterDeclLike. binding = getattr(f, "binding", None) if isinstance(binding, _ParameterBinding): _check(binding.parameter, f"filter {f.filter_id!r}") # TimeEqualityFilter + NumericRangeFilter expose # parameter / minimum_parameter / maximum_parameter directly. _check( getattr(f, "parameter", None), f"filter {f.filter_id!r} parameter", ) _check( getattr(f, "minimum_parameter", None), f"filter {f.filter_id!r} minimum_parameter", ) _check( getattr(f, "maximum_parameter", None), f"filter {f.filter_id!r} maximum_parameter", ) if bad: raise ValueError( f"App {self.name!r} has parameter-bound filters whose " f"parameter is unsettable (no control + no default). " f"Either add a parameter control on a sheet, give the " f"parameter a non-empty default, or use a non-parameter " f"filter form (with_values / with_literal): {bad}" ) def _validate_drill_destinations(self) -> None: """Raise if any Drill action targets a Sheet that isn't on this App's Analysis. Catches "drill into a sheet that doesn't exist" at emit time. The string-only ``target_sheet=SheetId(...)`` pattern lets typos through to deploy where the click silently does nothing. Sheet identity check uses ``is`` rather than ``in``/``set`` because Sheet's dataclass-generated ``__eq__`` compares fields and Sheet isn't hashable — but we want OBJECT identity here, not field equality. """ if self.analysis is None: return registered_sheets = self.analysis.sheets bad: list[str] = [] for sheet in registered_sheets: for visual in sheet.visuals: actions: list[Action] = getattr(visual, "actions", None) or [] # Only Drill actions navigate to a sheet; SameSheetFilter # actions target visuals on the current sheet and don't # carry a target_sheet field. for action in actions: if not isinstance(action, Drill): continue if not any( action.target_sheet is s for s in registered_sheets ): target_sheet_id = ( action.target_sheet.sheet_id if not isinstance(action.target_sheet, _AutoSentinel) else "<unset>" ) bad.append( f"action {action.name!r} on visual " f"{getattr(visual, 'visual_id', '?')!r} → sheet " f"{target_sheet_id!r}" ) if bad: raise ValueError( f"App {self.name!r} has drill actions targeting sheets that " f"aren't registered on the analysis: {bad}" ) def _validate_no_bare_string_columns(self) -> None: """Raise if any tree node uses an unvalidated column ref. Two unvalidated forms exist, both gated by ``allow_bare_strings``: - **Bare string**: ``Dim(ds, "amount")`` — a literal string that bypasses any contract check. Typo-prone. - **Unvalidated Column**: ``ds["amount"]`` against a dataset with no registered ``DatasetContract``. ``Dataset.__getitem__`` can't validate when no contract exists, so it returns a Column without checking. The walker catches this here so the silent-pass path turns into a loud raise. The validated path: ``ds["amount"]`` against a dataset whose contract IS registered. ``Dataset.__getitem__`` raises ``KeyError`` at the wiring site on typo, so by the time the walker sees the Column, the column name is already known good. ``allow_bare_strings=True`` on the App opts out for test fixtures and datasets without a registered contract (the kitchen sink, which has no DatasetContract). """ if self.allow_bare_strings or self.analysis is None: return bad: list[str] = [] def _check(column: object, where: str) -> None: if isinstance(column, str): bad.append(f"{where} → bare string {column!r}") return if isinstance(column, Column): try: get_contract(column.dataset.identifier) except KeyError: bad.append( f"{where} → ds[{column.name!r}] but dataset " f"{column.dataset.identifier!r} has no registered " f"DatasetContract — column couldn't be validated" ) for sheet in self.analysis.sheets: for visual in sheet.visuals: for attr, _role in _FIELD_SLOTS: slot: object = getattr(visual, attr, None) if slot is None: continue leaves: list[object] = ( list(slot) if isinstance(slot, list) # type: ignore[arg-type]: list(object) is list of leaves; slot narrowed by isinstance else [slot] ) for leaf in leaves: if leaf is None: continue _check( getattr(leaf, "column", None), f"sheet {sheet.sheet_id!r} visual " f"{getattr(visual, 'visual_id', '?')!r} " f"{attr}", ) # LinkedValues on parameter / filter controls hits the # same column-ref slot. for ctrl in ( *sheet.parameter_controls, *sheet.filter_controls, ): sv = getattr(ctrl, "selectable_values", None) if sv is not None: _check( getattr(sv, "column", None), f"sheet {sheet.sheet_id!r} control " f"{getattr(ctrl, 'control_id', '?')!r} " f"selectable_values", ) for fg in self.analysis.filter_groups: for filt in fg.filters: _check( getattr(filt, "column", None), f"filter {getattr(filt, 'filter_id', '?')!r}", ) if bad: raise ValueError( f"App {self.name!r} has unvalidated column refs " f"(typo-prone — they bypass the dataset contract):\n " + "\n ".join(bad) + "\n\nUse the typed form ds[\"column_name\"].dim() / " ".sum() / .date() / etc. against a dataset whose " "DatasetContract is registered — or pass " "``allow_bare_strings=True`` on the App when no " "dataset contract is registered (test fixtures)." ) def _validate_calc_field_references(self) -> None: """Raise if the tree references any CalcField not registered on this App's Analysis. Catches "filter / visual references calc field that doesn't exist" at emit time. The string-only column pattern lets that bug flow through to deploy where it renders silently as an empty column.""" if self.analysis is None: return referenced = self.analysis.calc_fields_referenced() registered = set(self.analysis.calc_fields) unregistered = referenced - registered if unregistered: # Names are populated by resolve_auto_ids before validation # runs (see emit_analysis); fall back to "<unnamed>" for # safety. names = sorted( "<unnamed>" if isinstance(c.name, _AutoSentinel) else c.name for c in unregistered ) raise ValueError( f"App {self.name!r} references unregistered calc fields: " f"{names} — register each via " f"app.analysis.add_calc_field() first." ) def _permissions(self, actions: list[str]) -> list[ResourcePermission] | None: if not self.cfg.principal_arns: return None return [ ResourcePermission(Principal=arn, Actions=actions) for arn in self.cfg.principal_arns ] def _theme_arn(self) -> str: return self.cfg.theme_arn(self.cfg.prefixed("theme")) def _used_datasets(self) -> list[Dataset]: """Datasets the analysis emits declarations for — only those actually referenced by the tree, in registration order.""" referenced = self.dataset_dependencies() return [d for d in self.datasets if d in referenced]
[docs] def emit_analysis(self) -> ModelAnalysis: if self.analysis is None: raise ValueError( "App has no Analysis — call set_analysis() first." ) self.resolve_auto_ids() self._validate_dataset_references() self._validate_calc_field_references() self._validate_parameter_references() self._validate_filter_param_settability() self._validate_drill_destinations() self._validate_no_bare_string_columns() return ModelAnalysis( AwsAccountId=self.cfg.aws_account_id, AnalysisId=self.cfg.prefixed(self.analysis.analysis_id_suffix), Name=self.analysis.name, ThemeArn=self._theme_arn(), Definition=self.analysis.emit_definition( datasets=self._used_datasets(), ), Permissions=self._permissions(ANALYSIS_ACTIONS), Tags=self.cfg.tags(), )
[docs] def emit_dashboard(self) -> ModelDashboard: if self.dashboard is None: raise ValueError( "App has no Dashboard — call create_dashboard() first." ) self.resolve_auto_ids() self._validate_dataset_references() self._validate_calc_field_references() self._validate_parameter_references() self._validate_filter_param_settability() self._validate_drill_destinations() self._validate_no_bare_string_columns() return ModelDashboard( AwsAccountId=self.cfg.aws_account_id, DashboardId=self.cfg.prefixed(self.dashboard.dashboard_id_suffix), Name=self.dashboard.name, ThemeArn=self._theme_arn(), Definition=self.dashboard.analysis.emit_definition( datasets=self._used_datasets(), ), Permissions=self._permissions(DASHBOARD_ACTIONS), Tags=self.cfg.tags(), VersionDescription="Generated by recon-gen", DashboardPublishOptions=DashboardPublishOptions( AdHocFilteringOption={"AvailabilityStatus": "ENABLED"}, ExportToCSVOption={"AvailabilityStatus": "ENABLED"}, SheetControlsOption={"VisibilityState": "EXPANDED"}, ), )