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,7 @@
1
+ """Superset Dashboard Compiler.
2
+
3
+ Typed spec -> deterministic compiler -> guaranteed-correct Superset dashboard.
4
+ The LLM is an untrusted parser at the edge; the guarantee lives in typed code.
5
+ """
6
+
7
+ __version__ = "0.1.0"
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