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,208 @@
1
+ """v1 plan/drift: what would apply change, and has the live dashboard drifted
2
+ from its spec? Terraform's plan verb, one dashboard at a time.
3
+
4
+ Exit contract (CLI): 0 = no changes (clean), 1 = changes pending / drift.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from dataclasses import dataclass, field
11
+
12
+ from . import ids
13
+ from .client import SupersetClient
14
+ from .decompile import decompile_live
15
+ from .spec import DEFAULT_ROW_LIMIT, DEFAULT_TIME_GRAIN, DashboardSpec, load_spec
16
+
17
+
18
+ @dataclass
19
+ class Plan:
20
+ dashboard: str # create | update | unchanged | blocked
21
+ detail: str | None = None
22
+ charts_added: list[str] = field(default_factory=list)
23
+ charts_changed: list[str] = field(default_factory=list)
24
+ charts_removed: list[str] = field(default_factory=list) # live-but-not-in-spec = orphaned on apply
25
+ filters_added: list[str] = field(default_factory=list)
26
+ filters_changed: list[str] = field(default_factory=list) # incl. live numeric scopes out of sync
27
+ filters_removed: list[str] = field(default_factory=list)
28
+ title_changed: bool = False
29
+ layout_changed: bool = False
30
+ decompile_losses: list[dict] = field(default_factory=list)
31
+
32
+ @property
33
+ def clean(self) -> bool:
34
+ return self.dashboard == "unchanged" and not (
35
+ self.charts_added or self.charts_changed or self.charts_removed
36
+ or self.filters_added or self.filters_changed or self.filters_removed
37
+ or self.title_changed or self.layout_changed
38
+ )
39
+
40
+ def to_json(self) -> str:
41
+ return json.dumps(
42
+ {
43
+ "dashboard": self.dashboard,
44
+ "detail": self.detail,
45
+ "clean": self.clean,
46
+ "charts_added": self.charts_added,
47
+ "charts_changed": self.charts_changed,
48
+ "charts_removed": self.charts_removed,
49
+ "filters_added": self.filters_added,
50
+ "filters_changed": self.filters_changed,
51
+ "filters_removed": self.filters_removed,
52
+ "title_changed": self.title_changed,
53
+ "layout_changed": self.layout_changed,
54
+ "decompile_losses": self.decompile_losses,
55
+ },
56
+ indent=2,
57
+ )
58
+
59
+
60
+ def _normalize(spec: DashboardSpec) -> dict:
61
+ """Canonical form for comparison: validated model dump with every
62
+ compiler default materialized, so spec-with-defaults-omitted and
63
+ decompiled-with-defaults-present compare equal. Chart identity is the
64
+ name, so chart list order is canonicalized by name."""
65
+ data = spec.model_dump(exclude_none=True, by_alias=True)
66
+ for chart in data["charts"]:
67
+ chart["width"] = spec.resolved_item_width(chart["name"])
68
+ chart["height"] = spec.resolved_height(chart["name"])
69
+ if chart["type"] in DEFAULT_ROW_LIMIT:
70
+ chart.setdefault("row_limit", DEFAULT_ROW_LIMIT[chart["type"]])
71
+ if chart["type"] in ("timeseries_line", "timeseries_bar", "timeseries_area",
72
+ "timeseries_scatter", "big_number_trend"):
73
+ chart.setdefault("time_grain", DEFAULT_TIME_GRAIN)
74
+ data["charts"].sort(key=lambda c: c["name"])
75
+
76
+ def norm_rows(model_rows, data_rows) -> None:
77
+ for mrow, drow in zip(model_rows, data_rows):
78
+ for mitem, ditem in zip(mrow, drow):
79
+ if not isinstance(mitem, str):
80
+ ditem["width"] = spec.resolved_item_width(mitem)
81
+ ditem.setdefault("height", 4)
82
+
83
+ def sketch_as_rows(holder) -> list[list]:
84
+ """Sketch -> the rows form decompile produces for the same dashboard
85
+ (columns flattened in order, matching decompile's named flattening),
86
+ so a sketch spec and its live state compare equal."""
87
+ rows = []
88
+ for srow in holder.parsed_sketch():
89
+ row = []
90
+ for child in srow.children:
91
+ for sc in (child.children if hasattr(child, "children") else [child]):
92
+ row.append(sc.name)
93
+ rows.append(row)
94
+ return rows
95
+
96
+ if spec.layout.sketch:
97
+ data["layout"] = {"rows": sketch_as_rows(spec.layout)}
98
+ elif spec.layout.rows:
99
+ norm_rows(spec.layout.rows, data["layout"]["rows"])
100
+ else:
101
+ for mtab, dtab in zip(spec.layout.tabs or [], data["layout"].get("tabs") or []):
102
+ if mtab.sketch:
103
+ dtab.pop("sketch", None)
104
+ dtab.pop("legend", None)
105
+ dtab["rows"] = sketch_as_rows(mtab)
106
+ else:
107
+ norm_rows(mtab.rows, dtab["rows"])
108
+ dtab.pop("line", None)
109
+ # `line` is sketch drawing config (units per sketch line), not geometry;
110
+ # its non-None default survives the dump and would false-drift a sketch
111
+ # spec against its rows-form live state.
112
+ data["layout"].pop("line", None)
113
+ return data
114
+
115
+
116
+ def plan(target: DashboardSpec, client: SupersetClient) -> Plan:
117
+ from .resolver import resolve
118
+
119
+ existing = client.find_dashboard_by_slug(target.dashboard.slug)
120
+ if existing is None:
121
+ return Plan(dashboard="create", detail="no dashboard at this slug",
122
+ charts_added=[c.name for c in target.charts])
123
+
124
+ # uuid comes from the LIST row: Superset 4.x omits uuid from the detail
125
+ # API (docs/CONTRACTS.md) but every supported version includes it in
126
+ # list_columns; find_dashboard_by_slug already returned it.
127
+ live_uuid = existing.get("uuid") or client.get(
128
+ f"/api/v1/dashboard/{existing['id']}")["result"].get("uuid")
129
+ if str(live_uuid) != str(ids.dashboard_uuid(target.dashboard.slug)):
130
+ return Plan(dashboard="blocked",
131
+ detail=f"slug {target.dashboard.slug!r} exists but is not owned by this tool")
132
+
133
+ resolution = resolve(target, client)
134
+ if not resolution.ok:
135
+ return Plan(dashboard="blocked",
136
+ detail=f"target spec has referential errors: {[e.as_dict() for e in resolution.errors]}")
137
+
138
+ live_result = decompile_live(target.dashboard.slug, client)
139
+ try:
140
+ live_spec = load_spec(live_result.spec)
141
+ except Exception as e: # noqa: BLE001 - decompiled live state can be arbitrarily degraded
142
+ return Plan(dashboard="update",
143
+ detail=f"live dashboard not representable as a spec ({e}); apply will rebuild it",
144
+ decompile_losses=live_result.losses_json())
145
+
146
+ t, l = _normalize(target), _normalize(live_spec)
147
+ p = Plan(dashboard="unchanged", decompile_losses=live_result.losses_json())
148
+
149
+ t_charts = {c["name"]: c for c in t["charts"]}
150
+ l_charts = {c["name"]: c for c in l["charts"]}
151
+ # Dataset identity compares by resolved uuid, not by literal triple:
152
+ # an omitted schema in the spec means "unambiguous", not "different".
153
+ for chart in target.charts:
154
+ t_charts[chart.name]["dataset"] = resolution.for_chart(chart.dataset).uuid
155
+ for name in l_charts:
156
+ l_charts[name]["dataset"] = live_result.dataset_uuids.get(name)
157
+ p.charts_added = sorted(set(t_charts) - set(l_charts))
158
+ p.charts_removed = sorted(set(l_charts) - set(t_charts))
159
+ p.charts_changed = sorted(
160
+ n for n in set(t_charts) & set(l_charts) if t_charts[n] != l_charts[n]
161
+ )
162
+ # Filters: same identity model as charts (name-keyed, dataset by resolved
163
+ # uuid). Without this, the primary real-world drift (a stale browser tab
164
+ # writing back metadata without our native filters) is invisible.
165
+ t_filters = {f["name"]: dict(f) for f in t.get("filters", [])}
166
+ l_filters = {f["name"]: dict(f) for f in l.get("filters", [])}
167
+ for f in target.filters:
168
+ if f.type in ("select", "range"):
169
+ t_filters[f.name]["dataset"] = resolution.datasets[f.dataset.key()].uuid
170
+ for name in l_filters:
171
+ u = live_result.dataset_uuids.get(f"filter:{name}")
172
+ if u:
173
+ l_filters[name]["dataset"] = u
174
+ p.filters_added = sorted(set(t_filters) - set(l_filters))
175
+ p.filters_removed = sorted(set(l_filters) - set(t_filters))
176
+ p.filters_changed = sorted(
177
+ n for n in set(t_filters) & set(l_filters) if t_filters[n] != l_filters[n]
178
+ )
179
+
180
+ # Live NUMERIC scopes must equal what apply's scope stage would write.
181
+ # The server accepts dead slice ids silently (docs/CONTRACTS.md) and
182
+ # the name-based marker still reads back clean, so recompute and diff.
183
+ if any(getattr(f, "charts", None) for f in target.filters):
184
+ from .apply import scoped_filter_fixup
185
+
186
+ detail = client.get(f"/api/v1/dashboard/{existing['id']}")["result"]
187
+ meta = json.loads(detail.get("json_metadata") or "{}")
188
+ name_to_id = {c["slice_name"]: c["id"] for c in client.dashboard_charts(existing["id"])}
189
+ fixed, errors = scoped_filter_fixup(meta, target, name_to_id)
190
+ live_nf = {nf.get("id"): nf for nf in meta.get("native_filter_configuration") or []}
191
+ for nf in fixed.get("native_filter_configuration") or []:
192
+ if nf != live_nf.get(nf.get("id")):
193
+ nm = nf.get("name") or nf.get("id")
194
+ if nm not in p.filters_changed:
195
+ p.filters_changed.append(nm)
196
+ for e in errors:
197
+ nm = f"scope: {e}"
198
+ if nm not in p.filters_changed:
199
+ p.filters_changed.append(nm)
200
+ p.filters_changed.sort()
201
+
202
+ p.title_changed = t["dashboard"]["title"] != l["dashboard"]["title"]
203
+ # Whole-layout compare: a tabs layout has no "rows" key after
204
+ # exclude_none dumping, so keyed access would KeyError.
205
+ p.layout_changed = t["layout"] != l["layout"]
206
+ if not p.clean:
207
+ p.dashboard = "update"
208
+ return p