chartwright 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- chartwright/__init__.py +7 -0
- chartwright/absorb.py +93 -0
- chartwright/apply.py +409 -0
- chartwright/cli.py +222 -0
- chartwright/client.py +227 -0
- chartwright/compiler.py +601 -0
- chartwright/dashdiff.py +208 -0
- chartwright/decompile.py +625 -0
- chartwright/ids.py +21 -0
- chartwright/mcp_server.py +112 -0
- chartwright/profiles.py +143 -0
- chartwright/resolver.py +196 -0
- chartwright/sketch.py +180 -0
- chartwright/smoke.py +124 -0
- chartwright/spec.py +597 -0
- chartwright/testing.py +31 -0
- chartwright-0.1.0.dist-info/METADATA +189 -0
- chartwright-0.1.0.dist-info/RECORD +23 -0
- chartwright-0.1.0.dist-info/WHEEL +5 -0
- chartwright-0.1.0.dist-info/entry_points.txt +3 -0
- chartwright-0.1.0.dist-info/licenses/LICENSE +202 -0
- chartwright-0.1.0.dist-info/licenses/NOTICE +5 -0
- chartwright-0.1.0.dist-info/top_level.txt +1 -0
chartwright/spec.py
ADDED
|
@@ -0,0 +1,597 @@
|
|
|
1
|
+
"""The dashboard spec: the typed contract the LLM is allowed to emit.
|
|
2
|
+
|
|
3
|
+
Anything not expressible here does not exist. Validation errors are the only
|
|
4
|
+
feedback channel an LLM caller gets; keep messages precise and actionable.
|
|
5
|
+
|
|
6
|
+
Surface: 14 chart types, per-chart WHERE filters, a dashboard-level native
|
|
7
|
+
filter bar (select + time_range), markdown blocks, and tabs.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from typing import Annotated, Literal, Union
|
|
14
|
+
|
|
15
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
16
|
+
|
|
17
|
+
GRID_WIDTH = 12
|
|
18
|
+
DEFAULT_HEIGHT = {"big_number_total": 4, "big_number_trend": 5, "markdown": 4, "default": 8}
|
|
19
|
+
DEFAULT_ROW_LIMIT = {
|
|
20
|
+
"timeseries_line": 10000, "timeseries_bar": 10000, "timeseries_area": 10000,
|
|
21
|
+
"timeseries_scatter": 10000, "bar": 10000, "pie": 100, "table": 1000,
|
|
22
|
+
"pivot_table": 10000, "heatmap": 10000, "histogram": 10000, "funnel": 10,
|
|
23
|
+
"treemap": 100,
|
|
24
|
+
}
|
|
25
|
+
DEFAULT_TIME_GRAIN = "P1D"
|
|
26
|
+
|
|
27
|
+
ADHOC_AGGREGATES = ("SUM", "AVG", "COUNT", "COUNT_DISTINCT", "MIN", "MAX")
|
|
28
|
+
_ADHOC_RE = re.compile(r"^(SUM|AVG|COUNT|COUNT_DISTINCT|MIN|MAX)\((.+?)\)(?:\s+AS\s+(.+))?$")
|
|
29
|
+
|
|
30
|
+
# The RAG hexes Superset's own conditional-formatting picker offers.
|
|
31
|
+
FORMAT_COLOR_HEX = {"green": "#ACE1C4", "amber": "#FDE380", "red": "#EFA1AA"}
|
|
32
|
+
|
|
33
|
+
FilterOp = Literal["==", "!=", ">", ">=", "<", "<=", "IN", "NOT IN", "LIKE", "IS NULL", "IS NOT NULL"]
|
|
34
|
+
_LIST_OPS = ("IN", "NOT IN")
|
|
35
|
+
_NULL_OPS = ("IS NULL", "IS NOT NULL")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class DatasetRef(BaseModel):
|
|
39
|
+
"""A dataset is referenced, never created. Names alone are not unique
|
|
40
|
+
across databases/schemas, so the reference is a triple."""
|
|
41
|
+
|
|
42
|
+
model_config = ConfigDict(extra="forbid")
|
|
43
|
+
|
|
44
|
+
database: str = Field(description="Superset database connection name")
|
|
45
|
+
schema_: str | None = Field(
|
|
46
|
+
default=None, alias="schema", description="Schema name; omit if unambiguous"
|
|
47
|
+
)
|
|
48
|
+
table: str = Field(description="Dataset (table) name as registered in Superset")
|
|
49
|
+
|
|
50
|
+
def key(self) -> str:
|
|
51
|
+
return f"{self.database}/{self.schema_ or ''}/{self.table}"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def parse_metric(metric: str) -> dict | None:
|
|
55
|
+
"""{'aggregate','column','label'} for ad-hoc metrics, None for saved-metric
|
|
56
|
+
names. Optional display label via ``AGG(col) AS Pretty Label`` (AS uppercase)."""
|
|
57
|
+
m = _ADHOC_RE.match(metric)
|
|
58
|
+
if not m:
|
|
59
|
+
return None
|
|
60
|
+
return {"aggregate": m.group(1), "column": m.group(2).strip(), "label": m.group(3)}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def metric_label(metric: str) -> str:
|
|
64
|
+
"""What Superset will display (and key row data by) for this metric string."""
|
|
65
|
+
parsed = parse_metric(metric)
|
|
66
|
+
if parsed and parsed["label"]:
|
|
67
|
+
return parsed["label"]
|
|
68
|
+
return metric
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class ChartFilter(BaseModel):
|
|
72
|
+
"""A WHERE-clause condition on the chart's dataset."""
|
|
73
|
+
|
|
74
|
+
model_config = ConfigDict(extra="forbid")
|
|
75
|
+
|
|
76
|
+
column: str
|
|
77
|
+
op: FilterOp = "=="
|
|
78
|
+
value: str | int | float | bool | list[str | int | float] | None = None
|
|
79
|
+
|
|
80
|
+
@model_validator(mode="after")
|
|
81
|
+
def _value_shape(self) -> "ChartFilter":
|
|
82
|
+
if self.op in _NULL_OPS:
|
|
83
|
+
if self.value is not None:
|
|
84
|
+
raise ValueError(f"filter op {self.op!r} takes no value")
|
|
85
|
+
elif self.op in _LIST_OPS:
|
|
86
|
+
if not isinstance(self.value, list) or not self.value:
|
|
87
|
+
raise ValueError(f"filter op {self.op!r} needs a non-empty list value")
|
|
88
|
+
elif isinstance(self.value, list):
|
|
89
|
+
raise ValueError(f"filter op {self.op!r} takes a scalar value, not a list")
|
|
90
|
+
elif self.value is None:
|
|
91
|
+
raise ValueError(f"filter op {self.op!r} needs a value")
|
|
92
|
+
return self
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class _ChartBase(BaseModel):
|
|
96
|
+
model_config = ConfigDict(extra="forbid")
|
|
97
|
+
|
|
98
|
+
name: str = Field(min_length=1, description="Chart title; unique within the dashboard (uuid seed input)")
|
|
99
|
+
dataset: DatasetRef
|
|
100
|
+
filters: list[ChartFilter] = Field(default_factory=list, description="WHERE conditions on this chart only")
|
|
101
|
+
width: int | None = Field(default=None, ge=1, le=GRID_WIDTH)
|
|
102
|
+
height: float | None = Field(
|
|
103
|
+
default=None, ge=1, le=100,
|
|
104
|
+
description="Height in 40px units. Fractional values (0.2 steps = Superset's "
|
|
105
|
+
"8px grid) are tool-written by `chartwright absorb`; humans write integers.",
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
def default_height(self) -> int:
|
|
109
|
+
return DEFAULT_HEIGHT.get(self.type, DEFAULT_HEIGHT["default"]) # type: ignore[attr-defined]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class BigNumberChart(_ChartBase):
|
|
113
|
+
type: Literal["big_number_total"]
|
|
114
|
+
metric: str
|
|
115
|
+
subtitle: str | None = None
|
|
116
|
+
number_format: str | None = Field(default=None, description="d3 format string, e.g. ',.0f'")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class BigNumberTrendChart(_ChartBase):
|
|
120
|
+
type: Literal["big_number_trend"]
|
|
121
|
+
metric: str
|
|
122
|
+
time_column: str
|
|
123
|
+
time_grain: str | None = None
|
|
124
|
+
number_format: str | None = None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class _TimeseriesBase(_ChartBase):
|
|
128
|
+
metrics: list[str] = Field(min_length=1)
|
|
129
|
+
time_column: str
|
|
130
|
+
time_grain: str | None = Field(default=None, description="ISO 8601 duration, e.g. P1D, P1W, P1M")
|
|
131
|
+
time_range: str | None = Field(default=None, description='Superset time range; defaults to "No filter"')
|
|
132
|
+
groupby: str | None = Field(default=None, description="At most one dimension column")
|
|
133
|
+
row_limit: int | None = Field(default=None, ge=1)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class TimeseriesLineChart(_TimeseriesBase):
|
|
137
|
+
type: Literal["timeseries_line"]
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class TimeseriesBarChart(_TimeseriesBase):
|
|
141
|
+
type: Literal["timeseries_bar"]
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class TimeseriesAreaChart(_TimeseriesBase):
|
|
145
|
+
type: Literal["timeseries_area"]
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class TimeseriesScatterChart(_TimeseriesBase):
|
|
149
|
+
type: Literal["timeseries_scatter"]
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class BarChart(_ChartBase):
|
|
153
|
+
"""Categorical bar: any column on the x axis.
|
|
154
|
+
|
|
155
|
+
``orientation: "horizontal"`` draws ranked lists with long labels the
|
|
156
|
+
readable way (bars run left to right, labels get a full line each).
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
type: Literal["bar"]
|
|
160
|
+
x_column: str
|
|
161
|
+
metrics: list[str] = Field(min_length=1)
|
|
162
|
+
groupby: str | None = None
|
|
163
|
+
row_limit: int | None = Field(default=None, ge=1)
|
|
164
|
+
orientation: Literal["vertical", "horizontal"] = "vertical"
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class PieChart(_ChartBase):
|
|
168
|
+
type: Literal["pie"]
|
|
169
|
+
metric: str
|
|
170
|
+
groupby: str
|
|
171
|
+
donut: bool = False
|
|
172
|
+
row_limit: int | None = Field(default=None, ge=1)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class TableChart(_ChartBase):
|
|
176
|
+
type: Literal["table"]
|
|
177
|
+
columns: list[str] | None = Field(default=None, description="Raw-records mode: plain columns")
|
|
178
|
+
metrics: list[str] | None = None
|
|
179
|
+
groupby: list[str] | None = None
|
|
180
|
+
row_limit: int | None = Field(default=None, ge=1)
|
|
181
|
+
sort_by: str | None = Field(default=None, description="Metric or column to sort by (aggregate mode: metric)")
|
|
182
|
+
|
|
183
|
+
@model_validator(mode="after")
|
|
184
|
+
def _mode(self) -> "TableChart":
|
|
185
|
+
aggregate = bool(self.metrics or self.groupby)
|
|
186
|
+
raw = bool(self.columns)
|
|
187
|
+
if aggregate and raw:
|
|
188
|
+
raise ValueError("table chart: use either columns (raw mode) or metrics+groupby (aggregate mode), not both")
|
|
189
|
+
if not aggregate and not raw:
|
|
190
|
+
raise ValueError("table chart: provide columns (raw mode) or metrics+groupby (aggregate mode)")
|
|
191
|
+
return self
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class FormatRule(BaseModel):
|
|
195
|
+
"""One RAG band on one pivot metric. Superset colors by FIXED value bands;
|
|
196
|
+
it cannot compare a cell to another column, so band a normalized metric
|
|
197
|
+
(e.g. a %-of-goal ratio) when thresholds differ per row."""
|
|
198
|
+
|
|
199
|
+
model_config = ConfigDict(extra="forbid")
|
|
200
|
+
|
|
201
|
+
metric: str = Field(description="Display label of one of the chart's metrics")
|
|
202
|
+
operator: Literal["<", ">", "between"]
|
|
203
|
+
target: float | None = Field(default=None, description="Threshold for < or >")
|
|
204
|
+
target_left: float | None = Field(default=None, description="Lower bound for 'between'")
|
|
205
|
+
target_right: float | None = Field(default=None, description="Upper bound for 'between'")
|
|
206
|
+
color: Literal["green", "amber", "red"]
|
|
207
|
+
|
|
208
|
+
@model_validator(mode="after")
|
|
209
|
+
def _target_shape(self) -> "FormatRule":
|
|
210
|
+
if self.operator == "between":
|
|
211
|
+
if self.target is not None or self.target_left is None or self.target_right is None:
|
|
212
|
+
raise ValueError("'between' needs target_left + target_right (and no target)")
|
|
213
|
+
elif self.target is None or self.target_left is not None or self.target_right is not None:
|
|
214
|
+
raise ValueError(f"operator {self.operator!r} needs target (and no target_left/right)")
|
|
215
|
+
return self
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class PivotTableChart(_ChartBase):
|
|
219
|
+
type: Literal["pivot_table"]
|
|
220
|
+
rows: list[str] = Field(default_factory=list, description="Dimension columns on pivot rows")
|
|
221
|
+
columns: list[str] = Field(default_factory=list, description="Dimension columns on pivot columns")
|
|
222
|
+
metrics: list[str] = Field(min_length=1)
|
|
223
|
+
row_limit: int | None = Field(default=None, ge=1)
|
|
224
|
+
combine_metric: bool = Field(
|
|
225
|
+
default=False,
|
|
226
|
+
description="Nest metrics under each column-dimension group (column-outer layout)",
|
|
227
|
+
)
|
|
228
|
+
date_format: str | None = Field(
|
|
229
|
+
default=None,
|
|
230
|
+
description="strftime for temporal pivot headers, e.g. '%m/%d/%y'",
|
|
231
|
+
)
|
|
232
|
+
conditional_formatting: list[FormatRule] = Field(default_factory=list)
|
|
233
|
+
|
|
234
|
+
@model_validator(mode="after")
|
|
235
|
+
def _dims(self) -> "PivotTableChart":
|
|
236
|
+
if not self.rows and not self.columns:
|
|
237
|
+
raise ValueError("pivot_table needs at least one of rows/columns")
|
|
238
|
+
labels = {metric_label(m) for m in self.metrics}
|
|
239
|
+
for rule in self.conditional_formatting:
|
|
240
|
+
if rule.metric not in labels:
|
|
241
|
+
raise ValueError(
|
|
242
|
+
f"conditional_formatting metric {rule.metric!r} is not one of the "
|
|
243
|
+
f"chart's metric labels {sorted(labels)}"
|
|
244
|
+
)
|
|
245
|
+
return self
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
class HeatmapChart(_ChartBase):
|
|
249
|
+
type: Literal["heatmap"]
|
|
250
|
+
x_column: str
|
|
251
|
+
y_column: str
|
|
252
|
+
metric: str
|
|
253
|
+
row_limit: int | None = Field(default=None, ge=1)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
class HistogramChart(_ChartBase):
|
|
257
|
+
type: Literal["histogram"]
|
|
258
|
+
column: str
|
|
259
|
+
bins: int = Field(default=10, ge=1, le=200)
|
|
260
|
+
groupby: str | None = None
|
|
261
|
+
row_limit: int | None = Field(default=None, ge=1)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
class FunnelChart(_ChartBase):
|
|
265
|
+
type: Literal["funnel"]
|
|
266
|
+
metric: str
|
|
267
|
+
groupby: str
|
|
268
|
+
row_limit: int | None = Field(default=None, ge=1)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
class TreemapChart(_ChartBase):
|
|
272
|
+
type: Literal["treemap"]
|
|
273
|
+
metric: str
|
|
274
|
+
groupby: list[str] = Field(min_length=1)
|
|
275
|
+
row_limit: int | None = Field(default=None, ge=1)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
Chart = Annotated[
|
|
279
|
+
Union[
|
|
280
|
+
BigNumberChart, BigNumberTrendChart, TimeseriesLineChart, TimeseriesBarChart,
|
|
281
|
+
TimeseriesAreaChart, TimeseriesScatterChart, BarChart, PieChart, TableChart,
|
|
282
|
+
PivotTableChart, HeatmapChart, HistogramChart, FunnelChart, TreemapChart,
|
|
283
|
+
],
|
|
284
|
+
Field(discriminator="type"),
|
|
285
|
+
]
|
|
286
|
+
|
|
287
|
+
CHART_TYPES = (
|
|
288
|
+
"big_number_total", "big_number_trend", "timeseries_line", "timeseries_bar",
|
|
289
|
+
"timeseries_area", "timeseries_scatter", "bar", "pie", "table", "pivot_table",
|
|
290
|
+
"heatmap", "histogram", "funnel", "treemap",
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
class SelectFilter(BaseModel):
|
|
295
|
+
"""Native filter bar: value picker over one dataset column."""
|
|
296
|
+
|
|
297
|
+
model_config = ConfigDict(extra="forbid")
|
|
298
|
+
|
|
299
|
+
type: Literal["select"]
|
|
300
|
+
name: str = Field(min_length=1)
|
|
301
|
+
dataset: DatasetRef
|
|
302
|
+
column: str
|
|
303
|
+
multi: bool = True
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
class TimeRangeFilter(BaseModel):
|
|
307
|
+
"""Native filter bar: dashboard-wide time range picker.
|
|
308
|
+
|
|
309
|
+
``default`` is a Superset time-range expression (``"Last month"``,
|
|
310
|
+
``"2026-05-01 : 2026-06-01"``) that pre-fills the picker on load; viewers
|
|
311
|
+
can still change it. Omitted, the picker starts empty (Superset shows
|
|
312
|
+
"No filter").
|
|
313
|
+
"""
|
|
314
|
+
|
|
315
|
+
model_config = ConfigDict(extra="forbid")
|
|
316
|
+
|
|
317
|
+
type: Literal["time_range"]
|
|
318
|
+
name: str = Field(min_length=1)
|
|
319
|
+
default: str | None = None
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
class RangeFilter(BaseModel):
|
|
323
|
+
"""Native filter bar: numeric range on one dataset column (filter_range).
|
|
324
|
+
|
|
325
|
+
``le``/``ge`` set the DEFAULT bound(s) users see on load. One bound gives a
|
|
326
|
+
single-handle slider (Superset's single-value mode); both give a range;
|
|
327
|
+
equal bounds give exact-match mode. ``charts`` scopes the filter to the
|
|
328
|
+
named charts only (default: every chart), resolved to slice ids after
|
|
329
|
+
import, since ids don't exist at compile time."""
|
|
330
|
+
|
|
331
|
+
model_config = ConfigDict(extra="forbid")
|
|
332
|
+
|
|
333
|
+
type: Literal["range"]
|
|
334
|
+
name: str = Field(min_length=1)
|
|
335
|
+
dataset: DatasetRef
|
|
336
|
+
column: str
|
|
337
|
+
le: float | None = Field(default=None, description="Default upper bound (x ≤ N)")
|
|
338
|
+
ge: float | None = Field(default=None, description="Default lower bound (x ≥ N)")
|
|
339
|
+
charts: list[str] | None = Field(
|
|
340
|
+
default=None,
|
|
341
|
+
description="Chart names this filter governs; omit for all charts",
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
@model_validator(mode="after")
|
|
345
|
+
def _bounds(self) -> "RangeFilter":
|
|
346
|
+
if self.le is not None and self.ge is not None and self.ge > self.le:
|
|
347
|
+
raise ValueError(f"range filter {self.name!r}: ge ({self.ge}) > le ({self.le})")
|
|
348
|
+
if self.charts is not None and not self.charts:
|
|
349
|
+
raise ValueError(f"range filter {self.name!r}: charts must be omitted or non-empty")
|
|
350
|
+
return self
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
DashboardFilter = Annotated[
|
|
354
|
+
Union[SelectFilter, TimeRangeFilter, RangeFilter], Field(discriminator="type")
|
|
355
|
+
]
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
class MarkdownBlock(BaseModel):
|
|
359
|
+
"""A text block in the layout (headers, notes)."""
|
|
360
|
+
|
|
361
|
+
model_config = ConfigDict(extra="forbid")
|
|
362
|
+
|
|
363
|
+
markdown: str = Field(min_length=1)
|
|
364
|
+
width: int | None = Field(default=None, ge=1, le=GRID_WIDTH)
|
|
365
|
+
height: int | None = Field(default=None, ge=1, le=100)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
RowItem = Union[str, MarkdownBlock]
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
class _SketchHolder(BaseModel):
|
|
372
|
+
"""Shared surface for ASCII layout sketches (see chartwright/sketch.py).
|
|
373
|
+
|
|
374
|
+
A sketch draws the layout as text: legend symbols map to chart names,
|
|
375
|
+
character runs become twelfths of the 12-column grid, each line adds
|
|
376
|
+
`line` height units (1 unit = 40 px; repeat lines for taller), and
|
|
377
|
+
vertically stacked symbols compile to Superset COLUMN containers.
|
|
378
|
+
'.' is a reserved EMPTY cell, legal trailing-right (a row narrower than
|
|
379
|
+
the page) or at the BOTTOM of a slice/stack (a short chart beside a tall
|
|
380
|
+
one); anywhere else is a named error (Superset packs left and upward)."""
|
|
381
|
+
|
|
382
|
+
sketch: list[str] | None = Field(
|
|
383
|
+
default=None, description="ASCII layout: one string per grid line; spaces are cosmetic"
|
|
384
|
+
)
|
|
385
|
+
legend: dict[str, str] | None = Field(
|
|
386
|
+
default=None, description="Sketch symbol -> chart name"
|
|
387
|
+
)
|
|
388
|
+
line: int = Field(
|
|
389
|
+
default=2, ge=1, le=20,
|
|
390
|
+
description="Height units per sketch line (1 unit = 40 px)",
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
def parsed_sketch(self):
|
|
394
|
+
from .sketch import parse_sketch
|
|
395
|
+
|
|
396
|
+
return parse_sketch(self.sketch or [], self.legend or {}, self.line)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
class Tab(_SketchHolder):
|
|
400
|
+
model_config = ConfigDict(extra="forbid")
|
|
401
|
+
|
|
402
|
+
title: str = Field(min_length=1)
|
|
403
|
+
rows: list[list[RowItem]] | None = None
|
|
404
|
+
|
|
405
|
+
@model_validator(mode="after")
|
|
406
|
+
def _rows_or_sketch(self) -> "Tab":
|
|
407
|
+
if bool(self.rows) == bool(self.sketch):
|
|
408
|
+
raise ValueError(f"tab {self.title!r}: provide exactly one of rows / sketch")
|
|
409
|
+
if self.sketch and not self.legend:
|
|
410
|
+
raise ValueError(f"tab {self.title!r}: a sketch needs a legend")
|
|
411
|
+
return self
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
class DashboardMeta(BaseModel):
|
|
415
|
+
model_config = ConfigDict(extra="forbid")
|
|
416
|
+
|
|
417
|
+
title: str = Field(min_length=1)
|
|
418
|
+
slug: str = Field(min_length=1, pattern=r"^[a-z0-9][a-z0-9-]*$", description="uuid seed input; lowercase kebab-case")
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
class Layout(_SketchHolder):
|
|
422
|
+
"""Flat rows, tabs, or an ASCII sketch; exactly one."""
|
|
423
|
+
|
|
424
|
+
model_config = ConfigDict(extra="forbid")
|
|
425
|
+
|
|
426
|
+
rows: list[list[RowItem]] | None = None
|
|
427
|
+
tabs: list[Tab] | None = None
|
|
428
|
+
|
|
429
|
+
@model_validator(mode="after")
|
|
430
|
+
def _exactly_one(self) -> "Layout":
|
|
431
|
+
given = [bool(self.rows), bool(self.tabs), bool(self.sketch)]
|
|
432
|
+
if sum(given) != 1:
|
|
433
|
+
raise ValueError("layout: provide exactly one of rows / tabs / sketch")
|
|
434
|
+
if self.sketch and not self.legend:
|
|
435
|
+
raise ValueError("layout: a sketch needs a legend")
|
|
436
|
+
return self
|
|
437
|
+
|
|
438
|
+
def all_rows(self) -> list[list[RowItem]]:
|
|
439
|
+
if self.rows:
|
|
440
|
+
return self.rows
|
|
441
|
+
return [row for tab in (self.tabs or []) for row in (tab.rows or [])]
|
|
442
|
+
|
|
443
|
+
def sketch_holders(self) -> list["_SketchHolder"]:
|
|
444
|
+
if self.sketch:
|
|
445
|
+
return [self]
|
|
446
|
+
return [t for t in (self.tabs or []) if t.sketch]
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
class DashboardSpec(BaseModel):
|
|
450
|
+
model_config = ConfigDict(extra="forbid")
|
|
451
|
+
|
|
452
|
+
spec_version: Literal["1"]
|
|
453
|
+
dashboard: DashboardMeta
|
|
454
|
+
charts: list[Chart] = Field(min_length=1)
|
|
455
|
+
filters: list[DashboardFilter] = Field(default_factory=list, description="Native filter bar")
|
|
456
|
+
layout: Layout
|
|
457
|
+
|
|
458
|
+
@field_validator("charts")
|
|
459
|
+
@classmethod
|
|
460
|
+
def _unique_names(cls, charts: list[Chart]) -> list[Chart]:
|
|
461
|
+
seen: set[str] = set()
|
|
462
|
+
for c in charts:
|
|
463
|
+
if c.name in seen:
|
|
464
|
+
raise ValueError(
|
|
465
|
+
f"duplicate chart name {c.name!r}: chart names must be unique within a dashboard "
|
|
466
|
+
"(identical uuid seeds would silently collide)"
|
|
467
|
+
)
|
|
468
|
+
seen.add(c.name)
|
|
469
|
+
return charts
|
|
470
|
+
|
|
471
|
+
@field_validator("filters")
|
|
472
|
+
@classmethod
|
|
473
|
+
def _unique_filter_names(cls, filters: list[DashboardFilter]) -> list[DashboardFilter]:
|
|
474
|
+
seen: set[str] = set()
|
|
475
|
+
for f in filters:
|
|
476
|
+
if f.name in seen:
|
|
477
|
+
raise ValueError(f"duplicate filter name {f.name!r}")
|
|
478
|
+
seen.add(f.name)
|
|
479
|
+
return filters
|
|
480
|
+
|
|
481
|
+
@model_validator(mode="after")
|
|
482
|
+
def _filter_scopes_resolve(self) -> "DashboardSpec":
|
|
483
|
+
names = {c.name for c in self.charts}
|
|
484
|
+
for f in self.filters:
|
|
485
|
+
for target in getattr(f, "charts", None) or []:
|
|
486
|
+
if target not in names:
|
|
487
|
+
raise ValueError(
|
|
488
|
+
f"filter {f.name!r} scopes unknown chart {target!r} "
|
|
489
|
+
f"(spec charts: {sorted(names)})"
|
|
490
|
+
)
|
|
491
|
+
return self
|
|
492
|
+
|
|
493
|
+
@model_validator(mode="after")
|
|
494
|
+
def _layout_consistent(self) -> "DashboardSpec":
|
|
495
|
+
by_name = {c.name: c for c in self.charts}
|
|
496
|
+
placed: set[str] = set()
|
|
497
|
+
for i, row in enumerate(self.layout.all_rows()):
|
|
498
|
+
if not row:
|
|
499
|
+
raise ValueError(f"layout row {i} is empty")
|
|
500
|
+
explicit = 0
|
|
501
|
+
implicit = 0
|
|
502
|
+
for item in row:
|
|
503
|
+
if isinstance(item, str):
|
|
504
|
+
if item not in by_name:
|
|
505
|
+
raise ValueError(f"layout row {i} references unknown chart {item!r}")
|
|
506
|
+
if item in placed:
|
|
507
|
+
raise ValueError(f"chart {item!r} appears more than once in the layout")
|
|
508
|
+
placed.add(item)
|
|
509
|
+
w = by_name[item].width
|
|
510
|
+
else:
|
|
511
|
+
w = item.width
|
|
512
|
+
if w is None:
|
|
513
|
+
implicit += 1
|
|
514
|
+
else:
|
|
515
|
+
explicit += w
|
|
516
|
+
if implicit and GRID_WIDTH - explicit < implicit:
|
|
517
|
+
raise ValueError(
|
|
518
|
+
f"layout row {i}: explicit widths leave {GRID_WIDTH - explicit} units for "
|
|
519
|
+
f"{implicit} unsized item(s); widths in a row must sum to <= {GRID_WIDTH}"
|
|
520
|
+
)
|
|
521
|
+
if not implicit and explicit > GRID_WIDTH:
|
|
522
|
+
raise ValueError(f"layout row {i}: widths sum to {explicit} > {GRID_WIDTH}; no wrapping, no clamping")
|
|
523
|
+
for holder in self.layout.sketch_holders():
|
|
524
|
+
where = f"tab {holder.title!r}" if isinstance(holder, Tab) else "layout"
|
|
525
|
+
try:
|
|
526
|
+
parsed = holder.parsed_sketch()
|
|
527
|
+
except ValueError as e:
|
|
528
|
+
raise ValueError(f"{where}: {e}") from e
|
|
529
|
+
for row in parsed:
|
|
530
|
+
for child in row.children:
|
|
531
|
+
charts = child.children if hasattr(child, "children") else [child]
|
|
532
|
+
for sc in charts:
|
|
533
|
+
if sc.name not in by_name:
|
|
534
|
+
raise ValueError(f"{where}: legend maps to unknown chart {sc.name!r}")
|
|
535
|
+
if sc.name in placed:
|
|
536
|
+
raise ValueError(f"chart {sc.name!r} appears more than once in the layout")
|
|
537
|
+
placed.add(sc.name)
|
|
538
|
+
missing = set(by_name) - placed
|
|
539
|
+
if missing:
|
|
540
|
+
raise ValueError(f"charts not placed in layout: {sorted(missing)}")
|
|
541
|
+
return self
|
|
542
|
+
|
|
543
|
+
# -- geometry -------------------------------------------------------------
|
|
544
|
+
|
|
545
|
+
def _row_of(self, want) -> list[RowItem]:
|
|
546
|
+
for row in self.layout.all_rows():
|
|
547
|
+
for item in row:
|
|
548
|
+
if item is want or (isinstance(item, str) and item == want):
|
|
549
|
+
return row
|
|
550
|
+
raise KeyError(want)
|
|
551
|
+
|
|
552
|
+
def _item_width(self, item: RowItem) -> int | None:
|
|
553
|
+
if isinstance(item, str):
|
|
554
|
+
return next(c for c in self.charts if c.name == item).width
|
|
555
|
+
return item.width
|
|
556
|
+
|
|
557
|
+
def _sketch_charts(self) -> dict[str, object]:
|
|
558
|
+
"""Chart name -> SketchChart for every chart placed via a sketch.
|
|
559
|
+
Sketch geometry is authoritative: widths and heights are drawn."""
|
|
560
|
+
out: dict[str, object] = {}
|
|
561
|
+
for holder in self.layout.sketch_holders():
|
|
562
|
+
for row in holder.parsed_sketch():
|
|
563
|
+
for child in row.children:
|
|
564
|
+
charts = child.children if hasattr(child, "children") else [child]
|
|
565
|
+
for sc in charts:
|
|
566
|
+
out[sc.name] = sc
|
|
567
|
+
return out
|
|
568
|
+
|
|
569
|
+
def resolved_item_width(self, item: RowItem) -> int:
|
|
570
|
+
if isinstance(item, str):
|
|
571
|
+
sc = self._sketch_charts().get(item)
|
|
572
|
+
if sc is not None:
|
|
573
|
+
return sc.width
|
|
574
|
+
row = self._row_of(item)
|
|
575
|
+
explicit_total = sum(self._item_width(x) or 0 for x in row)
|
|
576
|
+
implicit = [x for x in row if self._item_width(x) is None]
|
|
577
|
+
own = self._item_width(item)
|
|
578
|
+
if own is not None:
|
|
579
|
+
return own
|
|
580
|
+
return max(1, (GRID_WIDTH - explicit_total) // len(implicit))
|
|
581
|
+
|
|
582
|
+
def resolved_height(self, name: str) -> float:
|
|
583
|
+
chart = next(c for c in self.charts if c.name == name)
|
|
584
|
+
if chart.height is not None: # explicit height wins (absorb writes here)
|
|
585
|
+
return chart.height
|
|
586
|
+
sc = self._sketch_charts().get(name)
|
|
587
|
+
if sc is not None:
|
|
588
|
+
return sc.height
|
|
589
|
+
return chart.default_height()
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def load_spec(data: dict) -> DashboardSpec:
|
|
593
|
+
return DashboardSpec.model_validate(data)
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def json_schema() -> dict:
|
|
597
|
+
return DashboardSpec.model_json_schema()
|
chartwright/testing.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Stub resolution for offline compiles and golden tests: fake-but-deterministic
|
|
2
|
+
dataset ids/uuids so bundle bytes are stable without a live Superset."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import uuid
|
|
7
|
+
|
|
8
|
+
from . import ids
|
|
9
|
+
from .resolver import ResolvedDataset, Resolution
|
|
10
|
+
from .spec import DashboardSpec
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def stub_resolution(spec: DashboardSpec) -> Resolution:
|
|
14
|
+
res = Resolution()
|
|
15
|
+
refs = [c.dataset for c in spec.charts]
|
|
16
|
+
refs += [f.dataset for f in spec.filters if f.type in ("select", "range")]
|
|
17
|
+
for ref in refs:
|
|
18
|
+
key = ref.key()
|
|
19
|
+
if key in res.datasets:
|
|
20
|
+
continue
|
|
21
|
+
res.datasets[key] = ResolvedDataset(
|
|
22
|
+
id=1000 + len(res.datasets),
|
|
23
|
+
uuid=str(uuid.uuid5(ids.NAMESPACE, f"stub-dataset/{key}")),
|
|
24
|
+
table=ref.table,
|
|
25
|
+
schema=ref.schema_,
|
|
26
|
+
database_name=ref.database,
|
|
27
|
+
columns=[],
|
|
28
|
+
metrics=[],
|
|
29
|
+
main_dttm_col=None,
|
|
30
|
+
)
|
|
31
|
+
return res
|