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/compiler.py
ADDED
|
@@ -0,0 +1,601 @@
|
|
|
1
|
+
"""Deterministic compiler: spec + resolution -> Superset import bundle (ZIP).
|
|
2
|
+
|
|
3
|
+
Byte-stable by construction: fixed bundle timestamp, sorted YAML keys, sorted
|
|
4
|
+
ZIP entries, fixed ZIP entry timestamps, uuid5 identity. Golden tests assert
|
|
5
|
+
the bundle's exact bytes. Param shapes come from real 6.1.0 exports
|
|
6
|
+
(tests/fixtures/featured_charts_export.zip + live Sales/Slack exports).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import io
|
|
12
|
+
import json
|
|
13
|
+
import uuid
|
|
14
|
+
import zipfile
|
|
15
|
+
|
|
16
|
+
import yaml
|
|
17
|
+
|
|
18
|
+
from . import ids
|
|
19
|
+
from .resolver import Resolution
|
|
20
|
+
from .spec import (
|
|
21
|
+
DEFAULT_ROW_LIMIT,
|
|
22
|
+
DEFAULT_TIME_GRAIN,
|
|
23
|
+
FORMAT_COLOR_HEX,
|
|
24
|
+
DashboardSpec,
|
|
25
|
+
MarkdownBlock,
|
|
26
|
+
parse_metric,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
BUNDLE_ROOT = "sdc_bundle"
|
|
30
|
+
FIXED_TIMESTAMP = "2026-01-01T00:00:00+00:00"
|
|
31
|
+
ZIP_DATE_TIME = (2026, 1, 1, 0, 0, 0)
|
|
32
|
+
|
|
33
|
+
# ponytail: 1 spec grid unit -> 5 superset row units (1 row unit ~ 8px).
|
|
34
|
+
# Calibration knob; validated against rendered dashboards.
|
|
35
|
+
ROW_UNITS_PER_SPEC_UNIT = 5
|
|
36
|
+
|
|
37
|
+
VIZ_TYPE = {
|
|
38
|
+
"big_number_total": "big_number_total",
|
|
39
|
+
"big_number_trend": "big_number",
|
|
40
|
+
"timeseries_line": "echarts_timeseries_line",
|
|
41
|
+
"timeseries_bar": "echarts_timeseries_bar",
|
|
42
|
+
"timeseries_area": "echarts_area",
|
|
43
|
+
"timeseries_scatter": "echarts_timeseries_scatter",
|
|
44
|
+
"bar": "echarts_timeseries_bar",
|
|
45
|
+
"pie": "pie",
|
|
46
|
+
"table": "table",
|
|
47
|
+
"pivot_table": "pivot_table_v2",
|
|
48
|
+
"heatmap": "heatmap_v2",
|
|
49
|
+
"histogram": "histogram_v2",
|
|
50
|
+
"funnel": "funnel",
|
|
51
|
+
"treemap": "treemap_v2",
|
|
52
|
+
}
|
|
53
|
+
# 'bar' and 'timeseries_bar' share a viz_type; the compiler marks categorical
|
|
54
|
+
# bars in params so the decompiler can tell them apart (x column not temporal
|
|
55
|
+
# is not knowable offline).
|
|
56
|
+
SDC_BAR_MARKER = "sdc_categorical_bar"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _yaml(data: dict) -> bytes:
|
|
60
|
+
return yaml.safe_dump(data, sort_keys=True, default_flow_style=False, allow_unicode=True).encode()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _metric_payload(metric: str, slug: str, chart_name: str) -> str | dict:
|
|
64
|
+
adhoc = parse_metric(metric)
|
|
65
|
+
if adhoc is None:
|
|
66
|
+
return metric
|
|
67
|
+
option = "metric_sdc_" + uuid.uuid5(ids.NAMESPACE, f"{slug}/chart/{chart_name}/metric/{metric}").hex[:12]
|
|
68
|
+
label = adhoc["label"] or metric
|
|
69
|
+
if adhoc["column"] == "*":
|
|
70
|
+
return {
|
|
71
|
+
"expressionType": "SQL",
|
|
72
|
+
"sqlExpression": f"{adhoc['aggregate']}(*)",
|
|
73
|
+
"label": label,
|
|
74
|
+
"optionName": option,
|
|
75
|
+
"hasCustomLabel": bool(adhoc["label"]),
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
"expressionType": "SIMPLE",
|
|
79
|
+
"column": {"column_name": adhoc["column"]},
|
|
80
|
+
"aggregate": adhoc["aggregate"],
|
|
81
|
+
"label": label,
|
|
82
|
+
"optionName": option,
|
|
83
|
+
"hasCustomLabel": bool(adhoc["label"]),
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _format_rule_payload(rule) -> dict:
|
|
88
|
+
out = {
|
|
89
|
+
"column": rule.metric,
|
|
90
|
+
"colorScheme": FORMAT_COLOR_HEX[rule.color],
|
|
91
|
+
"operator": rule.operator,
|
|
92
|
+
}
|
|
93
|
+
if rule.operator == "between":
|
|
94
|
+
out["targetValueLeft"] = rule.target_left
|
|
95
|
+
out["targetValueRight"] = rule.target_right
|
|
96
|
+
else:
|
|
97
|
+
out["targetValue"] = rule.target
|
|
98
|
+
return out
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _adhoc_filters(chart) -> list[dict]:
|
|
102
|
+
out = []
|
|
103
|
+
for f in chart.filters:
|
|
104
|
+
out.append({
|
|
105
|
+
"clause": "WHERE",
|
|
106
|
+
"expressionType": "SIMPLE",
|
|
107
|
+
"subject": f.column,
|
|
108
|
+
"operator": f.op,
|
|
109
|
+
"comparator": f.value,
|
|
110
|
+
})
|
|
111
|
+
return out
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _pin_big_number_fonts(p: dict) -> None:
|
|
115
|
+
"""Pin the number and subtitle sizes to the plugin's own documented
|
|
116
|
+
defaults (proportions of the card). Left unset, 6.1 renders a subtitle fed
|
|
117
|
+
through the legacy `subheader` key at proportion 1 of the card
|
|
118
|
+
(BigNumberTotal/transformProps.ts:77-80 at 6.1.0: `subheaderFontSize ?? 1`),
|
|
119
|
+
so short subtitles blow up and crop. 4.1.4/5.0.0 declare both controls
|
|
120
|
+
natively; 6.1 honors them through that same fallback."""
|
|
121
|
+
p["header_font_size"] = 0.4
|
|
122
|
+
p["subheader_font_size"] = 0.15
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _chart_params(chart, spec: DashboardSpec, resolution: Resolution) -> dict:
|
|
126
|
+
ds = resolution.for_chart(chart.dataset)
|
|
127
|
+
slug = spec.dashboard.slug
|
|
128
|
+
t = chart.type
|
|
129
|
+
p: dict = {
|
|
130
|
+
"datasource": f"{ds.id}__table",
|
|
131
|
+
"viz_type": VIZ_TYPE[t],
|
|
132
|
+
# Always explicit: Superset's default time window can be narrow enough
|
|
133
|
+
# to return empty data on correct charts (false-trips the smoke check).
|
|
134
|
+
"time_range": "No filter",
|
|
135
|
+
"adhoc_filters": _adhoc_filters(chart),
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
def metric(m: str):
|
|
139
|
+
return _metric_payload(m, slug, chart.name)
|
|
140
|
+
|
|
141
|
+
if t == "big_number_total":
|
|
142
|
+
p["metric"] = metric(chart.metric)
|
|
143
|
+
if chart.subtitle:
|
|
144
|
+
p["subheader"] = chart.subtitle
|
|
145
|
+
if chart.number_format:
|
|
146
|
+
p["y_axis_format"] = chart.number_format
|
|
147
|
+
_pin_big_number_fonts(p)
|
|
148
|
+
elif t == "big_number_trend":
|
|
149
|
+
p["metric"] = metric(chart.metric)
|
|
150
|
+
p["x_axis"] = chart.time_column
|
|
151
|
+
p["time_grain_sqla"] = chart.time_grain or DEFAULT_TIME_GRAIN
|
|
152
|
+
p["show_trend_line"] = True
|
|
153
|
+
p["start_y_axis_at_zero"] = True
|
|
154
|
+
p["rolling_type"] = "None"
|
|
155
|
+
if chart.number_format:
|
|
156
|
+
p["y_axis_format"] = chart.number_format
|
|
157
|
+
_pin_big_number_fonts(p)
|
|
158
|
+
elif t in ("timeseries_line", "timeseries_bar", "timeseries_area", "timeseries_scatter"):
|
|
159
|
+
p["metrics"] = [metric(m) for m in chart.metrics]
|
|
160
|
+
p["x_axis"] = chart.time_column
|
|
161
|
+
p["time_grain_sqla"] = chart.time_grain or DEFAULT_TIME_GRAIN
|
|
162
|
+
if chart.time_range:
|
|
163
|
+
p["time_range"] = chart.time_range
|
|
164
|
+
p["groupby"] = [chart.groupby] if chart.groupby else []
|
|
165
|
+
p["row_limit"] = chart.row_limit or DEFAULT_ROW_LIMIT[t]
|
|
166
|
+
p["y_axis_format"] = "SMART_NUMBER"
|
|
167
|
+
p["rich_tooltip"] = True
|
|
168
|
+
p["show_legend"] = True
|
|
169
|
+
if t == "timeseries_area":
|
|
170
|
+
p["opacity"] = 0.2
|
|
171
|
+
if t == "timeseries_scatter":
|
|
172
|
+
p["markerSize"] = 6
|
|
173
|
+
elif t == "bar":
|
|
174
|
+
p["metrics"] = [metric(m) for m in chart.metrics]
|
|
175
|
+
p["x_axis"] = chart.x_column
|
|
176
|
+
p["groupby"] = [chart.groupby] if chart.groupby else []
|
|
177
|
+
p["row_limit"] = chart.row_limit or DEFAULT_ROW_LIMIT[t]
|
|
178
|
+
p["y_axis_format"] = "SMART_NUMBER"
|
|
179
|
+
p["rich_tooltip"] = True
|
|
180
|
+
p["show_legend"] = True
|
|
181
|
+
# Rankings read sorted by their measure, not by label order. The
|
|
182
|
+
# horizontal axis renders bottom-up, so ascending puts the largest
|
|
183
|
+
# bar on top there; vertical wants descending left to right.
|
|
184
|
+
first = p["metrics"][0]
|
|
185
|
+
p["x_axis_sort"] = first["label"] if isinstance(first, dict) else first
|
|
186
|
+
p["x_axis_sort_asc"] = chart.orientation == "horizontal"
|
|
187
|
+
if chart.orientation == "horizontal":
|
|
188
|
+
p["orientation"] = "horizontal"
|
|
189
|
+
p[SDC_BAR_MARKER] = True
|
|
190
|
+
elif t == "pie":
|
|
191
|
+
p["metric"] = metric(chart.metric)
|
|
192
|
+
p["groupby"] = [chart.groupby]
|
|
193
|
+
p["row_limit"] = chart.row_limit or DEFAULT_ROW_LIMIT[t]
|
|
194
|
+
p["sort_by_metric"] = True
|
|
195
|
+
p["show_labels_threshold"] = 5
|
|
196
|
+
p["label_type"] = "key_percent"
|
|
197
|
+
if chart.donut:
|
|
198
|
+
p["donut"] = True
|
|
199
|
+
elif t == "table":
|
|
200
|
+
if chart.columns:
|
|
201
|
+
p["query_mode"] = "raw"
|
|
202
|
+
p["all_columns"] = chart.columns
|
|
203
|
+
else:
|
|
204
|
+
p["query_mode"] = "aggregate"
|
|
205
|
+
p["groupby"] = chart.groupby or []
|
|
206
|
+
p["metrics"] = [metric(m) for m in (chart.metrics or [])]
|
|
207
|
+
p["row_limit"] = chart.row_limit or DEFAULT_ROW_LIMIT[t]
|
|
208
|
+
p["server_page_length"] = 10
|
|
209
|
+
if chart.sort_by:
|
|
210
|
+
p["order_desc"] = True
|
|
211
|
+
elif t == "pivot_table":
|
|
212
|
+
p["groupbyRows"] = chart.rows
|
|
213
|
+
p["groupbyColumns"] = chart.columns
|
|
214
|
+
p["metrics"] = [metric(m) for m in chart.metrics]
|
|
215
|
+
p["aggregateFunction"] = "Sum"
|
|
216
|
+
p["metricsLayout"] = "COLUMNS"
|
|
217
|
+
p["rowOrder"] = "key_a_to_z"
|
|
218
|
+
p["colOrder"] = "key_a_to_z"
|
|
219
|
+
p["valueFormat"] = "SMART_NUMBER"
|
|
220
|
+
p["row_limit"] = chart.row_limit or DEFAULT_ROW_LIMIT[t]
|
|
221
|
+
# Emitted only when set, so pre-feature bundles stay byte-identical.
|
|
222
|
+
if chart.combine_metric:
|
|
223
|
+
p["combineMetric"] = True
|
|
224
|
+
if chart.date_format:
|
|
225
|
+
p["date_format"] = chart.date_format
|
|
226
|
+
if chart.conditional_formatting:
|
|
227
|
+
p["conditional_formatting"] = [
|
|
228
|
+
_format_rule_payload(r) for r in chart.conditional_formatting
|
|
229
|
+
]
|
|
230
|
+
elif t == "heatmap":
|
|
231
|
+
p["x_axis"] = chart.x_column
|
|
232
|
+
p["groupby"] = chart.y_column
|
|
233
|
+
p["metric"] = metric(chart.metric)
|
|
234
|
+
p["normalize_across"] = "heatmap"
|
|
235
|
+
p["legend_type"] = "continuous"
|
|
236
|
+
p["linear_color_scheme"] = "superset_seq_1"
|
|
237
|
+
p["sort_x_axis"] = "alpha_asc"
|
|
238
|
+
p["sort_y_axis"] = "alpha_asc"
|
|
239
|
+
p["show_legend"] = True
|
|
240
|
+
p["row_limit"] = chart.row_limit or DEFAULT_ROW_LIMIT[t]
|
|
241
|
+
elif t == "histogram":
|
|
242
|
+
p["column"] = chart.column
|
|
243
|
+
p["bins"] = chart.bins
|
|
244
|
+
p["groupby"] = [chart.groupby] if chart.groupby else []
|
|
245
|
+
p["normalize"] = False
|
|
246
|
+
p["row_limit"] = chart.row_limit or DEFAULT_ROW_LIMIT[t]
|
|
247
|
+
elif t == "funnel":
|
|
248
|
+
p["metric"] = metric(chart.metric)
|
|
249
|
+
p["groupby"] = [chart.groupby]
|
|
250
|
+
p["sort_by_metric"] = True
|
|
251
|
+
p["row_limit"] = chart.row_limit or DEFAULT_ROW_LIMIT[t]
|
|
252
|
+
elif t == "treemap":
|
|
253
|
+
p["metric"] = metric(chart.metric)
|
|
254
|
+
p["groupby"] = chart.groupby
|
|
255
|
+
p["row_limit"] = chart.row_limit or DEFAULT_ROW_LIMIT[t]
|
|
256
|
+
|
|
257
|
+
# Bind charts without their own TIME column to the dataset's main temporal
|
|
258
|
+
# column. The dashboard time filter reaches a chart only through a time
|
|
259
|
+
# binding; without one the chart silently ignores it while the filter bar
|
|
260
|
+
# still counts it as filtered (verified live on 6.1.0: full-month total
|
|
261
|
+
# under a one-week default). The categorical bar's x_axis is not a time
|
|
262
|
+
# column, so it needs the binding too.
|
|
263
|
+
timeseries = ("timeseries_line", "timeseries_bar", "timeseries_area",
|
|
264
|
+
"timeseries_scatter", "big_number_trend")
|
|
265
|
+
if t not in timeseries and ds.main_dttm_col:
|
|
266
|
+
p["granularity_sqla"] = ds.main_dttm_col
|
|
267
|
+
return p
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _chart_yaml(chart, spec: DashboardSpec, resolution: Resolution) -> dict:
|
|
271
|
+
ds = resolution.for_chart(chart.dataset)
|
|
272
|
+
return {
|
|
273
|
+
"slice_name": chart.name,
|
|
274
|
+
"description": None,
|
|
275
|
+
"certified_by": None,
|
|
276
|
+
"certification_details": None,
|
|
277
|
+
"viz_type": VIZ_TYPE[chart.type],
|
|
278
|
+
"params": _chart_params(chart, spec, resolution),
|
|
279
|
+
"query_context": None,
|
|
280
|
+
"cache_timeout": None,
|
|
281
|
+
"uuid": str(ids.chart_uuid(spec.dashboard.slug, chart.name)),
|
|
282
|
+
"version": "1.0.0",
|
|
283
|
+
"dataset_uuid": ds.uuid,
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _rows_into(pos: dict, rows, spec: DashboardSpec, parents: list[str], prefix: str, counter: list[int]) -> list[str]:
|
|
288
|
+
"""Emit ROW/CHART/MARKDOWN nodes for a list of rows; returns row ids."""
|
|
289
|
+
slug = spec.dashboard.slug
|
|
290
|
+
row_ids: list[str] = []
|
|
291
|
+
for i, row in enumerate(rows):
|
|
292
|
+
row_id = f"ROW-{prefix}{i + 1}"
|
|
293
|
+
child_ids: list[str] = []
|
|
294
|
+
for j, item in enumerate(row):
|
|
295
|
+
if isinstance(item, MarkdownBlock):
|
|
296
|
+
md_id = f"MARKDOWN-{prefix}{i + 1}-{j + 1}"
|
|
297
|
+
child_ids.append(md_id)
|
|
298
|
+
pos[md_id] = {
|
|
299
|
+
"type": "MARKDOWN",
|
|
300
|
+
"id": md_id,
|
|
301
|
+
"children": [],
|
|
302
|
+
"parents": [*parents, row_id],
|
|
303
|
+
"meta": {
|
|
304
|
+
"code": item.markdown,
|
|
305
|
+
"width": spec.resolved_item_width(item),
|
|
306
|
+
"height": (item.height or 4) * ROW_UNITS_PER_SPEC_UNIT,
|
|
307
|
+
},
|
|
308
|
+
}
|
|
309
|
+
continue
|
|
310
|
+
counter[0] += 1
|
|
311
|
+
cuuid = ids.chart_uuid(slug, item)
|
|
312
|
+
chart_id = f"CHART-sdc-{cuuid.hex[:10]}"
|
|
313
|
+
child_ids.append(chart_id)
|
|
314
|
+
pos[chart_id] = {
|
|
315
|
+
"type": "CHART",
|
|
316
|
+
"id": chart_id,
|
|
317
|
+
"children": [],
|
|
318
|
+
"parents": [*parents, row_id],
|
|
319
|
+
"meta": {
|
|
320
|
+
"uuid": str(cuuid),
|
|
321
|
+
"sliceName": item,
|
|
322
|
+
"width": spec.resolved_item_width(item),
|
|
323
|
+
"height": int(round(spec.resolved_height(item) * ROW_UNITS_PER_SPEC_UNIT)),
|
|
324
|
+
# Placeholder the importer requires and remaps via uuid.
|
|
325
|
+
"chartId": 100000 + counter[0],
|
|
326
|
+
},
|
|
327
|
+
}
|
|
328
|
+
pos[row_id] = {
|
|
329
|
+
"type": "ROW",
|
|
330
|
+
"id": row_id,
|
|
331
|
+
"children": child_ids,
|
|
332
|
+
"parents": parents,
|
|
333
|
+
"meta": {"background": "BACKGROUND_TRANSPARENT"},
|
|
334
|
+
}
|
|
335
|
+
row_ids.append(row_id)
|
|
336
|
+
return row_ids
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _sketch_chart_node(pos, spec, sc, width, parents, counter) -> str:
|
|
340
|
+
"""Emit one CHART node from a sketch cell; explicit chart height wins."""
|
|
341
|
+
counter[0] += 1
|
|
342
|
+
cuuid = ids.chart_uuid(spec.dashboard.slug, sc.name)
|
|
343
|
+
chart_id = f"CHART-sdc-{cuuid.hex[:10]}"
|
|
344
|
+
explicit = next(c.height for c in spec.charts if c.name == sc.name)
|
|
345
|
+
pos[chart_id] = {
|
|
346
|
+
"type": "CHART",
|
|
347
|
+
"id": chart_id,
|
|
348
|
+
"children": [],
|
|
349
|
+
"parents": parents,
|
|
350
|
+
"meta": {
|
|
351
|
+
"uuid": str(cuuid),
|
|
352
|
+
"sliceName": sc.name,
|
|
353
|
+
"width": width,
|
|
354
|
+
"height": int(round((explicit or sc.height) * ROW_UNITS_PER_SPEC_UNIT)),
|
|
355
|
+
"chartId": 100000 + counter[0],
|
|
356
|
+
},
|
|
357
|
+
}
|
|
358
|
+
return chart_id
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _sketch_into(pos, parsed_rows, spec, parents: list[str], prefix: str, counter: list[int]) -> list[str]:
|
|
362
|
+
"""Emit ROW / COLUMN / CHART nodes from a parsed sketch (chartwright/sketch.py)."""
|
|
363
|
+
from .sketch import SketchColumn
|
|
364
|
+
|
|
365
|
+
row_ids: list[str] = []
|
|
366
|
+
for i, srow in enumerate(parsed_rows):
|
|
367
|
+
row_id = f"ROW-{prefix}{i + 1}"
|
|
368
|
+
child_ids: list[str] = []
|
|
369
|
+
for j, child in enumerate(srow.children):
|
|
370
|
+
if isinstance(child, SketchColumn):
|
|
371
|
+
col_id = f"COLUMN-{prefix}{i + 1}-{j + 1}"
|
|
372
|
+
col_children = [
|
|
373
|
+
_sketch_chart_node(pos, spec, sc, child.width, [*parents, row_id, col_id], counter)
|
|
374
|
+
for sc in child.children
|
|
375
|
+
]
|
|
376
|
+
pos[col_id] = {
|
|
377
|
+
"type": "COLUMN",
|
|
378
|
+
"id": col_id,
|
|
379
|
+
"children": col_children,
|
|
380
|
+
"parents": [*parents, row_id],
|
|
381
|
+
"meta": {"background": "BACKGROUND_TRANSPARENT", "width": child.width},
|
|
382
|
+
}
|
|
383
|
+
child_ids.append(col_id)
|
|
384
|
+
else:
|
|
385
|
+
child_ids.append(
|
|
386
|
+
_sketch_chart_node(pos, spec, child, child.width, [*parents, row_id], counter)
|
|
387
|
+
)
|
|
388
|
+
pos[row_id] = {
|
|
389
|
+
"type": "ROW",
|
|
390
|
+
"id": row_id,
|
|
391
|
+
"children": child_ids,
|
|
392
|
+
"parents": parents,
|
|
393
|
+
"meta": {"background": "BACKGROUND_TRANSPARENT"},
|
|
394
|
+
}
|
|
395
|
+
row_ids.append(row_id)
|
|
396
|
+
return row_ids
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _position(spec: DashboardSpec) -> dict:
|
|
400
|
+
"""The layout tree Superset's UI builds by drag and drop, generated from
|
|
401
|
+
the spec instead."""
|
|
402
|
+
pos: dict = {
|
|
403
|
+
"DASHBOARD_VERSION_KEY": "v2",
|
|
404
|
+
"ROOT_ID": {"type": "ROOT", "id": "ROOT_ID", "children": ["GRID_ID"]},
|
|
405
|
+
"HEADER_ID": {"type": "HEADER", "id": "HEADER_ID", "meta": {"text": spec.dashboard.title}},
|
|
406
|
+
}
|
|
407
|
+
counter = [0]
|
|
408
|
+
if spec.layout.rows:
|
|
409
|
+
grid_children = _rows_into(pos, spec.layout.rows, spec, ["ROOT_ID", "GRID_ID"], "sdc-", counter)
|
|
410
|
+
elif spec.layout.sketch:
|
|
411
|
+
grid_children = _sketch_into(
|
|
412
|
+
pos, spec.layout.parsed_sketch(), spec, ["ROOT_ID", "GRID_ID"], "sdc-", counter
|
|
413
|
+
)
|
|
414
|
+
else:
|
|
415
|
+
tabs_id = "TABS-sdc-1"
|
|
416
|
+
tab_ids: list[str] = []
|
|
417
|
+
for k, tab in enumerate(spec.layout.tabs or []):
|
|
418
|
+
tab_id = f"TAB-sdc-{k + 1}"
|
|
419
|
+
into = _sketch_into if tab.sketch else _rows_into
|
|
420
|
+
row_ids = into(
|
|
421
|
+
pos, tab.parsed_sketch() if tab.sketch else tab.rows, spec,
|
|
422
|
+
["ROOT_ID", "GRID_ID", tabs_id, tab_id],
|
|
423
|
+
f"sdc-t{k + 1}-", counter,
|
|
424
|
+
)
|
|
425
|
+
pos[tab_id] = {
|
|
426
|
+
"type": "TAB",
|
|
427
|
+
"id": tab_id,
|
|
428
|
+
"children": row_ids,
|
|
429
|
+
"parents": ["ROOT_ID", "GRID_ID", tabs_id],
|
|
430
|
+
"meta": {"text": tab.title, "defaultText": "Tab title", "placeholder": "Tab title"},
|
|
431
|
+
}
|
|
432
|
+
tab_ids.append(tab_id)
|
|
433
|
+
pos[tabs_id] = {
|
|
434
|
+
"type": "TABS",
|
|
435
|
+
"id": tabs_id,
|
|
436
|
+
"children": tab_ids,
|
|
437
|
+
"parents": ["ROOT_ID", "GRID_ID"],
|
|
438
|
+
"meta": {},
|
|
439
|
+
}
|
|
440
|
+
grid_children = [tabs_id]
|
|
441
|
+
pos["GRID_ID"] = {"type": "GRID", "id": "GRID_ID", "children": grid_children, "parents": ["ROOT_ID"]}
|
|
442
|
+
return pos
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _native_filters(spec: DashboardSpec, resolution: Resolution) -> list[dict]:
|
|
446
|
+
out = []
|
|
447
|
+
for f in spec.filters:
|
|
448
|
+
fid = "NATIVE_FILTER-sdc-" + uuid.uuid5(
|
|
449
|
+
ids.NAMESPACE, f"{spec.dashboard.slug}/filter/{f.name}"
|
|
450
|
+
).hex[:12]
|
|
451
|
+
base = {
|
|
452
|
+
"id": fid,
|
|
453
|
+
"name": f.name,
|
|
454
|
+
"description": "",
|
|
455
|
+
"cascadeParentIds": [],
|
|
456
|
+
"defaultDataMask": {"extraFormData": {}, "filterState": {}, "ownState": {}},
|
|
457
|
+
"scope": {"rootPath": ["ROOT_ID"], "excluded": []},
|
|
458
|
+
"type": "NATIVE_FILTER",
|
|
459
|
+
}
|
|
460
|
+
if f.type == "select":
|
|
461
|
+
ds = resolution.datasets[f.dataset.key()]
|
|
462
|
+
base["filterType"] = "filter_select"
|
|
463
|
+
base["targets"] = [{"column": {"name": f.column}, "datasetUuid": ds.uuid}]
|
|
464
|
+
base["controlValues"] = {
|
|
465
|
+
"multiSelect": f.multi,
|
|
466
|
+
"defaultToFirstItem": False,
|
|
467
|
+
"enableEmptyFilter": False,
|
|
468
|
+
"inverseSelection": False,
|
|
469
|
+
"searchAllOptions": False,
|
|
470
|
+
}
|
|
471
|
+
elif f.type == "range":
|
|
472
|
+
ds = resolution.datasets[f.dataset.key()]
|
|
473
|
+
base["filterType"] = "filter_range"
|
|
474
|
+
base["targets"] = [{"column": {"name": f.column}, "datasetUuid": ds.uuid}]
|
|
475
|
+
base["controlValues"] = _range_control_values(f)
|
|
476
|
+
mask = _range_default_mask(f)
|
|
477
|
+
if mask:
|
|
478
|
+
base["defaultDataMask"] = mask
|
|
479
|
+
if f.charts:
|
|
480
|
+
# Tool-owned, name-based scope marker (filter entries are opaque
|
|
481
|
+
# dicts to Superset). The bundle ships ROOT scope; slice ids
|
|
482
|
+
# don't exist at compile time; apply's scope stage rewrites live
|
|
483
|
+
# ids, so without this the scope is unrecoverable on decompile.
|
|
484
|
+
base["sdc_scope_charts"] = list(f.charts)
|
|
485
|
+
else: # time_range
|
|
486
|
+
base["filterType"] = "filter_time"
|
|
487
|
+
base["targets"] = [{}]
|
|
488
|
+
base["controlValues"] = {}
|
|
489
|
+
if f.default:
|
|
490
|
+
# Same contract as the range default: BOTH halves or it never
|
|
491
|
+
# hydrates (TimeFilterPlugin reads filterState.value for the
|
|
492
|
+
# pill; queries read extraFormData.time_range).
|
|
493
|
+
base["defaultDataMask"] = {
|
|
494
|
+
"extraFormData": {"time_range": f.default},
|
|
495
|
+
"filterState": {"value": f.default},
|
|
496
|
+
"ownState": {},
|
|
497
|
+
}
|
|
498
|
+
out.append(base)
|
|
499
|
+
return out
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _num(v: float) -> float | int:
|
|
503
|
+
"""8.0 -> 8: match how the Range plugin's own labels/values render integers."""
|
|
504
|
+
return int(v) if v == int(v) else v
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _range_control_values(f) -> dict:
|
|
508
|
+
"""SingleValueType is a bare TS enum: the stored values are NUMERIC
|
|
509
|
+
(Minimum=0, Exact=1, Maximum=2). A string here never hydrates."""
|
|
510
|
+
cv: dict = {"enableEmptyFilter": False}
|
|
511
|
+
if f.ge is not None and f.le is None:
|
|
512
|
+
cv["enableSingleValue"] = 0
|
|
513
|
+
elif f.ge is not None and f.le is not None and f.ge == f.le:
|
|
514
|
+
cv["enableSingleValue"] = 1
|
|
515
|
+
elif f.le is not None and f.ge is None:
|
|
516
|
+
cv["enableSingleValue"] = 2
|
|
517
|
+
return cv
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def _range_default_mask(f) -> dict | None:
|
|
521
|
+
"""Mirror the plugin's getRangeExtraFormData + getLabel: the default needs
|
|
522
|
+
BOTH the prebuilt query filters and the filterState, or it never applies."""
|
|
523
|
+
if f.ge is None and f.le is None:
|
|
524
|
+
return None
|
|
525
|
+
ge = None if f.ge is None else _num(f.ge)
|
|
526
|
+
le = None if f.le is None else _num(f.le)
|
|
527
|
+
filters: list[dict] = []
|
|
528
|
+
if ge is not None and ge != le:
|
|
529
|
+
filters.append({"col": f.column, "op": ">=", "val": ge})
|
|
530
|
+
if le is not None and le != ge:
|
|
531
|
+
filters.append({"col": f.column, "op": "<=", "val": le})
|
|
532
|
+
if ge is not None and le is not None and ge == le:
|
|
533
|
+
filters.append({"col": f.column, "op": "==", "val": le})
|
|
534
|
+
if ge is not None and le is not None:
|
|
535
|
+
label = f"x = {le}" if ge == le else f"{ge} ≤ x ≤ {le}"
|
|
536
|
+
elif le is not None:
|
|
537
|
+
label = f"x ≤ {le}"
|
|
538
|
+
else:
|
|
539
|
+
label = f"x ≥ {ge}"
|
|
540
|
+
return {
|
|
541
|
+
"extraFormData": {"filters": filters},
|
|
542
|
+
"filterState": {"value": [ge, le], "label": label},
|
|
543
|
+
"ownState": {},
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def _dashboard_yaml(spec: DashboardSpec, resolution: Resolution) -> dict:
|
|
548
|
+
metadata: dict = {
|
|
549
|
+
"color_scheme": "",
|
|
550
|
+
"cross_filters_enabled": False,
|
|
551
|
+
"expanded_slices": {},
|
|
552
|
+
"label_colors": {},
|
|
553
|
+
"refresh_frequency": 0,
|
|
554
|
+
"timed_refresh_immune_slices": [],
|
|
555
|
+
}
|
|
556
|
+
if spec.filters:
|
|
557
|
+
metadata["native_filter_configuration"] = _native_filters(spec, resolution)
|
|
558
|
+
return {
|
|
559
|
+
"dashboard_title": spec.dashboard.title,
|
|
560
|
+
"description": None,
|
|
561
|
+
"css": "",
|
|
562
|
+
"slug": spec.dashboard.slug,
|
|
563
|
+
"certified_by": None,
|
|
564
|
+
"certification_details": None,
|
|
565
|
+
"published": True,
|
|
566
|
+
"uuid": str(ids.dashboard_uuid(spec.dashboard.slug)),
|
|
567
|
+
"position": _position(spec),
|
|
568
|
+
"metadata": metadata,
|
|
569
|
+
"version": "1.0.0",
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def compile_bundle(
|
|
574
|
+
spec: DashboardSpec,
|
|
575
|
+
resolution: Resolution,
|
|
576
|
+
extra_files: dict[str, bytes] | None = None,
|
|
577
|
+
) -> bytes:
|
|
578
|
+
"""extra_files: round-tripped dataset/database YAMLs the importer requires,
|
|
579
|
+
path -> bytes, relative to the bundle root."""
|
|
580
|
+
files: dict[str, bytes] = {
|
|
581
|
+
f"{BUNDLE_ROOT}/metadata.yaml": _yaml(
|
|
582
|
+
{"version": "1.0.0", "type": "Dashboard", "timestamp": FIXED_TIMESTAMP}
|
|
583
|
+
),
|
|
584
|
+
f"{BUNDLE_ROOT}/dashboards/{spec.dashboard.slug}.yaml": _yaml(_dashboard_yaml(spec, resolution)),
|
|
585
|
+
}
|
|
586
|
+
for chart in spec.charts:
|
|
587
|
+
safe = "".join(ch if ch.isalnum() else "_" for ch in chart.name)
|
|
588
|
+
files[f"{BUNDLE_ROOT}/charts/{safe}_{ids.chart_uuid(spec.dashboard.slug, chart.name).hex[:8]}.yaml"] = _yaml(
|
|
589
|
+
_chart_yaml(chart, spec, resolution)
|
|
590
|
+
)
|
|
591
|
+
for rel, content in (extra_files or {}).items():
|
|
592
|
+
files[f"{BUNDLE_ROOT}/{rel}"] = content
|
|
593
|
+
|
|
594
|
+
buf = io.BytesIO()
|
|
595
|
+
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
596
|
+
for name in sorted(files):
|
|
597
|
+
info = zipfile.ZipInfo(name, date_time=ZIP_DATE_TIME)
|
|
598
|
+
info.external_attr = 0o644 << 16
|
|
599
|
+
info.create_system = 3 # pin to Unix: byte-stable across build platforms
|
|
600
|
+
zf.writestr(info, files[name])
|
|
601
|
+
return buf.getvalue()
|