Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,33 @@
Notable changes per release. Versions follow [semantic versioning](https://semver.org);
while the major version is 0, minor bumps may include breaking changes and say so here.

## Unreleased

### Upgrading from 0.6

- A spec that uses a field new here is refused by chartwright 0.6 (`extra_forbidden` or `literal_error`); upgrade CI before committing one. The new fields:
- a mixed chart's `kind: "scatter"`;
- a big number's `header_font_size` and `subtitle_font_size`, and a trendline's `header_font_size` and `trend_date_format`;
- an axis chart's `x_axis_title_margin`, `y_axis_title_margin` and `y_axis_title_position`;
- a markdown block's `id`;
- a chart's `cross_filter_scope`.
- A markdown link written `[words](tab:Title)` is a plain, dead link when chartwright 0.6 builds the spec: it doesn't know the form.
- `decompile` now reads a titled chart's stored title spacing back when it differs from the tool's own (`y_axis_title_position: "Left"`, Superset's default, for one), so `adopt` and `plan` keep it instead of resetting it to "Top".

### Added

- A mixed chart's query can draw points: `kind: "scatter"` writes Superset's `scatter` series type, a point per value and no line. Decompile reads a chart saved with it in Superset back as points instead of naming a loss.

- A big number sets its text sizes: `header_font_size` (0.2, 0.3, 0.4, 0.5 or 0.6 of the card's height) and, on a plain big number, `subtitle_font_size` (0.125 to 0.4), the options Superset's panel offers. Unset, they stay at 0.4 and 0.15 as before. A trendline formats its dates with `trend_date_format`, e.g. `"%b %Y"`; its number stays a number. Decompile reads all three back instead of naming them, and `readability.kpi-text` judges a big number at its own sizes.

- An axis chart sets its titles' spacing: `x_axis_title_margin`, `y_axis_title_margin` (pixels) and `y_axis_title_position` (`"Left"`, along the axis, or `"Top"`). Unset, the spacing chartwright writes stays as it was (30 px under the axis, 15 px above it). Each needs its title; a mixed chart's secondary title shares the y pair.

- A markdown block takes an `id`, a lowercase slug unique in the layout: the block's component id becomes `MARKDOWN-<id>`, which Superset renders as its DOM id, so the dashboard's `css` can style that one block (`#MARKDOWN-legend`). In rows, header and footer rows, and sketch legends; decompile reads it back.

- A markdown link can open a tab of the same dashboard: `[definitions](tab:Definitions)`, or `tab:Sales/Trend` for a sub-tab, compiles to the dashboard's URL with the tab in its hash, which Superset opens on load. An unknown or ambiguous title fails validation and lists the tabs a link can name. Decompile turns such links back into `tab:` form, and `plan` compares two links by the tab they open. For a viewer signed in to Superset, the link loads the dashboard again on that tab, so filter selections not saved in the URL reset; a viewer Superset can't save filter state for stays on the current tab ([Limits](docs/LIMITS.md)).

- A chart sets which charts its clicks cross-filter: `cross_filter_scope` is `"global"` (Superset's default), `"tab"` (the charts in its own tab or sub-tab), `"none"` (no chart, such as a summary table) or a list of chart names. It needs `dashboard.cross_filters`; big numbers, heatmaps, histograms and waterfalls emit no cross-filters and refuse it. `apply` and `restore` key the scopes to the live chart ids after the import. Decompile reads them back, so `plan` reports a scope changed in the UI, and `adopt` keeps a UI dashboard's per-chart scopes instead of listing them as a reset; a scope it can't carry is named as a loss.

## 0.6.0 (2026-10-05)

Bridge two totals with a waterfall and show a spread with a box plot, colour a KPI by its value, put cell bars on chosen table columns, and head the sections of a sketch. `advise` now checks axis titles, readable type, markdown that fits its block and sub-tabs too thin to earn a click, and `plan` is clean straight after `apply`.
Expand Down
7 changes: 4 additions & 3 deletions chartwright/adopt.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@
apply resets some of it to the spec's form. Adopt lists every such reset it can
find (`resets`) and refuses while there are any, unless the caller accepts them
(`accept_reset`): decompile's losses (settings and charts the spec can't hold,
which the first apply drops or takes off the dashboard) plus what decompile reads
past without a loss (per-chart cross-filter scopes, charts exempt from
auto-refresh, saved chart queries, tab and filter ids).
which the first apply drops or takes off the dashboard, a per-chart cross-filter
scope the spec can't carry among them) plus what decompile reads past without a
loss (the dashboard-wide cross-filter scope, charts exempt from auto-refresh, saved
chart queries, tab and filter ids).

Adopt also refuses, each time saying what to do instead, when the result could not
be applied safely:
Expand Down
87 changes: 82 additions & 5 deletions chartwright/apply.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@

from . import ids
from .client import SupersetClient, SupersetAPIError
from .compiler import compile_bundle
from .compiler import compile_bundle, cross_filter_configuration
from .resolver import Resolution, resolve
from .smoke import SmokeResult, smoke
from .spec import DashboardSpec
Expand Down Expand Up @@ -190,6 +190,79 @@ def _apply_filter_scopes(spec: DashboardSpec, client: SupersetClient, dashboard_
return []


def _apply_cross_filter_scopes(spec: DashboardSpec, client: SupersetClient, dashboard_id: int) -> list[str]:
"""Rewrite chart_configuration with the live chart ids. The bundle carries placeholder
ids, which only the 6.1.0 importer remaps, and a chart's live id is known only now."""
if all(c.cross_filter_scope in (None, "global") for c in spec.charts):
return [] # the import replaced the metadata with the bundle's, which has none
detail = client.get(f"/api/v1/dashboard/{dashboard_id}")["result"]
config = cross_filter_configuration(spec, json.loads(detail.get("position_json") or "{}"))
return _put_chart_configuration(client, dashboard_id, detail, config)


def _put_chart_configuration(client: SupersetClient, dashboard_id: int, detail: dict,
config: dict) -> list[str]:
metadata = json.loads(detail.get("json_metadata") or "{}")
if metadata.get("chart_configuration") == config:
return []
metadata["chart_configuration"] = config
r = client.put_json(f"/api/v1/dashboard/{dashboard_id}", {"json_metadata": json.dumps(metadata)})
if r.status_code != 200:
return [f"cross-filter scope PUT failed: HTTP {r.status_code} {r.text[:300]}"]
return []


def _chart_ids_by_uuid(position: dict) -> dict[str, int]:
return {str(n["meta"]["uuid"]): n["meta"]["chartId"] for n in position.values()
if isinstance(n, dict) and n.get("type") == "CHART"
and isinstance(n.get("meta"), dict) and n["meta"].get("uuid") and "chartId" in n["meta"]}


def remap_chart_configuration(config: dict, id_map: dict[int, int]) -> dict:
"""chart_configuration with every chart id moved through `id_map` (old -> new): each
entry's key and id, its scope's excluded ids and its chartsInScope. An entry or id
with no new id is dropped. Superset 6.1.0's importer does the same, except
chartsInScope (commands/dashboard/importers/v1/utils.py:146-189)."""
def moved(ids) -> list:
return [id_map[i] for i in ids if isinstance(i, int) and i in id_map] if isinstance(ids, list) else ids

out: dict = {}
for key, entry in config.items():
new = id_map.get(int(key)) if str(key).isdigit() else None
if new is None or not isinstance(entry, dict):
continue
entry = {**copy.deepcopy(entry), "id": new}
cross = entry.get("crossFilters")
if isinstance(cross, dict):
if isinstance(cross.get("scope"), dict) and "excluded" in cross["scope"]:
cross["scope"]["excluded"] = moved(cross["scope"]["excluded"])
if "chartsInScope" in cross:
cross["chartsInScope"] = moved(cross["chartsInScope"])
out[str(new)] = entry
return out


def _restore_cross_filter_scopes(zip_bytes: bytes, client: SupersetClient,
dashboard_id: int) -> list[str]:
"""Put the backup's own chart_configuration back under the chart ids the dashboard
has now, every entry kept as backed up: a chart decompile can't read keeps its scope,
and a scope keeps its roots. Ids map by chart uuid, from the backup's layout to the
live one (build_uuid_to_id_map, importers/v1/utils.py:48-71 at 6.1.0); 4.1.4 and
5.0.0 import the ids unmapped."""
zf = zipfile.ZipFile(io.BytesIO(zip_bytes))
backup = next((yaml.safe_load(zf.read(n)) or {} for n in zf.namelist()
if "/dashboards/" in n and n.endswith(".yaml")), {})
config = (backup.get("metadata") or {}).get("chart_configuration")
if not isinstance(config, dict) or not config:
return [] # the import wrote the backup's metadata, which has none
detail = client.get(f"/api/v1/dashboard/{dashboard_id}")["result"]
live = _chart_ids_by_uuid(json.loads(detail.get("position_json") or "{}"))
id_map = {old: live[u] for u, old in _chart_ids_by_uuid(backup.get("position") or {}).items()
if u in live and isinstance(old, int)}
return _put_chart_configuration(client, dashboard_id, detail,
remap_chart_configuration(config, id_map))


def _unlink_charts_off_the_layout(client: SupersetClient, dashboard_id: int) -> str | None:
"""Link the dashboard to exactly the charts in its layout, as saving it in Superset
does: a json_metadata PUT that carries `positions` sets the dashboard's charts to
Expand Down Expand Up @@ -521,14 +594,17 @@ def restore_bundle(zip_bytes: bytes, slug: str, client: SupersetClient) -> Apply
from .spec import load_spec

dec = decompile_bundle(zip_bytes, live_dataset_lookup(client))
scope_errors = []
try:
spec = load_spec(dec.spec)
except Exception as e: # noqa: BLE001 - degraded bundles restore without scopes
report.warnings.append(f"scopes not reapplied (bundle spec not loadable: {e})")
report.warnings.append(f"filter scopes not reapplied (bundle spec not loadable: {e})")
else:
scope_errors = _apply_filter_scopes(spec, client, report.dashboard_id)
for e in scope_errors:
report.warnings.append(f"scope reapply: {e}")
scope_errors += _apply_filter_scopes(spec, client, report.dashboard_id)
# Cross-filter scopes come from the backup itself, not its decompiled spec.
scope_errors += _restore_cross_filter_scopes(zip_bytes, client, report.dashboard_id)
for e in scope_errors:
report.warnings.append(f"scope reapply: {e}")
if not_restored:
# The layout is back but these charts still hold the newer params: not a
# restore anyone should be told succeeded.
Expand Down Expand Up @@ -767,6 +843,7 @@ def _auto_restore(reason: str) -> None:

report.stage = "scope"
scope_errors = _apply_filter_scopes(spec, client, report.dashboard_id)
scope_errors += _apply_cross_filter_scopes(spec, client, report.dashboard_id)
if scope_errors:
report.import_detail = "; ".join(scope_errors)
return report
Expand Down
Loading
Loading