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/cli.py ADDED
@@ -0,0 +1,222 @@
1
+ """CLI: the deterministic back-half the LLM front-end drives.
2
+
3
+ chartwright schema print the JSON Schema the LLM must satisfy
4
+ chartwright validate spec.json schema-validate only (no network)
5
+ chartwright compile spec.json -o out.zip compile bundle with a stub resolution (golden/debug)
6
+ chartwright check spec.json --profile P pre-flight referential resolution
7
+ chartwright apply spec.json --profile P check -> compile -> import -> smoke
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import json
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ from pydantic import ValidationError
18
+
19
+ from .spec import json_schema, load_spec
20
+
21
+
22
+ def _load(path: str):
23
+ try:
24
+ # Explicit UTF-8: Windows' locale default (cp1252) would mojibake or
25
+ # reject the non-ASCII titles/markdown LLM-written specs contain.
26
+ data = json.loads(Path(path).read_text(encoding="utf-8"))
27
+ except (OSError, json.JSONDecodeError, UnicodeDecodeError) as e:
28
+ _die({"stage": "parse", "errors": [{"code": "unreadable_spec", "detail": str(e)}]})
29
+ try:
30
+ return load_spec(data)
31
+ except ValidationError as e:
32
+ _die({"stage": "schema", "errors": json.loads(e.json())})
33
+
34
+
35
+ def _die(payload: dict, code: int = 1) -> None:
36
+ print(json.dumps(payload, indent=2, default=str))
37
+ sys.exit(code)
38
+
39
+
40
+ def _client(profile_name: str):
41
+ from .client import SupersetClient
42
+ from .profiles import load_profile, ProfileError
43
+
44
+ try:
45
+ p = load_profile(profile_name)
46
+ except ProfileError as e:
47
+ _die({"stage": "profile", "errors": [{"code": "profile", "detail": str(e)}]})
48
+ c = SupersetClient(p.base_url, p.username, p.password, auth_provider=p.auth_provider,
49
+ ca_bundle=p.ca_bundle, verify=p.verify)
50
+ c.login()
51
+ return c
52
+
53
+
54
+ def main(argv: list[str] | None = None) -> None:
55
+ try:
56
+ _main(argv)
57
+ except SystemExit:
58
+ raise
59
+ except Exception as e: # noqa: BLE001 - CLI boundary: tracebacks are not the contract
60
+ from .client import SupersetAPIError
61
+
62
+ code = "api" if isinstance(e, SupersetAPIError) else "unexpected"
63
+ detail = str(e)
64
+ if code == "unexpected":
65
+ detail = f"{type(e).__name__}: {e} (please report this; it should have been a typed error)"
66
+ _die({"stage": "error", "errors": [{"code": code, "detail": detail}]})
67
+
68
+
69
+ def _main(argv: list[str] | None = None) -> None:
70
+ ap = argparse.ArgumentParser(prog="chartwright", description=__doc__)
71
+ sub = ap.add_subparsers(dest="cmd", required=True)
72
+
73
+ sub.add_parser("schema")
74
+
75
+ v = sub.add_parser("validate")
76
+ v.add_argument("spec")
77
+
78
+ comp = sub.add_parser("compile")
79
+ comp.add_argument("spec")
80
+ comp.add_argument("-o", "--output", default=None)
81
+
82
+ for name in ("check", "apply", "plan"):
83
+ p = sub.add_parser(name)
84
+ p.add_argument("spec")
85
+ p.add_argument("--profile", required=True)
86
+
87
+ dec = sub.add_parser("decompile")
88
+ dec.add_argument("dashboard", help="slug or numeric id of a live dashboard")
89
+ dec.add_argument("--profile", required=True)
90
+ dec.add_argument("-o", "--output", default=None, help="write spec JSON here; losses go to stdout")
91
+
92
+ ab = sub.add_parser("absorb", help="patch LIVE UI height polish back into the spec (heights only)")
93
+ ab.add_argument("spec")
94
+ ab.add_argument("--profile", required=True)
95
+ ab.add_argument("--dry-run", action="store_true", help="report without writing the spec file")
96
+
97
+ rst = sub.add_parser("restore", help="re-import a backup bundle written by a prior apply")
98
+ rst.add_argument("bundle", help="path to a bundle zip written by a prior apply")
99
+ rst.add_argument("--profile", required=True)
100
+
101
+ args = ap.parse_args(argv)
102
+
103
+ if args.cmd == "schema":
104
+ print(json.dumps(json_schema(), indent=2))
105
+ return
106
+
107
+ if args.cmd == "validate":
108
+ _load(args.spec)
109
+ print(json.dumps({"ok": True, "stage": "schema"}))
110
+ return
111
+
112
+ if args.cmd == "compile":
113
+ spec = _load(args.spec)
114
+ from .compiler import compile_bundle
115
+ from .testing import stub_resolution
116
+
117
+ bundle = compile_bundle(spec, stub_resolution(spec))
118
+ out = Path(args.output or f"{spec.dashboard.slug}.zip")
119
+ out.write_bytes(bundle)
120
+ print(json.dumps({"ok": True, "stage": "compile", "output": str(out), "bytes": len(bundle),
121
+ "note": "stub resolution (fake dataset ids); use apply for a real import"}))
122
+ return
123
+
124
+ if args.cmd == "check":
125
+ spec = _load(args.spec)
126
+ client = _client(args.profile)
127
+ from .apply import check
128
+
129
+ res = check(spec, client)
130
+ payload = {"ok": res.ok, "stage": "resolve", "errors": [e.as_dict() for e in res.errors]}
131
+ print(json.dumps(payload, indent=2))
132
+ sys.exit(0 if res.ok else 1)
133
+
134
+ if args.cmd == "apply":
135
+ spec = _load(args.spec)
136
+ client = _client(args.profile)
137
+ from .apply import apply as run_apply
138
+
139
+ report = run_apply(spec, client, args.profile)
140
+ print(report.to_json())
141
+ sys.exit(0 if report.ok else 1)
142
+
143
+ if args.cmd == "plan":
144
+ spec = _load(args.spec)
145
+ client = _client(args.profile)
146
+ from .dashdiff import plan as run_plan
147
+
148
+ p = run_plan(spec, client)
149
+ print(p.to_json())
150
+ sys.exit(0 if p.clean else 1)
151
+
152
+ if args.cmd == "absorb":
153
+ spec = _load(args.spec)
154
+ client = _client(args.profile)
155
+ from .absorb import absorb_heights
156
+ from .apply import _ownership_guard
157
+
158
+ guard = _ownership_guard(spec, client)
159
+ if guard:
160
+ _die({"stage": "absorb", "errors": [{"code": "ownership", "detail": guard}]})
161
+ existing = client.find_dashboard_by_slug(spec.dashboard.slug)
162
+ if existing is None:
163
+ _die({"stage": "absorb", "errors": [{"code": "not_found",
164
+ "detail": f"no live dashboard at slug {spec.dashboard.slug!r}; apply first"}]})
165
+ detail = client.get(f"/api/v1/dashboard/{existing['id']}")["result"]
166
+ live_position = json.loads(detail.get("position_json") or "{}")
167
+ spec_data = json.loads(Path(args.spec).read_text(encoding="utf-8"))
168
+ new_data, report = absorb_heights(spec, spec_data, live_position)
169
+ if report.absorbed and not args.dry_run:
170
+ Path(args.spec).write_text(
171
+ json.dumps(new_data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
172
+ print(report.to_json())
173
+ sys.exit(0)
174
+
175
+ if args.cmd == "decompile":
176
+ client = _client(args.profile)
177
+ from .decompile import decompile_live
178
+
179
+ try:
180
+ result = decompile_live(args.dashboard, client)
181
+ except ValueError as e:
182
+ _die({"stage": "decompile", "errors": [{"code": "decompile", "detail": str(e)}]})
183
+ if args.output:
184
+ Path(args.output).write_text(json.dumps(result.spec, indent=2) + "\n")
185
+ print(json.dumps({"ok": True, "stage": "decompile", "output": args.output,
186
+ "losses": result.losses_json()}, indent=2))
187
+ else:
188
+ print(json.dumps({"spec": result.spec, "losses": result.losses_json()}, indent=2))
189
+ return
190
+
191
+ if args.cmd == "restore":
192
+ try:
193
+ blob = Path(args.bundle).read_bytes()
194
+ except OSError as e:
195
+ _die({"stage": "restore", "errors": [{"code": "unreadable_bundle", "detail": str(e)}]})
196
+ # Only tool-owned bundles are restorable: same ownership rule as apply.
197
+ import io
198
+ import zipfile
199
+
200
+ import yaml
201
+
202
+ from . import ids
203
+
204
+ zf = zipfile.ZipFile(io.BytesIO(blob))
205
+ dash_files = [n for n in zf.namelist() if "/dashboards/" in n and n.endswith(".yaml")]
206
+ if not dash_files:
207
+ _die({"stage": "restore", "errors": [{"code": "bad_bundle", "detail": "no dashboard yaml in bundle"}]})
208
+ dash = yaml.safe_load(zf.read(dash_files[0]))
209
+ slug, u = dash.get("slug"), str(dash.get("uuid"))
210
+ if not slug or u != str(ids.dashboard_uuid(slug)):
211
+ _die({"stage": "restore", "errors": [{"code": "not_owned",
212
+ "detail": f"bundle dashboard (slug={slug!r}) is not owned by this tool; refusing to import"}]})
213
+ client = _client(args.profile)
214
+ from .apply import restore_bundle
215
+
216
+ report = restore_bundle(blob, slug, client)
217
+ print(report.to_json())
218
+ sys.exit(0 if report.ok else 1)
219
+
220
+
221
+ if __name__ == "__main__":
222
+ main()
chartwright/client.py ADDED
@@ -0,0 +1,227 @@
1
+ """Thin Superset REST client. Auth = JWT login + CSRF token + session cookie
2
+ (Superset requires all three for mutating multipart endpoints).
3
+
4
+ Enterprise-network posture: every requests-level failure is converted into a
5
+ typed SupersetAPIError with a hint (TLS interception -> ca_bundle, proxy,
6
+ timeouts), login guards against SSO/proxy HTML pages, and an expired access
7
+ token triggers exactly one re-login + retry (long applies on big estates).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import time
14
+ from dataclasses import dataclass, field
15
+ from typing import Callable
16
+
17
+ import requests
18
+
19
+
20
+ class SupersetAPIError(RuntimeError):
21
+ def __init__(self, message: str, status: int | None = None, body: str | None = None):
22
+ super().__init__(message)
23
+ self.status = status
24
+ self.body = body
25
+
26
+
27
+ @dataclass
28
+ class SupersetClient:
29
+ base_url: str
30
+ username: str
31
+ password: str
32
+ auth_provider: str = "db"
33
+ ca_bundle: str | None = None
34
+ verify: bool = True
35
+ session: requests.Session = field(default_factory=requests.Session)
36
+ _csrf: str | None = None
37
+ _logged_in: bool = False
38
+
39
+ def __post_init__(self) -> None:
40
+ self.base_url = self.base_url.rstrip("/")
41
+ # Corporate TLS interception: custom root CA, or (last resort) no verify.
42
+ self.session.verify = self.ca_bundle if self.ca_bundle else self.verify
43
+
44
+ # -- transport ------------------------------------------------------------
45
+
46
+ def _send(self, fn: Callable[[], requests.Response], relogin_on_401: bool = True,
47
+ tries_429: int = 3) -> requests.Response:
48
+ """Run one request with typed network errors, a single 401 retry, and
49
+ polite 429 backoff (Superset ships a rate limiter; corporate gateways
50
+ add their own)."""
51
+ try:
52
+ r = fn()
53
+ except requests.exceptions.SSLError as e:
54
+ raise SupersetAPIError(
55
+ "TLS verification failed; corporate TLS interception? Set ca_bundle "
56
+ "in the profile (path to your corp root CA .pem) or REQUESTS_CA_BUNDLE; "
57
+ f"verify=false is the last resort. Underlying: {str(e)[:300]}"
58
+ ) from e
59
+ except requests.exceptions.ProxyError as e:
60
+ raise SupersetAPIError(
61
+ f"proxy error; check HTTPS_PROXY/HTTP_PROXY/NO_PROXY: {str(e)[:300]}"
62
+ ) from e
63
+ except requests.exceptions.Timeout as e:
64
+ raise SupersetAPIError(f"request timed out against {self.base_url}: {str(e)[:200]}") from e
65
+ except requests.exceptions.RequestException as e:
66
+ raise SupersetAPIError(f"connection to {self.base_url} failed: {str(e)[:300]}") from e
67
+ if r.status_code == 401 and relogin_on_401 and self._logged_in:
68
+ # Access token expired mid-run (default TTLs are short); one refresh.
69
+ self.login()
70
+ r = self._send(fn, relogin_on_401=False)
71
+ if r.status_code == 429 and tries_429 > 0:
72
+ try:
73
+ delay = min(float(r.headers.get("Retry-After") or 2), 30)
74
+ except (TypeError, ValueError):
75
+ delay = 2.0
76
+ time.sleep(delay)
77
+ r = self._send(fn, relogin_on_401=relogin_on_401, tries_429=tries_429 - 1)
78
+ return r
79
+
80
+ # -- auth ---------------------------------------------------------------
81
+
82
+ def login(self) -> None:
83
+ r = self._send(lambda: self.session.post(
84
+ f"{self.base_url}/api/v1/security/login",
85
+ json={
86
+ "username": self.username,
87
+ "password": self.password,
88
+ "provider": self.auth_provider,
89
+ "refresh": True,
90
+ },
91
+ timeout=30,
92
+ ), relogin_on_401=False)
93
+ self._raise_for(r, "login failed")
94
+ try:
95
+ token = r.json()["access_token"]
96
+ except (ValueError, KeyError) as e:
97
+ raise SupersetAPIError(
98
+ "login returned a non-JSON or tokenless response; usually an SSO portal "
99
+ "or proxy login page intercepting the API. If this instance is SSO-only, "
100
+ "the password login API is disabled (known limitation).",
101
+ r.status_code, r.text[:300],
102
+ ) from e
103
+ self.session.headers["Authorization"] = f"Bearer {token}"
104
+ r = self._send(lambda: self.session.get(
105
+ f"{self.base_url}/api/v1/security/csrf_token/", timeout=30), relogin_on_401=False)
106
+ self._raise_for(r, "csrf token fetch failed")
107
+ self._csrf = r.json()["result"]
108
+ self.session.headers["X-CSRFToken"] = self._csrf
109
+ self.session.headers["Referer"] = self.base_url
110
+ self._logged_in = True
111
+
112
+ # -- generic ------------------------------------------------------------
113
+
114
+ def get(self, path: str, **params) -> dict:
115
+ q = {}
116
+ if params:
117
+ q["q"] = json.dumps(params.pop("q")) if "q" in params else None
118
+ q = {k: v for k, v in q.items() if v is not None}
119
+ q.update(params)
120
+ r = self._send(lambda: self.session.get(f"{self.base_url}{path}", params=q, timeout=60))
121
+ self._raise_for(r, f"GET {path} failed")
122
+ return r.json()
123
+
124
+ def post_json(self, path: str, payload: dict) -> requests.Response:
125
+ return self._send(lambda: self.session.post(f"{self.base_url}{path}", json=payload, timeout=120))
126
+
127
+ def put_json(self, path: str, payload: dict) -> requests.Response:
128
+ return self._send(lambda: self.session.put(f"{self.base_url}{path}", json=payload, timeout=120))
129
+
130
+ # -- datasets / databases -----------------------------------------------
131
+
132
+ def find_datasets(self, table: str) -> list[dict]:
133
+ out = self.get(
134
+ "/api/v1/dataset/",
135
+ q={
136
+ "filters": [{"col": "table_name", "opr": "eq", "value": table}],
137
+ "columns": ["id", "table_name", "schema", "uuid", "database.database_name", "database.id"],
138
+ "page_size": 100,
139
+ },
140
+ )
141
+ return out["result"]
142
+
143
+ def dataset_detail(self, dataset_id: int) -> dict:
144
+ return self.get(f"/api/v1/dataset/{dataset_id}")["result"]
145
+
146
+ # -- dashboards / charts ------------------------------------------------
147
+
148
+ def find_dashboard_by_slug(self, slug: str) -> dict | None:
149
+ out = self.get(
150
+ "/api/v1/dashboard/",
151
+ q={"filters": [{"col": "slug", "opr": "eq", "value": slug}], "page_size": 10},
152
+ )
153
+ results = out["result"]
154
+ return results[0] if results else None
155
+
156
+ def find_charts_by_name(self, name: str) -> list[dict]:
157
+ """Targeted lookup (never scans the estate; corporate instances can
158
+ hold tens of thousands of charts)."""
159
+ out = self.get(
160
+ "/api/v1/chart/",
161
+ q={
162
+ "filters": [{"col": "slice_name", "opr": "eq", "value": name}],
163
+ "columns": ["id", "slice_name", "uuid"],
164
+ "page_size": 100,
165
+ },
166
+ )
167
+ return out["result"]
168
+
169
+ def charts_by_uuids(self, uuid_to_name: dict[str, str]) -> dict[str, dict]:
170
+ """uuid -> chart summary for uuids that exist, looked up per chart NAME
171
+ (indexed server-side) and matched by uuid client-side."""
172
+ found: dict[str, dict] = {}
173
+ for u, name in uuid_to_name.items():
174
+ for c in self.find_charts_by_name(name):
175
+ if str(c.get("uuid")) == u:
176
+ found[u] = c
177
+ return found
178
+
179
+ def dashboard_charts(self, dashboard_id: int) -> list[dict]:
180
+ return self.get(f"/api/v1/dashboard/{dashboard_id}/charts")["result"]
181
+
182
+ def delete_chart(self, chart_id: int) -> None:
183
+ r = self._send(lambda: self.session.delete(f"{self.base_url}/api/v1/chart/{chart_id}", timeout=60))
184
+ self._raise_for(r, f"delete chart {chart_id} failed")
185
+
186
+ # -- import / export ----------------------------------------------------
187
+
188
+ def import_dashboard_bundle(self, zip_bytes: bytes, overwrite: bool = True) -> requests.Response:
189
+ return self._send(lambda: self.session.post(
190
+ f"{self.base_url}/api/v1/dashboard/import/",
191
+ files={"formData": ("bundle.zip", zip_bytes, "application/zip")},
192
+ data={"overwrite": json.dumps(overwrite)},
193
+ # Without this, command errors can render the static HTML 500 page
194
+ # instead of the 422 JSON body (docs/CONTRACTS.md).
195
+ headers={"Accept": "application/json"},
196
+ timeout=120,
197
+ ))
198
+
199
+ def export_dashboard(self, dashboard_id: int) -> bytes:
200
+ r = self._send(lambda: self.session.get(
201
+ f"{self.base_url}/api/v1/dashboard/export/",
202
+ params={"q": json.dumps([dashboard_id])},
203
+ timeout=120,
204
+ ))
205
+ self._raise_for(r, "dashboard export failed")
206
+ return r.content
207
+
208
+ def export_dataset(self, dataset_id: int) -> bytes:
209
+ r = self._send(lambda: self.session.get(
210
+ f"{self.base_url}/api/v1/dataset/export/",
211
+ params={"q": json.dumps([dataset_id])},
212
+ timeout=120,
213
+ ))
214
+ self._raise_for(r, "dataset export failed")
215
+ return r.content
216
+
217
+ # -- chart data (smoke) ---------------------------------------------------
218
+
219
+ def chart_data(self, query_context: dict) -> requests.Response:
220
+ return self.post_json("/api/v1/chart/data", query_context)
221
+
222
+ # -- helpers --------------------------------------------------------------
223
+
224
+ @staticmethod
225
+ def _raise_for(r: requests.Response, message: str) -> None:
226
+ if r.status_code >= 400:
227
+ raise SupersetAPIError(f"{message}: HTTP {r.status_code}", r.status_code, r.text[:2000])