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
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""v2: MCP server over the same core, so any MCP client (Claude Desktop/Code,
|
|
2
|
+
etc.) can drive the compiler. Tools mirror the CLI verbs 1:1; the server adds
|
|
3
|
+
no behavior of its own; the guarantee stays in the core.
|
|
4
|
+
|
|
5
|
+
Run: chartwright-mcp (stdio transport; profiles + password env vars as for the CLI)
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
|
|
12
|
+
from mcp.server.fastmcp import FastMCP
|
|
13
|
+
|
|
14
|
+
from .spec import json_schema, load_spec
|
|
15
|
+
|
|
16
|
+
mcp = FastMCP("chartwright")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _parse_spec(spec_json: str):
|
|
20
|
+
from pydantic import ValidationError
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
data = json.loads(spec_json)
|
|
24
|
+
except json.JSONDecodeError as e:
|
|
25
|
+
return None, {"ok": False, "stage": "parse", "errors": [{"code": "bad_json", "detail": str(e)}]}
|
|
26
|
+
try:
|
|
27
|
+
return load_spec(data), None
|
|
28
|
+
except ValidationError as e:
|
|
29
|
+
return None, {"ok": False, "stage": "schema", "errors": json.loads(e.json())}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _client(profile: str):
|
|
33
|
+
from .client import SupersetClient
|
|
34
|
+
from .profiles import load_profile
|
|
35
|
+
|
|
36
|
+
p = load_profile(profile)
|
|
37
|
+
c = SupersetClient(p.base_url, p.username, p.password, auth_provider=p.auth_provider,
|
|
38
|
+
ca_bundle=p.ca_bundle, verify=p.verify)
|
|
39
|
+
c.login()
|
|
40
|
+
return c
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@mcp.tool()
|
|
44
|
+
def get_spec_schema() -> str:
|
|
45
|
+
"""The JSON Schema a dashboard spec must satisfy. Read this before writing a spec."""
|
|
46
|
+
return json.dumps(json_schema())
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@mcp.tool()
|
|
50
|
+
def validate_spec(spec_json: str) -> str:
|
|
51
|
+
"""Schema-validate a dashboard spec (offline). Returns ok or typed errors."""
|
|
52
|
+
_, err = _parse_spec(spec_json)
|
|
53
|
+
return json.dumps(err or {"ok": True, "stage": "schema"})
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@mcp.tool()
|
|
57
|
+
def check_spec(spec_json: str, profile: str) -> str:
|
|
58
|
+
"""Pre-flight referential resolution against the live Superset instance:
|
|
59
|
+
every dataset triple, column, and metric must exist. Returns typed errors."""
|
|
60
|
+
spec, err = _parse_spec(spec_json)
|
|
61
|
+
if err:
|
|
62
|
+
return json.dumps(err)
|
|
63
|
+
from .apply import check
|
|
64
|
+
|
|
65
|
+
res = check(spec, _client(profile))
|
|
66
|
+
return json.dumps({"ok": res.ok, "stage": "resolve", "errors": [e.as_dict() for e in res.errors]})
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@mcp.tool()
|
|
70
|
+
def build_dashboard(spec_json: str, profile: str) -> str:
|
|
71
|
+
"""Compile the spec and apply it to the live Superset instance
|
|
72
|
+
(resolve -> import -> linkage -> data smoke). Returns the full apply report
|
|
73
|
+
including the dashboard URL."""
|
|
74
|
+
spec, err = _parse_spec(spec_json)
|
|
75
|
+
if err:
|
|
76
|
+
return json.dumps(err)
|
|
77
|
+
from .apply import apply as run_apply
|
|
78
|
+
|
|
79
|
+
return run_apply(spec, _client(profile), profile).to_json()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@mcp.tool()
|
|
83
|
+
def plan_dashboard(spec_json: str, profile: str) -> str:
|
|
84
|
+
"""Diff a spec against the live dashboard at its slug: what would apply
|
|
85
|
+
change? clean=true means no drift."""
|
|
86
|
+
spec, err = _parse_spec(spec_json)
|
|
87
|
+
if err:
|
|
88
|
+
return json.dumps(err)
|
|
89
|
+
from .dashdiff import plan as run_plan
|
|
90
|
+
|
|
91
|
+
return run_plan(spec, _client(profile)).to_json()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@mcp.tool()
|
|
95
|
+
def decompile_dashboard(dashboard: str, profile: str) -> str:
|
|
96
|
+
"""Turn a live dashboard (slug or numeric id) into a spec + a named
|
|
97
|
+
lossiness report. Use to pull UI-born dashboards under spec control."""
|
|
98
|
+
from .decompile import decompile_live
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
result = decompile_live(dashboard, _client(profile))
|
|
102
|
+
except ValueError as e:
|
|
103
|
+
return json.dumps({"ok": False, "stage": "decompile", "errors": [{"code": "decompile", "detail": str(e)}]})
|
|
104
|
+
return json.dumps({"ok": True, "spec": result.spec, "losses": result.losses_json()})
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def main() -> None:
|
|
108
|
+
mcp.run()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
if __name__ == "__main__":
|
|
112
|
+
main()
|
chartwright/profiles.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Per-instance connection profiles.
|
|
2
|
+
|
|
3
|
+
Default file: ~/.config/chartwright/profiles.toml (override with CHARTWRIGHT_PROFILES env var,
|
|
4
|
+
e.g. when the home dir is roaming/OneDrive-redirected).
|
|
5
|
+
|
|
6
|
+
[work]
|
|
7
|
+
base_url = "https://superset.example.com"
|
|
8
|
+
username = "jdoe"
|
|
9
|
+
# username_env = "MY_USER_VAR" # OR read the username from an env var
|
|
10
|
+
# # (keeps the identity out of the file too)
|
|
11
|
+
password_env = "MY_EXISTING_SECRET_VAR" # ANY env var name you already use
|
|
12
|
+
# password_cmd = ["sops", "-d", "--extract", '["superset_password"]', "~/secrets.yaml"]
|
|
13
|
+
# # OR a command printing the password.
|
|
14
|
+
# # LIST form (recommended): no shell, no
|
|
15
|
+
# # quoting ambiguity, works identically on
|
|
16
|
+
# # bash/git-bash/PowerShell; ~ is expanded.
|
|
17
|
+
# # STRING form also accepted: parsed with
|
|
18
|
+
# # bash-style quoting on mac/linux, but runs
|
|
19
|
+
# # through cmd.exe on Windows (single quotes
|
|
20
|
+
# # are literal there) -- prefer the list.
|
|
21
|
+
# # (macOS keychain: ["security",
|
|
22
|
+
# # "find-generic-password","-a","jdoe",
|
|
23
|
+
# # "-s","superset","-w"]; 1Password:
|
|
24
|
+
# # ["op","read","op://Work/superset/password"])
|
|
25
|
+
# auth_provider = "ldap" # "db" default; "ldap" for LDAP logins
|
|
26
|
+
# ca_bundle = "/path/to/corp-root-ca.pem" # corporate TLS interception
|
|
27
|
+
# verify = false # last resort, disables TLS verification
|
|
28
|
+
|
|
29
|
+
Password resolution: password_env first (if set and non-empty), else
|
|
30
|
+
password_cmd. Username resolution: literal `username` first, else
|
|
31
|
+
`username_env` (first-listed source wins, same rule as passwords). No
|
|
32
|
+
secrets ever live in the file itself.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import os
|
|
38
|
+
import shlex
|
|
39
|
+
import subprocess
|
|
40
|
+
import tomllib
|
|
41
|
+
from dataclasses import dataclass
|
|
42
|
+
from pathlib import Path
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def profiles_path() -> Path:
|
|
46
|
+
override = os.environ.get("CHARTWRIGHT_PROFILES")
|
|
47
|
+
if override:
|
|
48
|
+
return Path(override)
|
|
49
|
+
return Path.home() / ".config" / "chartwright" / "profiles.toml"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ProfileError(RuntimeError):
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class Profile:
|
|
58
|
+
name: str
|
|
59
|
+
base_url: str
|
|
60
|
+
username: str
|
|
61
|
+
password: str
|
|
62
|
+
auth_provider: str = "db"
|
|
63
|
+
ca_bundle: str | None = None
|
|
64
|
+
verify: bool = True
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _expand(token: str) -> str:
|
|
68
|
+
return os.path.expanduser(token) if token.startswith("~") else token
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _resolve_password(name: str, p: dict) -> str:
|
|
72
|
+
env_name = p.get("password_env")
|
|
73
|
+
if env_name:
|
|
74
|
+
value = os.environ.get(env_name)
|
|
75
|
+
if value:
|
|
76
|
+
return value
|
|
77
|
+
cmd = p.get("password_cmd")
|
|
78
|
+
if cmd:
|
|
79
|
+
# List form (recommended): exec directly -- no shell, no quoting
|
|
80
|
+
# ambiguity, identical on bash / git bash / PowerShell. String form:
|
|
81
|
+
# bash-style split on posix; cmd.exe shell on Windows (documented caveat).
|
|
82
|
+
if isinstance(cmd, list):
|
|
83
|
+
argv = [_expand(str(t)) for t in cmd]
|
|
84
|
+
use_shell = False
|
|
85
|
+
elif os.name != "nt":
|
|
86
|
+
argv = [_expand(t) for t in shlex.split(cmd)]
|
|
87
|
+
use_shell = False
|
|
88
|
+
else:
|
|
89
|
+
argv = cmd
|
|
90
|
+
use_shell = True
|
|
91
|
+
try:
|
|
92
|
+
out = subprocess.run(
|
|
93
|
+
argv, capture_output=True, text=True, timeout=30, check=True, shell=use_shell,
|
|
94
|
+
).stdout.strip()
|
|
95
|
+
except Exception as e: # noqa: BLE001 - any failure means no credential
|
|
96
|
+
raise ProfileError(f"profile {name!r}: password_cmd failed: {e}") from e
|
|
97
|
+
if out:
|
|
98
|
+
return out
|
|
99
|
+
raise ProfileError(f"profile {name!r}: password_cmd produced no output")
|
|
100
|
+
if env_name:
|
|
101
|
+
raise ProfileError(
|
|
102
|
+
f"profile {name!r}: env var {env_name!r} is unset or empty; export it, "
|
|
103
|
+
f"or set password_cmd to fetch from your credential manager"
|
|
104
|
+
)
|
|
105
|
+
raise ProfileError(f"profile {name!r}: set password_env (any env var name) or password_cmd")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _resolve_username(name: str, p: dict) -> str:
|
|
109
|
+
if "username" in p:
|
|
110
|
+
return p["username"]
|
|
111
|
+
env_name = p.get("username_env")
|
|
112
|
+
if env_name:
|
|
113
|
+
value = os.environ.get(env_name)
|
|
114
|
+
if value:
|
|
115
|
+
return value
|
|
116
|
+
raise ProfileError(
|
|
117
|
+
f"profile {name!r}: env var {env_name!r} (username_env) is unset or empty"
|
|
118
|
+
)
|
|
119
|
+
raise ProfileError(f"profile {name!r}: set username or username_env")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def load_profile(name: str, path: Path | None = None) -> Profile:
|
|
123
|
+
path = path or profiles_path()
|
|
124
|
+
if not path.exists():
|
|
125
|
+
raise ProfileError(
|
|
126
|
+
f"no profiles file at {path}; create it (see chartwright/profiles.py docstring) "
|
|
127
|
+
f"or point CHARTWRIGHT_PROFILES at yours"
|
|
128
|
+
)
|
|
129
|
+
data = tomllib.loads(path.read_text(encoding="utf-8")) # TOML is UTF-8 by spec; never the locale codepage
|
|
130
|
+
if name not in data:
|
|
131
|
+
raise ProfileError(f"profile {name!r} not in {path}; have: {sorted(data)}")
|
|
132
|
+
p = data[name]
|
|
133
|
+
if "base_url" not in p:
|
|
134
|
+
raise ProfileError(f"profile {name!r} missing 'base_url'")
|
|
135
|
+
return Profile(
|
|
136
|
+
name=name,
|
|
137
|
+
base_url=p["base_url"],
|
|
138
|
+
username=_resolve_username(name, p),
|
|
139
|
+
password=_resolve_password(name, p),
|
|
140
|
+
auth_provider=p.get("auth_provider", "db"),
|
|
141
|
+
ca_bundle=p.get("ca_bundle"),
|
|
142
|
+
verify=p.get("verify", True),
|
|
143
|
+
)
|
chartwright/resolver.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Pre-flight referential resolution: where the correctness guarantee is earned.
|
|
2
|
+
|
|
3
|
+
Every dataset triple, column, saved metric, and ad-hoc aggregate column in the
|
|
4
|
+
spec is resolved against the live Superset metadata API. All failures are
|
|
5
|
+
collected (not fail-fast) and surfaced as machine-readable errors BEFORE import.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import asdict, dataclass, field
|
|
11
|
+
|
|
12
|
+
from .client import SupersetClient
|
|
13
|
+
from .spec import DashboardSpec, DatasetRef, parse_metric
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class ResolvedDataset:
|
|
18
|
+
id: int
|
|
19
|
+
uuid: str
|
|
20
|
+
table: str
|
|
21
|
+
schema: str | None
|
|
22
|
+
database_name: str
|
|
23
|
+
columns: list[str]
|
|
24
|
+
metrics: list[str]
|
|
25
|
+
main_dttm_col: str | None = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class ResolutionError:
|
|
30
|
+
code: str # dataset_not_found | dataset_ambiguous | column_not_found | metric_not_found | bad_metric
|
|
31
|
+
chart: str | None
|
|
32
|
+
ref: str
|
|
33
|
+
detail: str
|
|
34
|
+
|
|
35
|
+
def as_dict(self) -> dict:
|
|
36
|
+
return asdict(self)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class Resolution:
|
|
41
|
+
datasets: dict[str, ResolvedDataset] = field(default_factory=dict) # DatasetRef.key() -> resolved
|
|
42
|
+
errors: list[ResolutionError] = field(default_factory=list)
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def ok(self) -> bool:
|
|
46
|
+
return not self.errors
|
|
47
|
+
|
|
48
|
+
def for_chart(self, ref: DatasetRef) -> ResolvedDataset:
|
|
49
|
+
return self.datasets[ref.key()]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def resolve(spec: DashboardSpec, client: SupersetClient) -> Resolution:
|
|
53
|
+
res = Resolution()
|
|
54
|
+
cache: dict[str, ResolvedDataset | None] = {}
|
|
55
|
+
|
|
56
|
+
def dataset_for(ref: DatasetRef) -> ResolvedDataset | None:
|
|
57
|
+
key = ref.key()
|
|
58
|
+
if key not in cache:
|
|
59
|
+
cache[key] = _resolve_dataset(ref, client, res)
|
|
60
|
+
if cache[key] is not None:
|
|
61
|
+
res.datasets[key] = cache[key]
|
|
62
|
+
return cache[key]
|
|
63
|
+
|
|
64
|
+
for chart in spec.charts:
|
|
65
|
+
ds = dataset_for(chart.dataset)
|
|
66
|
+
if ds is None:
|
|
67
|
+
continue
|
|
68
|
+
_check_chart_fields(chart, ds, res)
|
|
69
|
+
for f in chart.filters:
|
|
70
|
+
_check_column(f.column, chart.name, ds, res, "filter column")
|
|
71
|
+
|
|
72
|
+
for f in spec.filters:
|
|
73
|
+
if f.type in ("select", "range"):
|
|
74
|
+
ds = dataset_for(f.dataset)
|
|
75
|
+
if ds is not None:
|
|
76
|
+
_check_column(f.column, f"filter:{f.name}", ds, res, "native filter column")
|
|
77
|
+
return res
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _resolve_dataset(ref: DatasetRef, client: SupersetClient, res: Resolution) -> ResolvedDataset | None:
|
|
81
|
+
candidates = client.find_datasets(ref.table)
|
|
82
|
+
matches = []
|
|
83
|
+
for c in candidates:
|
|
84
|
+
db_name = (c.get("database") or {}).get("database_name")
|
|
85
|
+
schema = c.get("schema") or None
|
|
86
|
+
if db_name != ref.database:
|
|
87
|
+
continue
|
|
88
|
+
if ref.schema_ is not None and schema != ref.schema_:
|
|
89
|
+
continue
|
|
90
|
+
matches.append(c)
|
|
91
|
+
if not matches:
|
|
92
|
+
near = [f"{(c.get('database') or {}).get('database_name')}/{c.get('schema') or ''}/{c['table_name']}" for c in candidates]
|
|
93
|
+
res.errors.append(ResolutionError(
|
|
94
|
+
"dataset_not_found", None, ref.key(),
|
|
95
|
+
f"no dataset {ref.table!r} in database {ref.database!r}"
|
|
96
|
+
+ (f" schema {ref.schema_!r}" if ref.schema_ else "")
|
|
97
|
+
+ (f"; same-named candidates: {near}" if near else ""),
|
|
98
|
+
))
|
|
99
|
+
return None
|
|
100
|
+
if len(matches) > 1:
|
|
101
|
+
cands = [f"{(m.get('database') or {}).get('database_name')}/{m.get('schema') or ''}/{m['table_name']}" for m in matches]
|
|
102
|
+
res.errors.append(ResolutionError(
|
|
103
|
+
"dataset_ambiguous", None, ref.key(),
|
|
104
|
+
f"multiple datasets match; add a schema to disambiguate: {cands}",
|
|
105
|
+
))
|
|
106
|
+
return None
|
|
107
|
+
m = matches[0]
|
|
108
|
+
detail = client.dataset_detail(m["id"])
|
|
109
|
+
return ResolvedDataset(
|
|
110
|
+
id=m["id"],
|
|
111
|
+
uuid=str(m["uuid"]),
|
|
112
|
+
table=m["table_name"],
|
|
113
|
+
schema=m.get("schema") or None,
|
|
114
|
+
database_name=(m.get("database") or {}).get("database_name"),
|
|
115
|
+
columns=[c["column_name"] for c in detail.get("columns", [])],
|
|
116
|
+
metrics=[x["metric_name"] for x in detail.get("metrics", [])],
|
|
117
|
+
main_dttm_col=detail.get("main_dttm_col") or None,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _check_metric(metric: str, chart_name: str, ds: ResolvedDataset, res: Resolution) -> None:
|
|
122
|
+
adhoc = parse_metric(metric)
|
|
123
|
+
if adhoc is None:
|
|
124
|
+
if metric not in ds.metrics:
|
|
125
|
+
res.errors.append(ResolutionError(
|
|
126
|
+
"metric_not_found", chart_name, metric,
|
|
127
|
+
f"not a saved metric on {ds.table!r} (has: {ds.metrics}) and not an "
|
|
128
|
+
f"ad-hoc aggregate of the form AGG(column), AGG in SUM/AVG/COUNT/COUNT_DISTINCT/MIN/MAX",
|
|
129
|
+
))
|
|
130
|
+
return
|
|
131
|
+
col = adhoc["column"]
|
|
132
|
+
if col != "*" and col not in ds.columns:
|
|
133
|
+
res.errors.append(ResolutionError(
|
|
134
|
+
"column_not_found", chart_name, col,
|
|
135
|
+
f"ad-hoc metric {metric!r}: column {col!r} not on dataset {ds.table!r}",
|
|
136
|
+
))
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _check_column(col: str, chart_name: str, ds: ResolvedDataset, res: Resolution, what: str = "column") -> None:
|
|
140
|
+
if col not in ds.columns:
|
|
141
|
+
res.errors.append(ResolutionError(
|
|
142
|
+
"column_not_found", chart_name, col,
|
|
143
|
+
f"{what} {col!r} not on dataset {ds.table!r} (has {len(ds.columns)} columns)",
|
|
144
|
+
))
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _check_chart_fields(chart, ds: ResolvedDataset, res: Resolution) -> None:
|
|
148
|
+
t = chart.type
|
|
149
|
+
if t in ("big_number_total", "big_number_trend"):
|
|
150
|
+
_check_metric(chart.metric, chart.name, ds, res)
|
|
151
|
+
if t == "big_number_trend":
|
|
152
|
+
_check_column(chart.time_column, chart.name, ds, res, "time_column")
|
|
153
|
+
elif t in ("timeseries_line", "timeseries_bar", "timeseries_area", "timeseries_scatter"):
|
|
154
|
+
for m in chart.metrics:
|
|
155
|
+
_check_metric(m, chart.name, ds, res)
|
|
156
|
+
_check_column(chart.time_column, chart.name, ds, res, "time_column")
|
|
157
|
+
if chart.groupby:
|
|
158
|
+
_check_column(chart.groupby, chart.name, ds, res, "groupby")
|
|
159
|
+
elif t == "bar":
|
|
160
|
+
for m in chart.metrics:
|
|
161
|
+
_check_metric(m, chart.name, ds, res)
|
|
162
|
+
_check_column(chart.x_column, chart.name, ds, res, "x_column")
|
|
163
|
+
if chart.groupby:
|
|
164
|
+
_check_column(chart.groupby, chart.name, ds, res, "groupby")
|
|
165
|
+
elif t == "pie":
|
|
166
|
+
_check_metric(chart.metric, chart.name, ds, res)
|
|
167
|
+
_check_column(chart.groupby, chart.name, ds, res, "groupby")
|
|
168
|
+
elif t == "table":
|
|
169
|
+
for c in chart.columns or []:
|
|
170
|
+
_check_column(c, chart.name, ds, res)
|
|
171
|
+
for m in chart.metrics or []:
|
|
172
|
+
_check_metric(m, chart.name, ds, res)
|
|
173
|
+
for g in chart.groupby or []:
|
|
174
|
+
_check_column(g, chart.name, ds, res, "groupby")
|
|
175
|
+
elif t == "pivot_table":
|
|
176
|
+
for m in chart.metrics:
|
|
177
|
+
_check_metric(m, chart.name, ds, res)
|
|
178
|
+
for c in chart.rows:
|
|
179
|
+
_check_column(c, chart.name, ds, res, "pivot row")
|
|
180
|
+
for c in chart.columns:
|
|
181
|
+
_check_column(c, chart.name, ds, res, "pivot column")
|
|
182
|
+
elif t == "heatmap":
|
|
183
|
+
_check_metric(chart.metric, chart.name, ds, res)
|
|
184
|
+
_check_column(chart.x_column, chart.name, ds, res, "x_column")
|
|
185
|
+
_check_column(chart.y_column, chart.name, ds, res, "y_column")
|
|
186
|
+
elif t == "histogram":
|
|
187
|
+
_check_column(chart.column, chart.name, ds, res, "column")
|
|
188
|
+
if chart.groupby:
|
|
189
|
+
_check_column(chart.groupby, chart.name, ds, res, "groupby")
|
|
190
|
+
elif t == "funnel":
|
|
191
|
+
_check_metric(chart.metric, chart.name, ds, res)
|
|
192
|
+
_check_column(chart.groupby, chart.name, ds, res, "groupby")
|
|
193
|
+
elif t == "treemap":
|
|
194
|
+
_check_metric(chart.metric, chart.name, ds, res)
|
|
195
|
+
for g in chart.groupby:
|
|
196
|
+
_check_column(g, chart.name, ds, res, "groupby")
|
chartwright/sketch.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""ASCII layout sketches: grid-template-areas for Superset's row/column tree.
|
|
2
|
+
|
|
3
|
+
The user draws each tab as text; the compiler turns it into ROW / COLUMN /
|
|
4
|
+
CHART geometry. Superset's real model (4.1 source): widths are TWELFTHS of the
|
|
5
|
+
page (GRID_COLUMN_COUNT=12, responsive), heights are absolute 8-px units, and
|
|
6
|
+
the layout is a strict tree: rows stack, a COLUMN stacks charts inside a row
|
|
7
|
+
slot, and nothing deeper exists. A sketch therefore compiles iff it guillotines
|
|
8
|
+
into rows of charts/columns; anything else is a named error.
|
|
9
|
+
|
|
10
|
+
Semantics:
|
|
11
|
+
- Spaces are cosmetic separators, stripped per line; after stripping every
|
|
12
|
+
line must have the SAME cell count (that count = the grid width, scaled to
|
|
13
|
+
12 columns by largest-remainder so each band sums to exactly 12).
|
|
14
|
+
- Each sketch line adds `line_units` spec height units (1 unit = 40 px);
|
|
15
|
+
repeat a line to make its regions taller.
|
|
16
|
+
- Every symbol's cells must form a solid rectangle.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class SketchChart:
|
|
26
|
+
name: str
|
|
27
|
+
width: int # twelfths
|
|
28
|
+
height: int # spec height units
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class SketchColumn:
|
|
33
|
+
width: int # twelfths
|
|
34
|
+
children: list[SketchChart] = field(default_factory=list)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class SketchRow:
|
|
39
|
+
children: list[SketchChart | SketchColumn] = field(default_factory=list)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
HOLE = "." # reserved: a deliberately empty cell (grid-template-areas prior art)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class SketchError(ValueError):
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _widths_to_twelfths(cell_counts: list[int], total_cells: int) -> list[int]:
|
|
50
|
+
"""Largest-remainder scaling so each band sums to exactly 12 columns."""
|
|
51
|
+
raw = [c * 12 / total_cells for c in cell_counts]
|
|
52
|
+
floors = [max(1, int(r)) for r in raw]
|
|
53
|
+
while sum(floors) > 12: # over-min floors: shave the largest
|
|
54
|
+
i = max(range(len(floors)), key=lambda k: floors[k])
|
|
55
|
+
if floors[i] == 1:
|
|
56
|
+
raise SketchError(f"too many charts side by side for a 12-column grid: {cell_counts}")
|
|
57
|
+
floors[i] -= 1
|
|
58
|
+
remainders = sorted(range(len(raw)), key=lambda k: raw[k] - int(raw[k]), reverse=True)
|
|
59
|
+
for i in remainders:
|
|
60
|
+
if sum(floors) == 12:
|
|
61
|
+
break
|
|
62
|
+
floors[i] += 1
|
|
63
|
+
return floors
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def parse_sketch(lines: list[str], legend: dict[str, str], line_units: int) -> list[SketchRow]:
|
|
67
|
+
if not lines:
|
|
68
|
+
raise SketchError("sketch is empty")
|
|
69
|
+
grid = [[ch for ch in line if ch != " "] for line in lines]
|
|
70
|
+
width = len(grid[0])
|
|
71
|
+
if width == 0:
|
|
72
|
+
raise SketchError("sketch line 1 is blank")
|
|
73
|
+
for i, row in enumerate(grid):
|
|
74
|
+
if len(row) != width:
|
|
75
|
+
raise SketchError(
|
|
76
|
+
f"sketch line {i + 1} has {len(row)} cells, line 1 has {width} "
|
|
77
|
+
"(spaces are separators; cell counts must match)"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
if HOLE in legend:
|
|
81
|
+
raise SketchError(f"{HOLE!r} is a reserved sketch symbol (empty cell); pick another legend symbol")
|
|
82
|
+
symbols = {ch for row in grid for ch in row} - {HOLE}
|
|
83
|
+
unknown = sorted(symbols - set(legend))
|
|
84
|
+
if unknown:
|
|
85
|
+
raise SketchError(f"sketch symbols not in legend: {unknown}")
|
|
86
|
+
unused = sorted(set(legend) - symbols)
|
|
87
|
+
if unused:
|
|
88
|
+
raise SketchError(f"legend symbols never drawn: {unused}")
|
|
89
|
+
|
|
90
|
+
# every symbol must fill a solid rectangle
|
|
91
|
+
boxes: dict[str, tuple[int, int, int, int]] = {} # r0, r1, c0, c1 inclusive
|
|
92
|
+
for s in symbols:
|
|
93
|
+
cells = [(r, c) for r, row in enumerate(grid) for c, ch in enumerate(row) if ch == s]
|
|
94
|
+
r0, r1 = min(r for r, _ in cells), max(r for r, _ in cells)
|
|
95
|
+
c0, c1 = min(c for _, c in cells), max(c for _, c in cells)
|
|
96
|
+
if len(cells) != (r1 - r0 + 1) * (c1 - c0 + 1):
|
|
97
|
+
raise SketchError(f"symbol {s!r} does not form a solid rectangle")
|
|
98
|
+
boxes[s] = (r0, r1, c0, c1)
|
|
99
|
+
|
|
100
|
+
# horizontal bands: cut where no rectangle spans the boundary
|
|
101
|
+
cuts = [0]
|
|
102
|
+
for r in range(1, len(grid)):
|
|
103
|
+
if all(not (b[0] < r <= b[1]) for b in boxes.values()):
|
|
104
|
+
cuts.append(r)
|
|
105
|
+
cuts.append(len(grid))
|
|
106
|
+
|
|
107
|
+
rows: list[SketchRow] = []
|
|
108
|
+
for b0, b1 in zip(cuts, cuts[1:]):
|
|
109
|
+
band = {s: box for s, box in boxes.items() if b0 <= box[0] and box[1] < b1}
|
|
110
|
+
if not band:
|
|
111
|
+
raise SketchError(
|
|
112
|
+
f"line {b0 + 1} is entirely empty: Superset has no vertical spacer; "
|
|
113
|
+
"use more lines on neighbors or a markdown block in rows mode"
|
|
114
|
+
)
|
|
115
|
+
# vertical slices: group symbols sharing column extents
|
|
116
|
+
slices: dict[tuple[int, int], list[str]] = {}
|
|
117
|
+
for s, (r0, r1, c0, c1) in sorted(band.items(), key=lambda kv: (kv[1][2], kv[1][0])):
|
|
118
|
+
for (sc0, sc1), members in slices.items():
|
|
119
|
+
if c0 <= sc1 and sc0 <= c1: # overlaps an existing slice
|
|
120
|
+
if (c0, c1) != (sc0, sc1):
|
|
121
|
+
raise SketchError(
|
|
122
|
+
f"symbol {s!r} partially overlaps the column span of {members}: "
|
|
123
|
+
"Superset cannot nest a row inside a column; align the columns "
|
|
124
|
+
"or split into separate full-width rows"
|
|
125
|
+
)
|
|
126
|
+
members.append(s)
|
|
127
|
+
break
|
|
128
|
+
else:
|
|
129
|
+
slices[(c0, c1)] = [s]
|
|
130
|
+
|
|
131
|
+
# Holes ('.') are legal only where Superset can express absence:
|
|
132
|
+
# trailing right of the band (rows pack left) or at the BOTTOM of a
|
|
133
|
+
# slice (rows/columns pack upward; row height = tallest child).
|
|
134
|
+
cmax = max(c1 for (_, _, _, c1) in band.values())
|
|
135
|
+
spans = list(slices)
|
|
136
|
+
for r in range(b0, b1):
|
|
137
|
+
for c in range(cmax + 1):
|
|
138
|
+
if grid[r][c] == HOLE and not any(c0 <= c <= c1 for c0, c1 in spans):
|
|
139
|
+
raise SketchError(
|
|
140
|
+
f"empty column {c + 1} between charts (line {r + 1}): Superset "
|
|
141
|
+
"rows pack left; empty space goes rightmost"
|
|
142
|
+
)
|
|
143
|
+
trailing_holes = width - 1 - cmax
|
|
144
|
+
|
|
145
|
+
ordered = sorted(slices.items(), key=lambda kv: kv[0][0])
|
|
146
|
+
counts = [c1 - c0 + 1 for (c0, c1), _ in ordered]
|
|
147
|
+
if trailing_holes:
|
|
148
|
+
counts.append(trailing_holes) # phantom entry scales real widths correctly
|
|
149
|
+
widths = _widths_to_twelfths(counts, width)
|
|
150
|
+
|
|
151
|
+
row = SketchRow()
|
|
152
|
+
for ((_, _), members), w in zip(ordered, widths):
|
|
153
|
+
if len(members) == 1:
|
|
154
|
+
s = members[0]
|
|
155
|
+
r0, r1 = boxes[s][0], boxes[s][1]
|
|
156
|
+
if r0 != b0:
|
|
157
|
+
raise SketchError(
|
|
158
|
+
f"empty space above chart {legend[s]!r} (line {b0 + 1}): Superset "
|
|
159
|
+
"packs upward; empty cells in a column go at the bottom"
|
|
160
|
+
)
|
|
161
|
+
row.children.append(SketchChart(legend[s], w, (r1 - r0 + 1) * line_units))
|
|
162
|
+
else:
|
|
163
|
+
members.sort(key=lambda s: boxes[s][0])
|
|
164
|
+
# stacked members must tile downward from the band top with no
|
|
165
|
+
# gaps; '.' may only end the stack early (bottom holes)
|
|
166
|
+
expect = b0
|
|
167
|
+
col = SketchColumn(width=w)
|
|
168
|
+
for s in members:
|
|
169
|
+
r0, r1 = boxes[s][0], boxes[s][1]
|
|
170
|
+
if r0 != expect:
|
|
171
|
+
raise SketchError(
|
|
172
|
+
f"symbols {members} must stack with no vertical gaps "
|
|
173
|
+
f"(symbol {s!r} starts at line {r0 + 1}, expected {expect + 1}; "
|
|
174
|
+
"empty cells may only end a stack, not interrupt it)"
|
|
175
|
+
)
|
|
176
|
+
expect = r1 + 1
|
|
177
|
+
col.children.append(SketchChart(legend[s], w, (r1 - r0 + 1) * line_units))
|
|
178
|
+
row.children.append(col)
|
|
179
|
+
rows.append(row)
|
|
180
|
+
return rows
|