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.
@@ -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()