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/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