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/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])
|