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/__init__.py
ADDED
chartwright/absorb.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Absorb UI height polish back into the spec file: the scalpel to
|
|
2
|
+
decompile's bulldozer.
|
|
3
|
+
|
|
4
|
+
Workflow this serves (the owner's ruling, 2026-07-11): the sketch gets a
|
|
5
|
+
dashboard 90-95% built in one shot; the last few percent of fine-grain sizing
|
|
6
|
+
happens by hand in the Superset UI. Without absorb, the next `chartwright apply`
|
|
7
|
+
rebuilds the layout from the spec and destroys that polish. `chartwright absorb`
|
|
8
|
+
reads the LIVE dashboard's geometry and patches ONLY per-chart heights into
|
|
9
|
+
the existing spec file; sketch, legend, metrics, structure untouched.
|
|
10
|
+
|
|
11
|
+
Heights only, by design: Superset widths ARE twelfths, so the UI cannot
|
|
12
|
+
express any width the sketch can't; width changes belong in the drawing.
|
|
13
|
+
Width drift is therefore REPORTED as advice, never written. Heights are where
|
|
14
|
+
the UI has finer resolution (8px) than the sketch quantum (40px); absorbed
|
|
15
|
+
values are written as fractional 40px units in 0.2 steps (8px), so the
|
|
16
|
+
polished geometry round-trips EXACTLY through the next apply.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import copy
|
|
22
|
+
from dataclasses import asdict, dataclass, field
|
|
23
|
+
|
|
24
|
+
from . import ids
|
|
25
|
+
from .compiler import ROW_UNITS_PER_SPEC_UNIT, _position
|
|
26
|
+
from .spec import DashboardSpec
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class AbsorbReport:
|
|
31
|
+
ok: bool
|
|
32
|
+
absorbed: list[dict] = field(default_factory=list) # {chart, from_px, to_px, height}
|
|
33
|
+
width_advice: list[dict] = field(default_factory=list) # {chart, spec_width, live_width}
|
|
34
|
+
unmatched_live: list[str] = field(default_factory=list)
|
|
35
|
+
detail: str | None = None
|
|
36
|
+
|
|
37
|
+
def to_json(self) -> str:
|
|
38
|
+
return json.dumps({"stage": "absorb", **asdict(self)}, indent=2)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _exact_units(row_units: int) -> float | int:
|
|
42
|
+
"""Superset row units (8px) -> spec units (40px), exactly representable."""
|
|
43
|
+
units = row_units / ROW_UNITS_PER_SPEC_UNIT
|
|
44
|
+
return int(units) if units.is_integer() else round(units, 1)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def absorb_heights(spec: DashboardSpec, spec_data: dict, live_position: dict) -> tuple[dict, AbsorbReport]:
|
|
48
|
+
"""Pure core: returns (patched copy of spec_data, report). spec_data is the
|
|
49
|
+
raw JSON the spec file holds; only charts[*].height values are touched."""
|
|
50
|
+
report = AbsorbReport(ok=True)
|
|
51
|
+
slug = spec.dashboard.slug
|
|
52
|
+
|
|
53
|
+
compiled = {
|
|
54
|
+
v["meta"]["uuid"]: v["meta"]
|
|
55
|
+
for v in _position(spec).values()
|
|
56
|
+
if isinstance(v, dict) and v.get("type") == "CHART"
|
|
57
|
+
}
|
|
58
|
+
live = {
|
|
59
|
+
v["meta"]["uuid"]: v["meta"]
|
|
60
|
+
for v in live_position.values()
|
|
61
|
+
if isinstance(v, dict) and v.get("type") == "CHART" and (v.get("meta") or {}).get("uuid")
|
|
62
|
+
}
|
|
63
|
+
report.unmatched_live = sorted(
|
|
64
|
+
(m.get("sliceName") or u) for u, m in live.items() if u not in compiled
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
out = copy.deepcopy(spec_data)
|
|
68
|
+
charts_by_name = {c["name"]: c for c in out.get("charts", [])}
|
|
69
|
+
|
|
70
|
+
for chart in spec.charts:
|
|
71
|
+
u = str(ids.chart_uuid(slug, chart.name))
|
|
72
|
+
live_meta = live.get(u)
|
|
73
|
+
if live_meta is None:
|
|
74
|
+
continue # chart not on the live board (e.g. never applied); nothing to absorb
|
|
75
|
+
want, have = compiled[u], live_meta
|
|
76
|
+
if have.get("width") != want["width"]:
|
|
77
|
+
report.width_advice.append({
|
|
78
|
+
"chart": chart.name,
|
|
79
|
+
"spec_width": want["width"],
|
|
80
|
+
"live_width": have.get("width"),
|
|
81
|
+
"note": "widths are twelfths; redraw the sketch line (absorb writes heights only)",
|
|
82
|
+
})
|
|
83
|
+
live_h = have.get("height")
|
|
84
|
+
if not isinstance(live_h, int) or live_h == want["height"]:
|
|
85
|
+
continue
|
|
86
|
+
charts_by_name[chart.name]["height"] = _exact_units(live_h)
|
|
87
|
+
report.absorbed.append({
|
|
88
|
+
"chart": chart.name,
|
|
89
|
+
"from_px": want["height"] * 8,
|
|
90
|
+
"to_px": live_h * 8,
|
|
91
|
+
"height": charts_by_name[chart.name]["height"],
|
|
92
|
+
})
|
|
93
|
+
return out, report
|
chartwright/apply.py
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
"""Orchestration: check -> prepare -> compile -> import -> linkage -> smoke.
|
|
2
|
+
|
|
3
|
+
Superset importer facts this flow is built around (verified live, 6.1.0):
|
|
4
|
+
- charts import ONLY if their dataset_uuid maps to a datasets/ file in the
|
|
5
|
+
bundle, so referenced datasets are round-tripped from the target at apply
|
|
6
|
+
time (read-only copies; existing datasets are never overwritten on import);
|
|
7
|
+
- the importer never overwrites existing charts (only the dashboard), so
|
|
8
|
+
spec-owned charts are deleted before import to make edits propagate.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import copy
|
|
14
|
+
import io
|
|
15
|
+
import json
|
|
16
|
+
import zipfile
|
|
17
|
+
from dataclasses import asdict, dataclass, field
|
|
18
|
+
from uuid import uuid5
|
|
19
|
+
|
|
20
|
+
import yaml
|
|
21
|
+
|
|
22
|
+
from . import ids
|
|
23
|
+
from .client import SupersetClient, SupersetAPIError
|
|
24
|
+
from .compiler import compile_bundle
|
|
25
|
+
from .resolver import Resolution, resolve
|
|
26
|
+
from .smoke import SmokeResult, smoke
|
|
27
|
+
from .spec import DashboardSpec
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class ApplyReport:
|
|
32
|
+
ok: bool
|
|
33
|
+
stage: str # resolve | ownership | prepare | import | linkage | scope | smoke | done
|
|
34
|
+
resolution_errors: list[dict] = field(default_factory=list)
|
|
35
|
+
import_status: int | None = None
|
|
36
|
+
import_detail: str | None = None
|
|
37
|
+
dashboard_id: int | None = None
|
|
38
|
+
dashboard_url: str | None = None
|
|
39
|
+
backup: str | None = None
|
|
40
|
+
smoke_results: list[dict] = field(default_factory=list)
|
|
41
|
+
warnings: list[str] = field(default_factory=list)
|
|
42
|
+
|
|
43
|
+
def to_json(self) -> str:
|
|
44
|
+
return json.dumps(asdict(self), indent=2)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def check(spec: DashboardSpec, client: SupersetClient) -> Resolution:
|
|
48
|
+
return resolve(spec, client)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _ownership_guard(spec: DashboardSpec, client: SupersetClient) -> str | None:
|
|
52
|
+
"""Refuse to overwrite a dashboard at our slug that we did not create.
|
|
53
|
+
uuid5-owned objects are safe to overwrite; anything else is someone's work."""
|
|
54
|
+
existing = client.find_dashboard_by_slug(spec.dashboard.slug)
|
|
55
|
+
if existing is None:
|
|
56
|
+
return None
|
|
57
|
+
# The list endpoint doesn't return uuid; fetch detail.
|
|
58
|
+
detail = client.get(f"/api/v1/dashboard/{existing['id']}")["result"]
|
|
59
|
+
existing_uuid = str(detail.get("uuid"))
|
|
60
|
+
if not detail.get("uuid"):
|
|
61
|
+
# Superset 4.x omits uuid from the detail API; the export bundle is
|
|
62
|
+
# authoritative (without this, apply can't recognize its own dashboard
|
|
63
|
+
# on 4.x and every re-apply is refused).
|
|
64
|
+
zf = zipfile.ZipFile(io.BytesIO(client.export_dashboard(existing["id"])))
|
|
65
|
+
for n in zf.namelist():
|
|
66
|
+
if "/dashboards/" in n and n.endswith(".yaml"):
|
|
67
|
+
existing_uuid = str(yaml.safe_load(zf.read(n)).get("uuid"))
|
|
68
|
+
break
|
|
69
|
+
if existing_uuid != str(ids.dashboard_uuid(spec.dashboard.slug)):
|
|
70
|
+
return (
|
|
71
|
+
f"dashboard slug {spec.dashboard.slug!r} exists (id={existing['id']}) with uuid "
|
|
72
|
+
f"{existing_uuid}, which this tool does not own; refusing to overwrite. "
|
|
73
|
+
f"Pick a different slug."
|
|
74
|
+
)
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def scoped_filter_fixup(
|
|
79
|
+
metadata: dict, spec: DashboardSpec, name_to_id: dict[str, int]
|
|
80
|
+
) -> tuple[dict, list[str]]:
|
|
81
|
+
"""Rewrite native-filter scopes for spec filters with a `charts` list.
|
|
82
|
+
|
|
83
|
+
Slice ids don't exist at compile time, so the bundle ships every filter
|
|
84
|
+
scoped to ROOT; this runs after import, when ids are known. Filters are
|
|
85
|
+
matched by their deterministic uuid5 id. Pure: returns (new metadata,
|
|
86
|
+
errors) and mutates nothing.
|
|
87
|
+
"""
|
|
88
|
+
errors: list[str] = []
|
|
89
|
+
scoped = {f.name: f.charts for f in spec.filters if getattr(f, "charts", None)}
|
|
90
|
+
if not scoped:
|
|
91
|
+
return metadata, errors
|
|
92
|
+
meta = copy.deepcopy(metadata)
|
|
93
|
+
by_id = {
|
|
94
|
+
"NATIVE_FILTER-sdc-"
|
|
95
|
+
+ uuid5(ids.NAMESPACE, f"{spec.dashboard.slug}/filter/{f.name}").hex[:12]: f.name
|
|
96
|
+
for f in spec.filters
|
|
97
|
+
}
|
|
98
|
+
seen: set[str] = set()
|
|
99
|
+
for nf in meta.get("native_filter_configuration") or []:
|
|
100
|
+
name = by_id.get(nf.get("id"))
|
|
101
|
+
if name is None:
|
|
102
|
+
continue
|
|
103
|
+
seen.add(name)
|
|
104
|
+
if name not in scoped:
|
|
105
|
+
# Unscoped filter: normalize the cached chartsInScope too; the PUT
|
|
106
|
+
# round-trip has been seen to leave it empty, which reads as "no
|
|
107
|
+
# charts" to the frontend.
|
|
108
|
+
nf["scope"] = {"rootPath": ["ROOT_ID"], "excluded": []}
|
|
109
|
+
nf["chartsInScope"] = sorted(name_to_id.values())
|
|
110
|
+
continue
|
|
111
|
+
missing = [c for c in scoped[name] if c not in name_to_id]
|
|
112
|
+
if missing:
|
|
113
|
+
errors.append(f"filter {name!r}: scoped charts not on the dashboard: {missing}")
|
|
114
|
+
continue
|
|
115
|
+
included = sorted(name_to_id[c] for c in scoped[name])
|
|
116
|
+
excluded = sorted(set(name_to_id.values()) - set(included))
|
|
117
|
+
nf["scope"] = {"rootPath": ["ROOT_ID"], "excluded": excluded}
|
|
118
|
+
nf["chartsInScope"] = included
|
|
119
|
+
for name in scoped.keys() - seen:
|
|
120
|
+
errors.append(f"filter {name!r}: not found in imported native_filter_configuration")
|
|
121
|
+
return meta, errors
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _apply_filter_scopes(spec: DashboardSpec, client: SupersetClient, dashboard_id: int) -> list[str]:
|
|
125
|
+
if not any(getattr(f, "charts", None) for f in spec.filters):
|
|
126
|
+
return []
|
|
127
|
+
detail = client.get(f"/api/v1/dashboard/{dashboard_id}")["result"]
|
|
128
|
+
metadata = json.loads(detail.get("json_metadata") or "{}")
|
|
129
|
+
name_to_id = {c["slice_name"]: c["id"] for c in client.dashboard_charts(dashboard_id)}
|
|
130
|
+
new_meta, errors = scoped_filter_fixup(metadata, spec, name_to_id)
|
|
131
|
+
if errors:
|
|
132
|
+
return errors
|
|
133
|
+
r = client.put_json(f"/api/v1/dashboard/{dashboard_id}", {"json_metadata": json.dumps(new_meta)})
|
|
134
|
+
if r.status_code != 200:
|
|
135
|
+
return [f"scope PUT failed: HTTP {r.status_code} {r.text[:300]}"]
|
|
136
|
+
return []
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _roundtrip_dataset_files(resolution: Resolution, client: SupersetClient) -> dict[str, bytes]:
|
|
140
|
+
"""Export every referenced dataset from the TARGET at apply time (never a
|
|
141
|
+
cached copy) so the bundle carries the dataset/database YAMLs the importer
|
|
142
|
+
requires. Existing datasets are not overwritten by dashboard import."""
|
|
143
|
+
extra: dict[str, bytes] = {}
|
|
144
|
+
for ds_id in sorted({d.id for d in resolution.datasets.values()}):
|
|
145
|
+
blob = client.export_dataset(ds_id)
|
|
146
|
+
zf = zipfile.ZipFile(io.BytesIO(blob))
|
|
147
|
+
for n in zf.namelist():
|
|
148
|
+
rel = n.split("/", 1)[1] if "/" in n else n
|
|
149
|
+
if rel.startswith(("datasets/", "databases/")):
|
|
150
|
+
extra.setdefault(rel, zf.read(n))
|
|
151
|
+
return extra
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def chart_payloads_from_bundle(bundle: bytes) -> dict[str, dict]:
|
|
155
|
+
"""uuid -> ChartRestApi.put payload, from the compiled bundle's chart yamls.
|
|
156
|
+
|
|
157
|
+
Used to update pre-existing owned charts IN PLACE. Slice ids must stay
|
|
158
|
+
stable across re-applies: delete+reimport mints new ids, which invalidates
|
|
159
|
+
native-filter scopes the moment any open browser tab writes its (stale,
|
|
160
|
+
dead-id) metadata back; Superset dashboard metadata is last-writer-wins
|
|
161
|
+
(observed live; docs/CONTRACTS.md, "How dashboard settings are stored")."""
|
|
162
|
+
zf = zipfile.ZipFile(io.BytesIO(bundle))
|
|
163
|
+
out: dict[str, dict] = {}
|
|
164
|
+
for n in zf.namelist():
|
|
165
|
+
if "/charts/" in n and n.endswith(".yaml"):
|
|
166
|
+
cy = yaml.safe_load(zf.read(n))
|
|
167
|
+
out[str(cy.get("uuid"))] = {
|
|
168
|
+
"slice_name": cy.get("slice_name"),
|
|
169
|
+
"viz_type": cy.get("viz_type"),
|
|
170
|
+
"params": json.dumps(cy.get("params") or {}),
|
|
171
|
+
# params changed -> any stored query context is stale
|
|
172
|
+
"query_context": None,
|
|
173
|
+
}
|
|
174
|
+
return out
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _update_owned_charts_in_place(
|
|
178
|
+
spec: DashboardSpec, client: SupersetClient, bundle: bytes,
|
|
179
|
+
existing_by_uuid: dict[str, dict],
|
|
180
|
+
) -> tuple[list[str], list[str]]:
|
|
181
|
+
"""PUT compiled params onto pre-existing owned charts (ids stay stable).
|
|
182
|
+
Returns (updated chart names, errors)."""
|
|
183
|
+
owned = {str(ids.chart_uuid(spec.dashboard.slug, c.name)): c.name for c in spec.charts}
|
|
184
|
+
payloads = chart_payloads_from_bundle(bundle)
|
|
185
|
+
updated, errors = [], []
|
|
186
|
+
for u, summary in existing_by_uuid.items():
|
|
187
|
+
payload = payloads.get(u)
|
|
188
|
+
if payload is None:
|
|
189
|
+
continue
|
|
190
|
+
r = client.put_json(f"/api/v1/chart/{summary['id']}", payload)
|
|
191
|
+
if r.status_code != 200:
|
|
192
|
+
errors.append(f"chart {owned.get(u, u)!r}: update PUT HTTP {r.status_code} {r.text[:200]}")
|
|
193
|
+
else:
|
|
194
|
+
updated.append(owned.get(u, u))
|
|
195
|
+
return updated, errors
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def backup_dir_for(profile: str, slug: str) -> "Path":
|
|
199
|
+
"""~/.config/chartwright/backups/<profile>/<slug> (or $CHARTWRIGHT_BACKUP_DIR/<profile>/<slug>).
|
|
200
|
+
|
|
201
|
+
Namespaced by PROFILE, not just slug: the same slug on two instances yields
|
|
202
|
+
the same uuid5, so restore's ownership guard cannot tell a sandbox zip from
|
|
203
|
+
a production one; only the directory layout can. CWD-independent."""
|
|
204
|
+
import os
|
|
205
|
+
from pathlib import Path
|
|
206
|
+
|
|
207
|
+
custom = os.environ.get("CHARTWRIGHT_BACKUP_DIR")
|
|
208
|
+
base = Path(custom) if custom else Path.home() / ".config" / "chartwright" / "backups"
|
|
209
|
+
return base / profile / slug
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def restore_bundle(zip_bytes: bytes, slug: str, client: SupersetClient) -> ApplyReport:
|
|
213
|
+
"""Restore a backup bundle COMPLETELY, not just import it. The importer
|
|
214
|
+
never overwrites existing charts (docs/CONTRACTS.md), so surviving
|
|
215
|
+
charts' params are PUT back in place from the bundle; and bundles ship
|
|
216
|
+
ROOT filter scopes by design, so numeric scopes are recomputed from the
|
|
217
|
+
bundle's own name-based markers (via its decompiled spec)."""
|
|
218
|
+
report = ApplyReport(ok=False, stage="restore")
|
|
219
|
+
try:
|
|
220
|
+
r = client.import_dashboard_bundle(zip_bytes, overwrite=True)
|
|
221
|
+
report.import_status = r.status_code
|
|
222
|
+
if r.status_code != 200:
|
|
223
|
+
report.import_detail = r.text[:2000]
|
|
224
|
+
return report
|
|
225
|
+
|
|
226
|
+
payloads = chart_payloads_from_bundle(zip_bytes)
|
|
227
|
+
existing = client.charts_by_uuids(
|
|
228
|
+
{u: p["slice_name"] for u, p in payloads.items()})
|
|
229
|
+
restored = []
|
|
230
|
+
for u, row in existing.items():
|
|
231
|
+
rr = client.put_json(f"/api/v1/chart/{row['id']}", payloads[u])
|
|
232
|
+
if rr.status_code != 200:
|
|
233
|
+
report.warnings.append(
|
|
234
|
+
f"chart {payloads[u]['slice_name']!r}: params restore PUT HTTP {rr.status_code}")
|
|
235
|
+
else:
|
|
236
|
+
restored.append(payloads[u]["slice_name"])
|
|
237
|
+
if restored:
|
|
238
|
+
report.warnings.append(f"restored chart params in place: {sorted(restored)}")
|
|
239
|
+
|
|
240
|
+
dash = client.find_dashboard_by_slug(slug)
|
|
241
|
+
if dash is None:
|
|
242
|
+
report.import_detail = "restore imported but dashboard not found at slug"
|
|
243
|
+
return report
|
|
244
|
+
report.dashboard_id = dash["id"]
|
|
245
|
+
report.dashboard_url = f"{client.base_url}/superset/dashboard/{slug}/"
|
|
246
|
+
|
|
247
|
+
from .decompile import decompile_bundle, live_dataset_lookup
|
|
248
|
+
from .spec import load_spec
|
|
249
|
+
|
|
250
|
+
dec = decompile_bundle(zip_bytes, live_dataset_lookup(client))
|
|
251
|
+
try:
|
|
252
|
+
spec = load_spec(dec.spec)
|
|
253
|
+
except Exception as e: # noqa: BLE001 - degraded bundles restore without scopes
|
|
254
|
+
report.warnings.append(f"scopes not reapplied (bundle spec not loadable: {e})")
|
|
255
|
+
else:
|
|
256
|
+
scope_errors = _apply_filter_scopes(spec, client, report.dashboard_id)
|
|
257
|
+
for e in scope_errors:
|
|
258
|
+
report.warnings.append(f"scope reapply: {e}")
|
|
259
|
+
except SupersetAPIError as e:
|
|
260
|
+
report.import_detail = f"{e} (status={e.status})"
|
|
261
|
+
return report
|
|
262
|
+
report.stage = "done"
|
|
263
|
+
report.ok = True
|
|
264
|
+
return report
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def apply(spec: DashboardSpec, client: SupersetClient, profile: str = "default") -> ApplyReport:
|
|
268
|
+
report = ApplyReport(ok=False, stage="resolve")
|
|
269
|
+
|
|
270
|
+
resolution = resolve(spec, client)
|
|
271
|
+
report.resolution_errors = [e.as_dict() for e in resolution.errors]
|
|
272
|
+
if not resolution.ok:
|
|
273
|
+
return report
|
|
274
|
+
|
|
275
|
+
report.stage = "ownership"
|
|
276
|
+
guard = _ownership_guard(spec, client)
|
|
277
|
+
if guard:
|
|
278
|
+
report.import_detail = guard
|
|
279
|
+
return report
|
|
280
|
+
|
|
281
|
+
report.stage = "prepare"
|
|
282
|
+
backup_bytes: bytes | None = None
|
|
283
|
+
existing = client.find_dashboard_by_slug(spec.dashboard.slug)
|
|
284
|
+
if existing is not None:
|
|
285
|
+
# Last-known-good insurance before we mutate anything: the previous
|
|
286
|
+
# owned state, restorable with `chartwright restore <zip> --profile ...`.
|
|
287
|
+
import datetime
|
|
288
|
+
import os
|
|
289
|
+
|
|
290
|
+
backup_dir = backup_dir_for(profile, spec.dashboard.slug)
|
|
291
|
+
backup_dir.mkdir(parents=True, exist_ok=True)
|
|
292
|
+
if not os.environ.get("CHARTWRIGHT_BACKUP_DIR"):
|
|
293
|
+
# Zips hold dashboard/dataset metadata; gate the default tree to
|
|
294
|
+
# the owner. (No-op on Windows; custom dirs are the user's to manage.)
|
|
295
|
+
os.chmod(backup_dir.parent.parent, 0o700)
|
|
296
|
+
stamp = datetime.datetime.now().strftime("%Y%m%dT%H%M%S")
|
|
297
|
+
backup_path = backup_dir / f"{stamp}.zip"
|
|
298
|
+
backup_bytes = client.export_dashboard(existing["id"])
|
|
299
|
+
backup_path.write_bytes(backup_bytes)
|
|
300
|
+
report.backup = str(backup_path)
|
|
301
|
+
|
|
302
|
+
def _auto_restore(reason: str) -> None:
|
|
303
|
+
"""Import failed mid-mutation: put the previous state back rather
|
|
304
|
+
than leaving a half-updated dashboard live. Full restore: dashboard
|
|
305
|
+
state, surviving charts' params, and numeric filter scopes."""
|
|
306
|
+
if backup_bytes is None:
|
|
307
|
+
return
|
|
308
|
+
try:
|
|
309
|
+
rr = restore_bundle(backup_bytes, spec.dashboard.slug, client)
|
|
310
|
+
if rr.ok:
|
|
311
|
+
report.warnings.append(f"{reason}; previous state AUTO-RESTORED from backup")
|
|
312
|
+
else:
|
|
313
|
+
report.warnings.append(
|
|
314
|
+
f"{reason}; auto-restore also failed ({rr.import_detail}); "
|
|
315
|
+
f"restore manually: chartwright restore {report.backup}"
|
|
316
|
+
)
|
|
317
|
+
except Exception as e: # noqa: BLE001 - restore is best-effort recovery
|
|
318
|
+
report.warnings.append(
|
|
319
|
+
f"{reason}; auto-restore errored ({e}); restore manually: chartwright restore {report.backup}"
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
# Everything below mutates the instance; any failure must still return a
|
|
323
|
+
# report (it carries the backup path) rather than a traceback.
|
|
324
|
+
try:
|
|
325
|
+
if existing is not None:
|
|
326
|
+
# Owned charts that fell OUT of the spec (removed or renamed away)
|
|
327
|
+
# must be deleted, not left behind: pre-6.1 importers MERGE
|
|
328
|
+
# dashboard_slices, so a lingering owned chart stays linked and
|
|
329
|
+
# fails linkage (docs/CONTRACTS.md); on every version it would
|
|
330
|
+
# otherwise accumulate as an instance orphan. uuid5 ownership
|
|
331
|
+
# (uuid == chart_uuid(slug, its own name)) gates the delete;
|
|
332
|
+
# user charts and UI-renamed drift are never touched.
|
|
333
|
+
stale = {c["slice_name"] for c in client.dashboard_charts(existing["id"])}
|
|
334
|
+
stale -= {c.name for c in spec.charts}
|
|
335
|
+
if stale:
|
|
336
|
+
owned_stale = client.charts_by_uuids(
|
|
337
|
+
{str(ids.chart_uuid(spec.dashboard.slug, n)): n for n in stale})
|
|
338
|
+
for u, row in owned_stale.items():
|
|
339
|
+
client.delete_chart(row["id"])
|
|
340
|
+
if owned_stale:
|
|
341
|
+
report.warnings.append(
|
|
342
|
+
f"deleted owned charts no longer in spec: {sorted(row['slice_name'] for row in owned_stale.values())}"
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
extra = _roundtrip_dataset_files(resolution, client)
|
|
346
|
+
# Slice ids must survive re-apply (see chart_payloads_from_bundle): existing
|
|
347
|
+
# owned charts are updated in place AFTER import; the importer creates only
|
|
348
|
+
# the missing ones and never overwrites existing charts.
|
|
349
|
+
owned_names = {str(ids.chart_uuid(spec.dashboard.slug, c.name)): c.name for c in spec.charts}
|
|
350
|
+
existing_by_uuid = client.charts_by_uuids(owned_names)
|
|
351
|
+
|
|
352
|
+
report.stage = "import"
|
|
353
|
+
bundle = compile_bundle(spec, resolution, extra_files=extra)
|
|
354
|
+
r = client.import_dashboard_bundle(bundle, overwrite=True)
|
|
355
|
+
report.import_status = r.status_code
|
|
356
|
+
if r.status_code != 200:
|
|
357
|
+
report.import_detail = r.text[:2000]
|
|
358
|
+
_auto_restore(f"import failed (HTTP {r.status_code})")
|
|
359
|
+
return report
|
|
360
|
+
|
|
361
|
+
if existing_by_uuid:
|
|
362
|
+
updated, update_errors = _update_owned_charts_in_place(spec, client, bundle, existing_by_uuid)
|
|
363
|
+
if update_errors:
|
|
364
|
+
report.import_detail = "; ".join(update_errors)
|
|
365
|
+
return report
|
|
366
|
+
if updated:
|
|
367
|
+
report.warnings.append(
|
|
368
|
+
f"re-apply: updated owned charts in place (ids stable): {sorted(updated)}"
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
dash = client.find_dashboard_by_slug(spec.dashboard.slug)
|
|
372
|
+
if dash:
|
|
373
|
+
report.dashboard_id = dash["id"]
|
|
374
|
+
report.dashboard_url = f"{client.base_url}/superset/dashboard/{spec.dashboard.slug}/"
|
|
375
|
+
|
|
376
|
+
report.stage = "linkage"
|
|
377
|
+
if report.dashboard_id is None:
|
|
378
|
+
report.import_detail = "import returned 200 but dashboard not found at slug"
|
|
379
|
+
return report
|
|
380
|
+
linked = {c["slice_name"] for c in client.dashboard_charts(report.dashboard_id)}
|
|
381
|
+
expected = {c.name for c in spec.charts}
|
|
382
|
+
if linked != expected:
|
|
383
|
+
report.import_detail = (
|
|
384
|
+
f"dashboard chart linkage mismatch: missing={sorted(expected - linked)}, "
|
|
385
|
+
f"unexpected={sorted(linked - expected)}"
|
|
386
|
+
)
|
|
387
|
+
return report
|
|
388
|
+
|
|
389
|
+
report.stage = "scope"
|
|
390
|
+
scope_errors = _apply_filter_scopes(spec, client, report.dashboard_id)
|
|
391
|
+
if scope_errors:
|
|
392
|
+
report.import_detail = "; ".join(scope_errors)
|
|
393
|
+
return report
|
|
394
|
+
|
|
395
|
+
report.stage = "smoke"
|
|
396
|
+
results: list[SmokeResult] = smoke(spec, resolution, client)
|
|
397
|
+
report.smoke_results = [asdict(s) for s in results]
|
|
398
|
+
report.warnings.extend(f"{s.chart}: {s.detail}" for s in results if s.warning)
|
|
399
|
+
if any(not s.ok for s in results):
|
|
400
|
+
return report
|
|
401
|
+
except SupersetAPIError as e:
|
|
402
|
+
report.import_detail = f"{e} (status={e.status})"
|
|
403
|
+
if report.stage in ("prepare", "import"):
|
|
404
|
+
_auto_restore(f"apply failed during {report.stage}: {e}")
|
|
405
|
+
return report
|
|
406
|
+
|
|
407
|
+
report.stage = "done"
|
|
408
|
+
report.ok = True
|
|
409
|
+
return report
|