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,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()
@@ -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
+ )
@@ -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