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/dashdiff.py
ADDED
|
@@ -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
|