From 1b255f9dfead85196b5fd5780c58aba7aaac7a69 Mon Sep 17 00:00:00 2001 From: debabsah Date: Mon, 5 Oct 2026 02:47:33 -0700 Subject: [PATCH] feat: box plot chart, a measure's distribution per group, with a check that the boxes hold every observation A box plot (Superset's Box Plot plugin): distribute_across names the columns whose rows are the observations (a time column at time_grain: a day each), groupby the columns that get a box each, metrics the measure, and whiskers Tukey's, the lowest and highest values, or any two percentiles ([5, 95] works on every release: the select is free-form and the operator reads any 'lo/hi percentiles'). The compiler writes whiskerOptions even at Tukey, since without it the plugin adds no boxplot step, and temporal_columns_lookup for the time columns, which the grain needs. Compiled, decompiled, compared by plan and queried by the data check on 4.1.4, 5.0.0 and 6.1.0; the box plot in the featured-charts export reads back whole. row_limit is a 6.0.0 control: a 4.1.4 dashboard's query still stops at it, Explore's leaves it out (both seen live), so it warns before 6.0.0. The data check says when the observations reach the row limit, 10,000 unset on 6.x, so the boxes miss some. Design brain 9 also gets chart.box-plot-observations and chart.box-plot-groups, and chart.ordinal-order reads a box plot's groups and, with a profile, stands down for a column the dataset reports numeric or temporal. The chart-choice guide says when a distribution beats an average and a box plot beats a line of P50 and P90, and how to give the boxes one colour (dashboard.label_colors). Seen rendering on 4.1.4 and 6.1.0; on 6.1.0 a theme hides the plugin's stray "boxplot / outlier" legend. Also: decompile reads stored zoomable false and an empty series_limit as Superset's defaults, which Explore-saved time-axis, mixed and pivot charts carry. --- CHANGELOG.md | 10 +- README.md | 2 +- chartwright/compiler.py | 50 +++ chartwright/decompile.py | 59 +++- chartwright/design/brief.py | 2 +- chartwright/design/content.py | 2 +- chartwright/design/guidelines/chart-choice.md | 8 + chartwright/design/model.py | 6 +- chartwright/design/rules.py | 64 ++++ chartwright/resolver.py | 7 + chartwright/smoke.py | 38 +- chartwright/spec.py | 72 +++- chartwright/versions.py | 14 + docs/CONTRACTS.md | 46 ++- docs/DESIGN-BRAIN.md | 36 +- docs/FEATURES.md | 3 +- docs/VERIFICATION.md | 12 +- schema/dashboard_spec.schema.json | 328 ++++++++++++++++++ skill/SKILL.md | 6 +- tests/fixtures/display_controls.json | 29 +- tests/fixtures/kitchen_sink.json | 21 +- tests/fixtures/live_display_controls.json | 28 +- tests/test_box_plot_chart.py | 273 +++++++++++++++ tests/test_decompile.py | 2 +- tests/test_spec_v2.py | 2 +- tests/test_superset_version.py | 1 + tools/contracts/params-contract.json | 62 ++++ tools/extract_panel_contract.py | 1 + tools/params_drift.py | 1 + 29 files changed, 1149 insertions(+), 36 deletions(-) create mode 100644 tests/test_box_plot_chart.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 15bd473..939254b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,11 +12,17 @@ while the major version is 0, minor bumps may include breaking changes and say s - `increase_color`, `decrease_color` and `total_color` take green, amber, red or any `#RRGGBB`, and replace Superset's stock green, red and grey on every release. `show_value`, `show_legend`, `number_format`, axis titles, `x_label_rotation` (0, 45 or 90) and `x_label_format` set the rest. - On Superset 6.1.0 or later, `steps` lists a bridge's values in its own order and `closing` names the row that closes it, drawn last as the running total in the total colour: `"steps": ["FY2025", "Price", "Volume"], "closing": "FY2026"`. The first step rises from zero; `"opening": "FY2025"` draws the opening row as a total too, on a dashboard whose theme keeps zero on the value axis (`{"echartsOptionsOverridesByChartType": {"waterfall": {"yAxis": {"scale": false}}}}` in its JSON), which `check` and `apply` verify (`waterfall_opening_axis`). Older releases draw a running total after every step, so `check`, `apply` and `plan` refuse `steps` there. `total_label`, `increase_label` and `decrease_label` name the bars on 6.1.0 and warn before it. - `apply`'s data check says when a bridge's opening, step or closing has no row, a value isn't in `steps`, or the closing row doesn't equal the steps added up (Superset draws the sum either way). -- The design brain is version 9. A waterfall gets `chart.waterfall-additive` (warn: `AVG`, `MIN`, `MAX` or `COUNT_DISTINCT` steps don't add up), `chart.waterfall-steps` (warn: past ~12 bars), `chart.waterfall-order` (info: a categorical waterfall without `steps` reads A to Z), `chart.waterfall-colors` (info for Superset's stock colours, whose green is 2.2:1 against the panel; warn for a colour under 3:1) and `chart.waterfall-axis-titles` (info: Superset puts the titles over wide or rotated tick labels). `advise --fix` fills a waterfall's `show_value` when it has at most 12 bars, and `number_format` `,.0f` on a count. The brief's chart-choice guide says when a waterfall is the right form. -- `tools/extract_panel_contract.py` extracts the waterfall's options for the params contract from its control panel at each release; `tools/ci_live_waterfall.py` builds a bridge on a virtual dataset in CI. +- A box plot (`"type": "box_plot"`, Superset's Box Plot): a measure's distribution in each group, its median, quartiles, whiskers and outliers, such as the daily sales of each month. Compiled, decompiled, compared by `plan` and checked by `apply` on 4.1.4, 5.0.0 and 6.1.0. + - `distribute_across` holds the columns whose rows are the observations (a time column at `time_grain`, e.g. a day each), `groupby` the columns that get a box each, `metrics` the measure. + - `whiskers` is `"tukey"` (Superset's default), `"min_max"` (no outliers) or two percentiles such as `[5, 95]`, which every release takes. + - `number_format`, `x_label_format`, axis titles, `x_label_rotation` and `color_scheme` set the rest; `row_limit` is a Superset 6.0.0 control, and warns before it, where a dashboard's query still stops at it but Explore leaves it out. + - `apply`'s data check says when the observations reach the row limit (10,000 unset on Superset 6.x), so the boxes miss some. +- The design brain is version 9. A waterfall gets `chart.waterfall-additive` (warn: `AVG`, `MIN`, `MAX` or `COUNT_DISTINCT` steps don't add up), `chart.waterfall-steps` (warn: past ~12 bars), `chart.waterfall-order` (info: a categorical waterfall without `steps` reads A to Z), `chart.waterfall-colors` (info for Superset's stock colours, whose green is 2.2:1 against the panel; warn for a colour under 3:1) and `chart.waterfall-axis-titles` (info: Superset puts the titles over wide or rotated tick labels). `advise --fix` fills a waterfall's `show_value` when it has at most 12 bars, and `number_format` `,.0f` on a count. A box plot gets `chart.box-plot-observations` (warn: grouped by a period at that period's grain, each box holds a few observations) and `chart.box-plot-groups` (warn, with `--profile`: past ~20 boxes). `chart.ordinal-order` reads a box plot's groups, and with `--profile` stands down for a column the dataset reports numeric or temporal. The brief's chart-choice guide says when a waterfall is the right form, and when a box plot beats a line of P50 and P90. +- `tools/extract_panel_contract.py` extracts the waterfall's and the box plot's options for the params contract from their control panels at each release; `tools/ci_live_waterfall.py` builds a bridge on a virtual dataset in CI. ### Fixed +- `decompile` reads Superset's stored defaults for data zoom and the series limit (`zoomable` false on time-axis and mixed charts, no `series_limit` on a pivot) as unset; before, `params not preserved` named them on charts saved in Explore. A zoomable chart or a series limit is still named. - `plan` reads a `trend_color` written as the hex of a named shade (`#1B7F3B` for green) as equal to the dashboard, which decompile reads back as the name; before, the chart showed as changed after every apply. The waterfall's colours compare the same way. - `decompile` (and so `adopt`) names more chart settings it can't carry when they differ from Superset's defaults: big-number font sizes and a hidden trendline, a smooth or step line, horizontal bars on a time axis, a pie's radius and hidden labels, a funnel's labels, tooltip and percentage calculation, treemap labels, a heatmap's axis sort, legend, margins, label intervals and value bounds, a histogram's normalize setting, and any chart's currency format. Before, they were dropped without a note. diff --git a/README.md b/README.md index e418916..f79756b 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,7 @@ as-is. automatically. Dashboards Chartwright did not create are never overwritten; to bring a hand-built one under a spec, decompile it and build it at a new slug. -- **What a spec can express**: 16 chart types, metrics as you write them, +- **What a spec can express**: 17 chart types, metrics as you write them, per-chart filters, a native filter bar, tabs, markdown notes, a header and footer shown on every tab, and layouts you can draw as ASCII sketches. - **Environment promotion**: specs name their data (connection, schema, diff --git a/chartwright/compiler.py b/chartwright/compiler.py index 7d80f99..70e3531 100644 --- a/chartwright/compiler.py +++ b/chartwright/compiler.py @@ -70,6 +70,7 @@ "treemap": "treemap_v2", "mixed": "mixed_timeseries", "waterfall": "waterfall", + "box_plot": "box_plot", } # 'bar' and 'timeseries_bar' share a viz_type; the compiler marks categorical # bars in params so the decompiler can tell them apart (x column not temporal @@ -498,6 +499,8 @@ def metric(m: str): p["number_format"] = chart.number_format elif t == "waterfall": _waterfall_params(chart, p, metric) + elif t == "box_plot": + _box_plot_params(chart, p, ds, metric) if isinstance(chart, _SeriesDisplay): _series_display_params(chart, p, metric) @@ -845,6 +848,53 @@ def _waterfall_params(chart, p: dict, metric) -> None: p["x_axis_time_format"] = chart.x_label_format +# whiskers -> whiskerOptions, the Box Plot panel's choices (BoxPlot/controlPanel.ts, 4.1.4 +# and 5.0.0 :83-99, 6.1.0 :84-102). The select is free-form, and the boxplot operator +# reads any 'lo/hi percentiles' (operators/boxplotOperator.ts:28, all three releases), so +# [5, 95] works on 4.1.4 too, whose choices don't list it (seen rendering). +WHISKER_OPTIONS = {"tukey": "Tukey", "min_max": "Min/max (no outliers)"} + + +def whisker_options(whiskers) -> str: + if isinstance(whiskers, list): + return f"{whiskers[0]}/{whiskers[1]} percentiles" + return WHISKER_OPTIONS[whiskers] + + +def _box_plot_params(chart, p: dict, ds, metric) -> None: + """BoxPlot/controlPanel.ts at 4.1.4, 5.0.0 and 6.1.0: the observations are the rows + of `columns` (Distribute across), a box per `groupby` value, and the boxplot + post-processing turns them into quartiles (BoxPlot/buildQuery.ts:29-57). row_limit + is a 6.0.0 control.""" + p["columns"] = list(chart.distribute_across) + p["groupby"] = list(chart.groupby) + p["metrics"] = [metric(m) for m in chart.metrics] + # Written even as Tukey: without it buildQuery adds no boxplot post-processing. + p["whiskerOptions"] = whisker_options(chart.whiskers) + if chart.time_grain: + # buildQuery buckets a column by the grain only if temporal_columns_lookup marks + # it, a control Explore fills from the dataset (BoxPlot/buildQuery.ts:38-50). + p["time_grain_sqla"] = chart.time_grain + lookup = {c: True for c in chart.distribute_across if ds.is_temporal(c) is not False} + if lookup: + p["temporal_columns_lookup"] = lookup + if chart.row_limit: + p["row_limit"] = chart.row_limit + if chart.number_format: + p["number_format"] = chart.number_format + if chart.x_label_format: + p["date_format"] = chart.x_label_format + if chart.x_label_rotation is not None: + p["x_ticks_layout"] = TICK_LAYOUTS[chart.x_label_rotation] + # sections.titleControls, as the axis charts write them (_y_axis_params). + if chart.x_axis_title: + p["x_axis_title"] = chart.x_axis_title + p["x_axis_title_margin"] = 50 if chart.x_label_rotation else 30 + if chart.y_axis_title: + p["y_axis_title"] = chart.y_axis_title + _y_title_layout(p) + + def _x_label_params(chart: _AxisChart, p: dict) -> None: # Emitted only when set, so pre-feature bundles stay byte-identical. # x_axis_time_format and xAxisLabelRotation: every chart panel at 4.1.4, 5.0.0 and 6.1.0. diff --git a/chartwright/decompile.py b/chartwright/decompile.py index d8be783..70e6644 100644 --- a/chartwright/decompile.py +++ b/chartwright/decompile.py @@ -20,7 +20,7 @@ from .compiler import ( BACKGROUND, COLUMN_CONFIG_KEYS, CONTRIBUTION_VALUES, FOOTER_PREFIX, HEADER_PREFIX, HEADER_SIZE, LEGEND_TYPES, ROW_UNITS_PER_SPEC_UNIT, SDC_BAR_MARKER, STACK_VALUES, VIZ_TYPE, - bridge_totals_sql, parse_steps_order_sql, + WHISKER_OPTIONS, bridge_totals_sql, parse_steps_order_sql, ) from .spec import ( ADHOC_AGGREGATES, BRIDGE_TOTAL, DEPENDENCY_PARENT_TYPES, FORMAT_COLOR_HEX, FORMAT_TEXT_HEX, @@ -60,7 +60,8 @@ "left_margin", "show_percentage", "show_values", "value_bounds", "xscale_interval", "yscale_interval", "column", "bins", "normalize", "show_value", "slice_id", "url_params", "percent_calculation_type", - "show_tooltip_labels", "tooltip_label_type", "show_total", "x_ticks_layout", SDC_BAR_MARKER, + "show_tooltip_labels", "tooltip_label_type", "show_total", "x_ticks_layout", "zoomable", + "series_limit", "series_limit_metric", SDC_BAR_MARKER, } # Of the keys above, those a user changes from the value an untouched chart stores: @@ -105,6 +106,9 @@ # Waterfall/controlPanel.tsx: a closing total (show_total, 6.1.0 :134) and Superset's # own tick layout (x_ticks_layout, 4.1.4 :130, 6.1.0 :210). "show_total": (True,), "x_ticks_layout": ("auto",), + # sharedControls.tsx: data zoom off (zoomable, 6.1.0 :422) and no series limit + # (series_limit and series_limit_metric, the box plot's at all three releases). + "zoomable": (False,), "series_limit": (None, "", 0), "series_limit_metric": (None, "", [], {}), } # A currency format has no default: unset reads as {} or every part empty. _UNSET_WHEN_EMPTY = {"currency_format"} @@ -120,7 +124,7 @@ # Spec chart types with a color_scheme field (spec._ColorSchemeMixin). _COLOR_SCHEME_TYPES = ("timeseries_line", "timeseries_bar", "timeseries_area", "timeseries_scatter", - "bar", "pie", "histogram", "funnel", "treemap", "mixed") + "bar", "pie", "histogram", "funnel", "treemap", "mixed", "box_plot") _REVERSE_HEADER_SIZE = {v: k for k, v in HEADER_SIZE.items()} _REVERSE_OPACITY = {"opacityLow": "low", "opacityMedium": "medium", "opacityHigh": "high"} @@ -378,6 +382,49 @@ def _waterfall_to_spec(p: dict, out: dict, losses: list, name: str, metric_one) return read +_WHISKER_SPEC = {v: k for k, v in WHISKER_OPTIONS.items()} +_PERCENTILES = re.compile(r"^(\d+)/(\d+) percentiles$") # the boxplot operator's own reading + + +def _box_plot_to_spec(p: dict, out: dict, losses: list, name: str, metric_one) -> set[str] | None: + """A box plot's params into out; the params it read, or None when the chart can't be + a spec chart (an observation or group the spec can't name: a SQL column).""" + ms = [m for m in (metric_one(m) for m in (p.get("metrics") or [])) if m] + across, groupby = p.get("columns") or [], p.get("groupby") or [] + if not ms or not across: + losses.append(Loss(name, "box plot needs metrics + distribute across; chart skipped")) + return None + if not all(isinstance(c, str) for c in [*across, *groupby]): + losses.append(Loss(name, "box plot over SQL columns the spec can't express; chart skipped")) + return None + out.update({"metrics": ms, "distribute_across": list(across)}) + if groupby: + out["groupby"] = list(groupby) + whiskers = p.get("whiskerOptions") or "Tukey" + percentiles = _PERCENTILES.match(whiskers) if isinstance(whiskers, str) else None + if whiskers in _WHISKER_SPEC: + if whiskers != "Tukey": + out["whiskers"] = _WHISKER_SPEC[whiskers] + elif percentiles and int(percentiles.group(1)) < int(percentiles.group(2)) <= 100: + out["whiskers"] = [int(percentiles.group(1)), int(percentiles.group(2))] + else: + losses.append(Loss(name, f"whisker option {whiskers!r} not preserved (Tukey on re-apply)")) + if p.get("time_grain_sqla"): + out["time_grain"] = p["time_grain_sqla"] + if p.get("number_format") not in (None, "", "SMART_NUMBER"): + out["number_format"] = p["number_format"] + if p.get("date_format") not in (None, "", "smart_date"): + out["x_label_format"] = p["date_format"] + for key in ("x_axis_title", "y_axis_title"): + if isinstance(p.get(key), str) and p[key].strip(): + out[key] = p[key] + _tick_layout_to_spec(p, out, losses, name) + if p.get("row_limit"): + out["row_limit"] = p["row_limit"] + return {"columns", "whiskerOptions", "date_format", "x_axis_title", "y_axis_title", + "x_ticks_layout"} + + def _annotations_to_spec(p: dict, out: dict, losses: list, name: str) -> None: """FORMULA annotation layers -> spec annotations; any other layer (events, intervals, other charts' series) is named as a loss.""" @@ -1015,6 +1062,10 @@ def keep_row_limit() -> None: waterfall_read = _waterfall_to_spec(p, out, losses, name, metric_one) if waterfall_read is None: return None + elif spec_type == "box_plot": + box_plot_read = _box_plot_to_spec(p, out, losses, name, metric_one) + if box_plot_read is None: + return None mapped_here = {"combineMetric", "conditional_formatting", "rowTotals", "colTotals", "colSubTotals"} if spec_type == "pivot_table" else ( {"order_by_cols", "timeseries_limit_metric", "series_limit_metric", @@ -1049,6 +1100,8 @@ def keep_row_limit() -> None: mapped_here = mapped_here | {"show_total"} if spec_type == "waterfall": mapped_here = mapped_here | waterfall_read + if spec_type == "box_plot": + mapped_here = mapped_here | box_plot_read if spec_type in _AXIS_TYPES: _x_labels_to_spec(p, out, losses, name) mapped_here = mapped_here | _axis_to_spec(p, out, losses, name, spec_type) diff --git a/chartwright/design/brief.py b/chartwright/design/brief.py index 498311f..a5f0644 100644 --- a/chartwright/design/brief.py +++ b/chartwright/design/brief.py @@ -38,7 +38,7 @@ def render_brief(audience: str, overlay: Overlay | None = None) -> str: "", f"- Height budget per tab: {p.fold_units} units (~{p.fold_units * 40}px). Over budget -> tabs or prune.", f"- KPI band: {p.kpi_row_min}-{p.kpi_row_max} big numbers, {p.kpi_height} units tall, first band on the page.", - f"- Axis charts (timeseries/bar/heatmap/histogram/waterfall): >= {p.min_axis_height} units tall, 8 is the comfortable default.", + f"- Axis charts (timeseries/bar/heatmap/histogram/waterfall/box_plot): >= {p.min_axis_height} units tall, 8 is the comfortable default.", f"- At most {p.max_row_charts} axis charts per row; below 3/12 width a chart is unreadable.", "- Pie/donut: >= 5/12 wide, >= 8 tall. Heatmap: >= 5/12 wide (7/12 when many columns), >= 6 tall.", f"- Vertical bars: <= {p.vbar_max_categories} categories, then flip horizontal; an ordered axis " diff --git a/chartwright/design/content.py b/chartwright/design/content.py index 1ccde38..205e837 100644 --- a/chartwright/design/content.py +++ b/chartwright/design/content.py @@ -70,7 +70,7 @@ NUMBER_FORMAT_TYPES = frozenset({ "big_number_total", "big_number_trend", "timeseries_line", "timeseries_bar", "timeseries_area", "timeseries_scatter", "bar", "pie", "pivot_table", "heatmap", "funnel", - "treemap", "waterfall"}) + "treemap", "waterfall", "box_plot"}) _MARK = re.compile(r"/\* cw:(std|end) ([A-Za-z0-9][A-Za-z0-9_-]*)(?: ([0-9a-f]{12}))? \*/") diff --git a/chartwright/design/guidelines/chart-choice.md b/chartwright/design/guidelines/chart-choice.md index d90463c..531d89e 100644 --- a/chartwright/design/guidelines/chart-choice.md +++ b/chartwright/design/guidelines/chart-choice.md @@ -9,6 +9,7 @@ | Several measures per item | `table` or `pivot_table`, not grouped bars. Pivots: <= 3 total dimensions; column-dim values x metrics <= ~15 rendered columns | | Two dimensions, one measure | `heatmap` (keep the grid under ~400 cells) | | Distribution of one column | `histogram`, 20-30 bins; trim long tails with a WHERE filter and say so in the title | +| A distribution per group | `box_plot`: median, quartiles, whiskers and outliers of many observations each (daily sales by month) | | Staged conversion | `funnel`, 3-8 ordered stages | | Hierarchical share | `treemap`, at most 2 levels | | A bridge between two totals | `waterfall`: the opening, 3-12 steps that add up, the closing; `steps` and `closing` keep its order | @@ -39,6 +40,13 @@ in `steps` (6.1.0+). An opening total needs a dashboard theme that keeps zero on the axis (see `opening` in the schema); without `opening`, the first step rises from zero. Set the three colours and `show_value`. +- Show the distribution, not the average, when the spread is the story + (delivery times, daily sales, latency): an average hides skew and outliers. + A `box_plot` beats a line of P50 and P90 across categories or a few periods, + and whenever the outliers matter; keep the line for a trend over many + periods. Give each box many observations: `distribute_across` a time column + at P1D, `groupby` the month. Superset colours each box by its group: pin one + colour to the group labels in `dashboard.label_colors`. - One dominant category flattening its siblings is the data talking: note it or filter it, don't hide it. - Every chart with a time axis should tolerate the dashboard time filter; diff --git a/chartwright/design/model.py b/chartwright/design/model.py index 1fc4643..3aebae7 100644 --- a/chartwright/design/model.py +++ b/chartwright/design/model.py @@ -29,12 +29,14 @@ # "9" = the waterfall: chart.waterfall-additive and chart.waterfall-steps (warn) can # newly block a strict gate, chart.waterfall-order, chart.waterfall-colors and # chart.waterfall-axis-titles are new findings, and default.value-labels and -# default.count-format now fill a waterfall's show_value and number_format. +# default.count-format now fill a waterfall's show_value and number_format. The box +# plot: chart.box-plot-observations and chart.box-plot-groups (warn), and +# chart.ordinal-order now reads a box plot's groups. DESIGN_BRAIN_VERSION = "9" KPI_TYPES = {"big_number_total", "big_number_trend"} TIMESERIES_TYPES = {"timeseries_line", "timeseries_bar", "timeseries_area", "timeseries_scatter"} -AXIS_TYPES = TIMESERIES_TYPES | {"bar", "heatmap", "histogram", "mixed", "waterfall"} +AXIS_TYPES = TIMESERIES_TYPES | {"bar", "heatmap", "histogram", "mixed", "waterfall", "box_plot"} # Charts that are neither KPI nor axis-bearing; a contract test asserts the # three sets exactly cover CHART_TYPES, so a new chart type fails CI until # someone consciously classifies it (and reviews which rules apply). diff --git a/chartwright/design/rules.py b/chartwright/design/rules.py index 0ffa822..d91c5a5 100644 --- a/chartwright/design/rules.py +++ b/chartwright/design/rules.py @@ -798,6 +798,60 @@ def waterfall_steps(ctx: RuleContext): ) +# -- chart: box plots ------------------------------------------------------------------ + +BOX_PLOT_MAX_GROUPS = 20 +# Time grains from finest to coarsest, and the period a group column's name says it is. +# A day column is left out: day of week and day of month read alike by name. +_GRAIN_RANK = {"PT1S": 0, "PT1M": 0, "PT1H": 0, "P1D": 1, "P1W": 2, "P1M": 3, "P3M": 4, "P1Y": 5} +_PERIOD_COLUMN = re.compile(r"(?:^|_)(week|month|quarter|qtr|year)(?:_|$|id$|name$)", re.I) +_PERIOD_RANK = {"week": 2, "month": 3, "quarter": 4, "qtr": 4, "year": 5} + + +@rule("chart.box-plot-observations", "warn", + "a box needs many observations: distribute across a finer grain than the groups", + since="9") +def box_plot_observations(ctx: RuleContext): + for c in ctx.spec.charts: + if c.type != "box_plot" or not c.time_grain: + continue + grain = _GRAIN_RANK.get("P1W" if "P1W" in c.time_grain else c.time_grain) + for col in c.groupby: + m = _PERIOD_COLUMN.search(col) + if grain is None or not m or grain < _PERIOD_RANK[m.group(1).lower()]: + continue + yield Finding( + "chart.box-plot-observations", "warn", c.name, ctx.where(c.name), + f"observations at {c.time_grain} grouped by {col!r}: each box holds one " + f"observation per {m.group(1).lower()}, too few for quartiles; distribute " + f"across a finer grain, e.g. P1D", + ) + break + + +@rule("chart.box-plot-groups", "warn", + f"past ~{BOX_PLOT_MAX_GROUPS} boxes each thins to a sliver and the labels drop", + since="9") +def box_plot_groups(ctx: RuleContext): + # Offline the groups are unknown; with a live resolution it probes one group column, + # as vbar-categories does. + if ctx.prober is None: + return + for c in ctx.spec.charts: + if c.type != "box_plot" or len(c.groupby) != 1: + continue + ds = ctx.dataset_for(c) + if ds is None: + continue + if ctx.prober.more_than(ds, c.groupby[0], BOX_PLOT_MAX_GROUPS): + yield Finding( + "chart.box-plot-groups", "warn", c.name, ctx.where(c.name), + f"{c.groupby[0]!r} has more than {BOX_PLOT_MAX_GROUPS} values, a box each; " + f"group coarser, filter to the groups that matter, or chart the spread per " + f"group as a table", + ) + + @rule("chart.heatmap-grid", "warn", "a heatmap past ~400 cells is unreadable at any size", data_aware=True) def heatmap_grid(ctx: RuleContext): if ctx.prober is None: @@ -911,10 +965,20 @@ def dims(c): return [c.x_column, c.y_column] if c.type == "pivot_table": return [*c.rows, *c.columns] + if c.type == "box_plot": + return list(c.groupby) # the boxplot step groups and sorts by them return [] + def own_order(c, column) -> bool: + # A column the dataset reports numeric or temporal sorts in its own order + # (1..12); only names sort alphabetically. Known with a live resolution only. + ds = ctx.dataset_for(c) + return ds is not None and ds.column_types.get(column) in (0, 2) + for c in ctx.spec.charts: hits = [d for d in dims(c) if d and _ORDINAL_RE.search(d)] + if c.type != "bar": + hits = [d for d in hits if not own_order(c, d)] if hits and c.type == "bar" and not c.category_sort: # A bar sorts by its first metric unless category_sort is set, so an # order-encoded label alone changes nothing. diff --git a/chartwright/resolver.py b/chartwright/resolver.py index 26940b2..e610728 100644 --- a/chartwright/resolver.py +++ b/chartwright/resolver.py @@ -425,3 +425,10 @@ def _check_chart_fields(chart, ds: ResolvedDataset, res: Resolution) -> None: _check_column(chart.x_column, chart.name, ds, res, "x_column") if chart.groupby: _check_column(chart.groupby, chart.name, ds, res, "groupby") + elif t == "box_plot": + for m in chart.metrics: + _check_metric(m, chart.name, ds, res) + for c in chart.distribute_across: + _check_column(c, chart.name, ds, res, "distribute_across") + for g in chart.groupby: + _check_column(g, chart.name, ds, res, "groupby") diff --git a/chartwright/smoke.py b/chartwright/smoke.py index dd28764..1591b22 100644 --- a/chartwright/smoke.py +++ b/chartwright/smoke.py @@ -98,6 +98,22 @@ def _bridge_warning(chart, result: list) -> str | None: return "bridge: " + "; ".join(out) if out else None +# Superset 6.x fills an unset box plot row limit with the control's default +# (sharedControls.tsx row_limit, 6.1.0 :236); smoke asks for as many. +BOX_PLOT_ROWS = 10000 + + +def _observations_warning(chart, rows: int) -> str | None: + """A box plot's query returns its observations before the boxplot step makes + quartiles of them; a query that stops at its row limit leaves some out.""" + if chart.type != "box_plot" or rows < (chart.row_limit or BOX_PLOT_ROWS): + return None + where = ("its row_limit" if chart.row_limit else + f"{BOX_PLOT_ROWS:,}, where Superset 6.0.0 or later stops an unset row_limit") + return (f"the observations reached {where} ({rows:,} rows), so the boxes may leave " + f"some out; raise row_limit or coarsen distribute_across") + + @dataclass class SmokeResult: chart: str @@ -224,6 +240,23 @@ def _waterfall_query(chart, spec: DashboardSpec, ds) -> dict: }, chart) +def _box_plot_query(chart, spec: DashboardSpec, ds) -> dict: + """The observations, as BoxPlot/buildQuery.ts asks for them before its boxplot + step: each time column of distribute_across at the grain, then the groups.""" + across = [_time_axis(c, chart.time_grain) + if chart.time_grain and ds.is_temporal(c) is not False else c + for c in chart.distribute_across] + return _with_where({ + "filters": _filters_payload(chart), + "extras": {"time_grain_sqla": chart.time_grain or "P1D"}, + "time_range": "No filter", + "row_limit": chart.row_limit or BOX_PLOT_ROWS, + "columns": [*across, *chart.groupby], + "metrics": [_metric_payload(m, spec.dashboard.slug, chart.name) for m in chart.metrics], + "orderby": [], + }, chart) + + def _mixed_queries(chart, spec: DashboardSpec, ds) -> list[dict]: """Query A and query B, as MixedTimeseries/buildQuery.ts sends them.""" x = (_time_axis(chart.x_column, chart.time_grain) @@ -248,6 +281,8 @@ def smoke_chart(chart, spec: DashboardSpec, resolution: Resolution, client: Supe queries = _mixed_queries(chart, spec, ds) elif chart.type == "waterfall": queries = [_waterfall_query(chart, spec, ds)] + elif chart.type == "box_plot": + queries = [_box_plot_query(chart, spec, ds)] else: queries = [_query_for(chart, spec)] ctx = { @@ -266,7 +301,8 @@ def smoke_chart(chart, spec: DashboardSpec, resolution: Resolution, client: Supe return SmokeResult(chart.name, False, False, f"unparseable chart/data response: {e}") if rows == 0: return SmokeResult(chart.name, True, True, "query succeeded but returned 0 rows") - fit = _fit_warning(chart, spec, result) or _bridge_warning(chart, result) + fit = (_fit_warning(chart, spec, result) or _bridge_warning(chart, result) + or _observations_warning(chart, rows)) if fit: return SmokeResult(chart.name, True, True, f"{rows} rows; {fit}") return SmokeResult(chart.name, True, False, f"{rows} rows") diff --git a/chartwright/spec.py b/chartwright/spec.py index 0016346..ea1e28d 100644 --- a/chartwright/spec.py +++ b/chartwright/spec.py @@ -3,7 +3,7 @@ Anything not expressible here does not exist. Validation errors are the only feedback channel an LLM caller gets; keep messages precise and actionable. -Surface: 16 chart types, per-chart WHERE filters, a dashboard-level native +Surface: 17 chart types, per-chart WHERE filters, a dashboard-level native filter bar (select, time range, numeric range, time grain, time column), markdown blocks, headers, dividers, and tabs. """ @@ -1239,12 +1239,78 @@ def colour_hex(self, field: str) -> str: return named_hex(value) if value is not None else WATERFALL_DEFAULT_HEX[field] +Percentiles = Annotated[list[int], Field(min_length=2, max_length=2)] + + +class BoxPlotChart(_ChartBase, _ColorSchemeMixin): + """The distribution of a measure in each group (Superset's Box Plot): its median, + quartiles, whiskers and outliers, e.g. the daily sales of each month. Each row of + ``distribute_across`` (a day, with time_grain P1D) is one observation of the + ``metrics``; ``groupby`` draws one box per value, each from its own observations.""" + + type: Literal["box_plot"] + metrics: list[str] = Field( + min_length=1, + description="The measure each observation takes, e.g. [\"SUM(sales)\"]: a box per " + "group and metric, all on one value axis") + distribute_across: list[str] = Field( + min_length=1, + description="The columns whose rows are the observations (Superset's Distribute " + "across), e.g. [\"order_date\"] with time_grain P1D: one observation a day") + groupby: list[str] = Field( + default_factory=list, + description="One box per value of these columns (Superset's Dimensions), e.g. " + "[\"month\"]; omitted, one box for every observation") + time_grain: str | None = Field( + default=None, + description="The period of a time column in distribute_across, e.g. P1D; omitted, " + "each distinct timestamp is one observation") + whiskers: Literal["tukey", "min_max"] | Percentiles = Field( + default="tukey", + description="How far the whiskers reach: \"tukey\" (to the furthest value within 1.5 " + "times the box's height, values beyond drawn as outliers; Superset's " + "default), \"min_max\" (the lowest and highest values, no outliers), or two " + "percentiles, e.g. [5, 95], with the values beyond them as outliers") + number_format: str | None = Field( + default=None, description="d3 format for the value axis and tooltip, e.g. '$,.0f'") + x_label_format: str | None = Field( + default=None, description="d3 time format for groups of a time column, e.g. '%b %Y'") + x_axis_title: str | None = Field(default=None, description="Title under the x axis") + y_axis_title: str | None = Field(default=None, description="Title above the value axis, e.g. its unit") + x_label_rotation: Literal[0, 45, 90] | None = Field( + default=None, description="Rotate the group labels: 0, 45 or 90 degrees (Superset's " + "X Tick Layout); omitted, Superset lays them out itself") + row_limit: int | None = Field( + default=None, ge=1, + description="The most rows the query returns, observations of every group together; " + "past it, the boxes miss observations (apply's data check says when it " + "is reached). Superset 6.0.0 or later declares it; omitted, 6.x stops at " + "10,000 rows and older releases at the server's ROW_LIMIT") + + @model_validator(mode="after") + def _distribution(self) -> "BoxPlotChart": + both = sorted(set(self.distribute_across) & set(self.groupby)) + if both: + raise ValueError(f"chart {self.name!r}: {both} are in both distribute_across and " + "groupby; a box would hold one observation") + for field in ("distribute_across", "groupby", "metrics"): + values = getattr(self, field) + if len(set(values)) != len(values): + raise ValueError(f"chart {self.name!r}: {field} lists a value twice") + if isinstance(self.whiskers, list): + lo, hi = self.whiskers + if not 0 <= lo < hi <= 100: + raise ValueError(f"chart {self.name!r}: whisker percentiles {self.whiskers} must " + "be two whole numbers from 0 to 100, the lower first, e.g. [5, 95]") + return self + + Chart = Annotated[ Union[ BigNumberChart, BigNumberTrendChart, TimeseriesLineChart, TimeseriesBarChart, TimeseriesAreaChart, TimeseriesScatterChart, BarChart, PieChart, TableChart, PivotTableChart, HeatmapChart, HistogramChart, FunnelChart, TreemapChart, - MixedChart, WaterfallChart, + MixedChart, WaterfallChart, BoxPlotChart, ], Field(discriminator="type"), ] @@ -1252,7 +1318,7 @@ def colour_hex(self, field: str) -> str: CHART_TYPES = ( "big_number_total", "big_number_trend", "timeseries_line", "timeseries_bar", "timeseries_area", "timeseries_scatter", "bar", "pie", "table", "pivot_table", - "heatmap", "histogram", "funnel", "treemap", "mixed", "waterfall", + "heatmap", "histogram", "funnel", "treemap", "mixed", "waterfall", "box_plot", ) diff --git a/chartwright/versions.py b/chartwright/versions.py index 73f9bc1..e3cd4b8 100644 --- a/chartwright/versions.py +++ b/chartwright/versions.py @@ -167,6 +167,20 @@ def _charts(predicate) -> Callable[[DashboardSpec], list[str | None]]: "absent at 4.1.4, 5.0.0 and 6.0.0", _charts(lambda c: c.type == "waterfall" and c.increase_label), ), + GatedField( + # A dashboard's query takes it on every release (buildQueryObject.ts reads row_limit + # from the stored params: seen on a 4.1.4 dashboard); the older panel has no + # control for it, so Explore's query leaves it out (seen on 4.1.4 and 5.0.0). + "row_limit", "6.0.0", "warn", + "has no Row limit on a box plot's panel: the dashboard's query stops at it, but " + "the chart opened in Explore queries without it", + "row_limit at 6.0.0 BoxPlot/controlPanel.ts:81 (6.1.0 :81); absent at 4.1.4 and 5.0.0, " + "where buildQueryObject.ts:117-120 still reads the stored value", + _charts(lambda c: c.type == "box_plot" and c.row_limit is not None), + warning="row_limit on {where} is a Superset {since} control on a box plot; {runs}, " + "and {that} has no Row limit on the box plot's panel: the dashboard's query " + "stops at it, but the chart opened in Explore queries without it.", + ), GatedField( "decrease_label", "6.1.0", "warn", "calls a falling step Decrease in the legend and tooltip", diff --git a/docs/CONTRACTS.md b/docs/CONTRACTS.md index 14285dd..b36efa5 100644 --- a/docs/CONTRACTS.md +++ b/docs/CONTRACTS.md @@ -293,7 +293,8 @@ running the tool against real instances of all three releases. since `only_total` is on by default; `only_total: false` asks for every segment on every release, so it raises nothing. A waterfall's `total_label`, `increase_label` and `decrease_label` are 6.1.0 controls; - older releases name the bars Total, Increase and Decrease. These come back in + older releases name the bars Total, Increase and Decrease. A box plot's + `row_limit` is a 6.0.0 control (see "A box plot's row limit" below). These come back in `version_warnings`, and the dashboard still builds. - **6.0.x is checked against its source, not tested live.** Each field's first release, 6.0.0 or 6.1.0, was read from the 6.0.0 tag, so `check`, @@ -318,9 +319,10 @@ running the tool against real instances of all three releases. controls the tool does not emit yet; `--check` fails when the JSON no longer matches the source. Among the controls 6.1.0 declares and 5.0.0 does not are `only_total` and `only_totalB`, both new in 6.0.0 - (`MixedTimeseries/controlPanel.tsx:204` at 6.0.0). The `waterfall` entry - is extracted the same way by `tools/extract_panel_contract.py`, from - `Waterfall/controlPanel.tsx` and the `showValueControl` it imports. + (`MixedTimeseries/controlPanel.tsx:204` at 6.0.0). The `waterfall` and `box_plot` entries + are extracted the same way by `tools/extract_panel_contract.py`, from + `Waterfall/controlPanel.tsx` (and the `showValueControl` it imports) and + `BoxPlot/controlPanel.ts` (and its title section). - **Formula annotation layers draw the same way in every release.** The line, bar, area, scatter and mixed panels all include the annotation section (`chart-controls/src/sections/annotationsAndLayers.tsx:31`), and both @@ -470,6 +472,42 @@ running the tool against real instances of all three releases. (`transformProps.ts:445-464` at 6.1.0, the same at 4.1.4), so a y title can sit under wide tick labels and rotated labels hang over an x title (seen on 4.1.4 and 6.1.0); `advise` says so (`chart.waterfall-axis-titles`). +- **A box plot's observations are its query's rows.** The plugin queries the + Distribute across columns and then the Dimensions, and its boxplot + post-processing step groups the rows by the Dimensions into median, + quartiles, whiskers and outliers (`BoxPlot/buildQuery.ts:29-57`; + `superset/utils/pandas_postprocessing/boxplot.py`, the groups sorted by value + at `aggregate.py:43`; all three releases). A time column is bucketed by the + grain only where the stored `temporal_columns_lookup` marks it + (`buildQuery.ts:38-50`), a control Explore fills from the dataset, so the + compiler writes it for the time columns of `distribute_across`. Without + `whiskerOptions` the plugin adds no boxplot step, so the compiler always + writes it, Tukey included. +- **Whiskers take any two percentiles on every release.** The panel's select is + free-form (`BoxPlot/controlPanel.ts:87` at 4.1.4, `:88` at 6.1.0) and the + operator reads any `lo/hi percentiles` (`operators/boxplotOperator.ts:28`, + all three releases), so `[5, 95]` works on 4.1.4, whose choices list only + 2/98 and 9/91 (seen drawn). +- **A box plot's row limit is a 6.0.0 control, and it cuts observations.** + 6.0.0 adds Row limit to the panel (`BoxPlot/controlPanel.ts:81`), and a + dashboard fills an unset one with its default, 10,000 + (`sharedControls.tsx:236` at 6.1.0); 4.1.4 and 5.0.0 have no such control, + so the query falls back to the server's `ROW_LIMIT` (50,000, + `superset/config.py:156` at 4.1.4). A stored `row_limit` still reaches a + 4.1.4 dashboard's query, since `buildQueryObject.ts:117-120` reads it, but + Explore's query there leaves it out (both seen on 4.1.4 and 5.0.0), so the + field warns before 6.0.0. Rows past the limit are observations the boxes + miss, so `apply`'s data check warns when the query reaches it. +- **Superset colours each box by its group.** The plugin paints a box with the + colour scheme's colour for its group label (`BoxPlot/transformProps.ts:113` + at 6.1.0, `:112` at 4.1.4), so `dashboard.label_colors` pins it: one colour + for every label draws every box alike (seen on 4.1.4 and 6.1.0). On 6.1.0 the + chart also shows a "boxplot / outlier" legend: the ECharts theme Superset + merges into every chart has a legend entry + (`plugin-chart-echarts/src/components/Echart.tsx:212`) and the box plot has + no control to hide it; a dashboard theme's + `{"echartsOptionsOverridesByChartType": {"box_plot": {"legend": {"show": + false}}}}` does (seen on 6.1.0). - **On 4.1.4, heatmap and histogram exist twice** (a legacy plugin and a current one, with different options). The tool builds the current ones; decompiling a dashboard built on the legacy ones reports them as named diff --git a/docs/DESIGN-BRAIN.md b/docs/DESIGN-BRAIN.md index 4f4399a..73a900e 100644 --- a/docs/DESIGN-BRAIN.md +++ b/docs/DESIGN-BRAIN.md @@ -2,8 +2,9 @@ > **Status: SHIPPED, design brain 9** (4 added `narrative.color-scheme`; 5 the > design defaults of §16; 6 the `standard.*` rules of §18's content; 7 fills keeping -> a null record after an edit, and `default.stale-record`; 9 the waterfall's rules -> and fills, §7 "Waterfalls"). This page is both the design and the +> a null record after an edit, and `default.stale-record`; 9 the waterfall's and +> the box plot's rules and fills, §7 "Waterfalls" and "Box plots"). This page is +> both the design and the > reference for the implementation in `chartwright/design/`. The decision log > at the bottom records every judgment call made without a review gate; §15 > records where the implementation deliberately deviates from the design @@ -295,7 +296,8 @@ several offline rules additionally sharpen or stand down when probes are available (noted in their text). `since` is the design-brain version that introduced the rule: "2" the post-review batch (docs/DESIGN-BRAIN-V2.md), "3" the review burn-down (§15.11 onward), "4" -the colour-scheme check, "5" the design defaults (§16), "9" the waterfall. +the colour-scheme check, "5" the design defaults (§16), "9" the waterfall and +the box plot. The table below is GENERATED from the registry by `tools/gen_rule_table.py --write`; do not hand-edit it. `tests/test_docs.py` @@ -305,6 +307,8 @@ fails when it drifts. | id | sev | fix | data | since | rule | |---|---|---|---|---|---| +| `chart.box-plot-groups` | warn | - | - | 9 | past ~20 boxes each thins to a sliver and the labels drop | +| `chart.box-plot-observations` | warn | - | - | 9 | a box needs many observations: distribute across a finer grain than the groups | | `chart.dupe` | info | - | - | 1 | two charts answering the identical question is redundancy | | `chart.format-bands` | warn/info | - | - | 2 | conditional-formatting bands must tell one coherent story per metric | | `chart.funnel-stages` | warn | - | ⚡ | 1 | funnels need 3-8 ordered stages | @@ -412,6 +416,32 @@ total and is cut away (seen on 6.1.0). `opening` draws it as a total anyway, on a dashboard whose theme keeps zero on the axis, which `check` verifies on the instance; the guideline tells an author both ways. +### Box plots + +A box plot shows a distribution per group, which an average hides. Two rules +hold a spec to a box that means something: + +- `chart.box-plot-observations` (warn): a box's quartiles come from its + observations, the rows of `distribute_across`. Grouped by a column named as a + period (week, month, quarter, year) at that period's grain or coarser, each + box holds one observation per period, a month box three over three years. + A day column is left out: by name, day of week and day of month look alike. +- `chart.box-plot-groups` (warn, with `--profile`): past ~20 groups each box + thins to a sliver and the labels drop. + +`chart.ordinal-order` reads a box plot's groups too: the boxplot step sorts +them as values, so month names read A to Z. A group column the dataset reports +numeric or temporal (a month numbered 1 to 12) sorts in its own order, so with +`--profile` the rule stands down there, for heatmaps and pivots as well. + +Superset colours each box by its group label from the colour scheme, a hue +that encodes nothing; pinning one colour to every group label in +`dashboard.label_colors` draws the boxes in one (seen on 4.1.4 and 6.1.0). On +6.1.0 the plugin also shows a "boxplot / outlier" legend no chart option hides; +a dashboard theme with `{"echartsOptionsOverridesByChartType": {"box_plot": +{"legend": {"show": false}}}}` hides it (seen on 6.1.0). Both are styling, so +they are guidance, not rules. + Anything fuzzier than this (reading order beyond KPI-first, grouping related metrics, matched granularity across a row, insight-stating titles) is Tier G: it goes in the brief, not the linter. diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 7a17e37..b5dc5dd 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -16,7 +16,7 @@ into one. Everything below works from that one file. ## Dashboard Design - **Deterministic Dashboards**: The same spec always produces the identical dashboard. Diff it in git, review it in a PR. -- **16 Chart Types**: big number, big number with trendline, line, bar, area, scatter, categorical bar, pie/donut, table, pivot table, heatmap, histogram, funnel, treemap, mixed (bars and a line on two value axes, over time or over categories), and waterfall (a bridge from one total to the next through the steps that move it). +- **17 Chart Types**: big number, big number with trendline, line, bar, area, scatter, categorical bar, pie/donut, table, pivot table, heatmap, histogram, funnel, treemap, mixed (bars and a line on two value axes, over time or over categories), waterfall (a bridge from one total to the next through the steps that move it), and box plot (a distribution per group: median, quartiles, whiskers and outliers). - **Metrics As You Write Them**: Saved Superset metrics, `SUM(col)`-style aggregates, `COUNT(*)`, with inline renames (`MAX(pct_of_goal) AS % of Goal`), and custom SQL for ratios the dataset doesn't define: `SQL(100.0 * SUM(on_time) / NULLIF(COUNT(*), 0)) AS On-time %`. `chartwright check` lists custom SQL as unchecked, and apply's data check runs it. - **Filters and Formatting**: Per-chart WHERE conditions (a column test, or custom SQL such as `{"sql": "amount > 0 OR refunded"}`), a native filter bar, and formatting for table and pivot cells. - Filter bar: value pickers, a time range with an optional starting range, numeric sliders, and time grain and time column pickers. @@ -37,6 +37,7 @@ into one. Everything below works from that one file. - Pivots: averages and other aggregations, rows sorted by value, row subtotals, rows and columns swapped. - Heatmap values and colour scheme; what pie, funnel and treemap labels show, and their number format. - Waterfalls: the rising, falling and total bar colours (green, amber, red or any hex), each step's value on its bar, and a breakdown per period. On Superset 6.1+, list a bridge's steps in its own order and name the rows that open and close it, each drawn as a total under its own name (an opening total needs a dashboard theme that keeps zero on the axis; `check` says when it doesn't); on earlier releases the steps draw in label order, closed by a total named Total. `apply`'s data check says when a step or the closing has no row, or the closing doesn't equal the steps added up. + - Box plots: the columns whose rows are the observations (a date at a daily grain), the groups that get a box each, and the whiskers: Tukey's, the lowest and highest values, or any two percentiles such as 5 and 95. `apply`'s data check says when the observations reach the row limit, so the boxes miss some. - **Goal Lines**: Draw a target or trend line over line, bar, area, scatter and mixed charts with `annotations`: `{"name": "Goal", "value": 80, "style": "dashed"}`, or a formula in x for a trend. - **Dashboard and Chart Settings**: Set the dashboard's own settings and each chart's in the spec, so a change made in the UI shows up in `plan`. - Dashboard: colour scheme, description, certification badge, draft state, auto-refresh interval, and the filter bar across the top (on 4.1.4 and 5.0.0, with Superset's `HORIZONTAL_FILTER_BAR` flag on). diff --git a/docs/VERIFICATION.md b/docs/VERIFICATION.md index 3425214..1731266 100644 --- a/docs/VERIFICATION.md +++ b/docs/VERIFICATION.md @@ -35,7 +35,7 @@ injection. | Layer | Proves | Where it runs | |---|---|---| -| Offline suite (1626 tests, 69 modules) | Contract, determinism, round-trips, credentials | every PR and push to main, Linux + Windows, mcp 1 and 2 | +| Offline suite (1658 tests, 70 modules) | Contract, determinism, round-trips, credentials | every PR and push to main, Linux + Windows, mcp 1 and 2 | | Chart-option contract | Every emitted chart option is declared by each version's plugin source | every PR and push to main | | Live guarantee check | Three specs applied (every chart type, every display control, every dashboard and filter control), per-chart data check, ids stable across re-apply | every PR and push to main, all 3 versions | | Standards content round trip | `standards apply` writes an org footer, a team header and two CSS blocks into a spec; it applies, `plan` stays clean, decompile reads the CSS markers and every managed row back, `--claim` rebuilds the record, re-apply stays clean | every PR and push to main, all 3 versions | @@ -63,8 +63,8 @@ collects, the same "generated, not hand-maintained" rule the rule table in timestamps and entry order pinned, so the same spec produces the identical bundle on any build platform. - **Lossless round-trips** (`test_decompile.py`): decompiling a compiled - spec reproduces that spec exactly, across the full surface (17 charts - covering all 16 chart types, chart filters, the native filter bar, + spec reproduces that spec exactly, across the full surface (18 charts + covering all 17 chart types, chart filters, the native filter bar, markdown, tabs), and stays stable under a second round-trip. Decompiling a real 25-chart export that uses unsupported chart types names every loss; nothing drops silently. @@ -194,10 +194,10 @@ collects, the same "generated, not hand-maintained" rule the rule table in Superset's backend has no schema for chart options; each chart type's options are defined only by its frontend plugin. The file -`tools/contracts/params-contract.json` holds the option names for the 15 +`tools/contracts/params-contract.json` holds the option names for the 16 chart plugins the compiler writes, extracted from plugin source at each supported release (the Mixed Chart's by `tools/extract_mixed_contract.py`, -the waterfall's by `tools/extract_panel_contract.py`), and `tools/params_drift.py` (run by `test_params_contract.py`) +the waterfall's and the box plot's by `tools/extract_panel_contract.py`), and `tools/params_drift.py` (run by `test_params_contract.py`) fails the build if the compiler ever emits an option a target release does not declare. Current status: clean against all three releases; the single tool-owned key (`sdc_categorical_bar`) is allowlisted with its rationale @@ -212,7 +212,7 @@ resolution, which collects every bad reference into typed errors, and a deliberately misspelled column, which must come back with the real column as its first suggestion. Then an apply of the complete example spec (`tests/fixtures/kitchen_sink.json`), -which exercises all 16 chart types, per-chart WHERE filters, a native +which exercises all 17 chart types, per-chart WHERE filters, a native filter bar with two value pickers and a time range, plus markdown and tabs. (A numeric range filter scoped to specific charts is exercised live by the second-writer and fault-injection runs.) The same check then runs on two diff --git a/schema/dashboard_spec.schema.json b/schema/dashboard_spec.schema.json index f00ee03..b24fc91 100644 --- a/schema/dashboard_spec.schema.json +++ b/schema/dashboard_spec.schema.json @@ -1057,6 +1057,330 @@ "title": "BigNumberTrendChart", "type": "object" }, + "BoxPlotChart": { + "additionalProperties": false, + "description": "The distribution of a measure in each group (Superset's Box Plot): its median,\nquartiles, whiskers and outliers, e.g. the daily sales of each month. Each row of\n``distribute_across`` (a day, with time_grain P1D) is one observation of the\n``metrics``; ``groupby`` draws one box per value, each from its own observations.", + "properties": { + "color_scheme": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Categorical colour scheme for this chart's series, e.g. \"bnbColors\"; a dashboard color_scheme overrides it on the dashboard. Superset ships supersetColors, supersetAndPresetColors, presetColors, bnbColors, d3Category10, d3Category20, d3Category20b, d3Category20c, echarts4Colors, echarts5Colors, googleCategory10c, googleCategory20c, lyftColors, modernSunset, colorsOfRainbow, blueToGreen, redToYellow, wavesOfBlue; other names must be registered by your deployment (EXTRA_CATEGORICAL_COLOR_SCHEMES) or Superset uses its default.", + "title": "Color Scheme" + }, + "name": { + "description": "Chart title; unique within the dashboard (uuid seed input)", + "minLength": 1, + "title": "Name", + "type": "string" + }, + "dataset": { + "$ref": "#/$defs/DatasetRef" + }, + "filters": { + "description": "WHERE conditions on this chart only", + "items": { + "$ref": "#/$defs/ChartFilter" + }, + "title": "Filters", + "type": "array" + }, + "time_range": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Superset time range for this chart alone, e.g. \"Last 30 days\"; defaults to \"No filter\". A chart without a time axis of its own is filtered on its dataset's main time column, so a dataset without one ignores it.", + "title": "Time Range" + }, + "width": { + "anyOf": [ + { + "maximum": 12, + "minimum": 1, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Width" + }, + "height": { + "anyOf": [ + { + "maximum": 100, + "minimum": 1, + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Height in 40px units. Fractional values (0.2 steps = Superset's 8px grid) are tool-written by `chartwright absorb`; humans write integers.", + "title": "Height" + }, + "display_name": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "A shorter title shown on this dashboard's chart card (Superset's sliceNameOverride); `name` stays the chart's name everywhere else", + "title": "Display Name" + }, + "description": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "What the chart shows, e.g. a metric definition; dashboard viewers open it with \"Show chart description\" in the chart menu", + "title": "Description" + }, + "certified_by": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Who certified the chart: Superset shows a certified badge", + "title": "Certified By" + }, + "certification_details": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The certified badge's tooltip text; needs certified_by", + "title": "Certification Details" + }, + "cache_timeout": { + "anyOf": [ + { + "minimum": 1, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Seconds Superset caches this chart's query results; omit for the default", + "title": "Cache Timeout" + }, + "tags": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Superset tags, e.g. [\"finance\", \"weekly\"]. Superset 6.0.0 or later, with the TAGGING_SYSTEM feature flag on. 4.1.4 and 5.0.0 can't import a bundle that carries tags, so check, apply and plan refuse the field there before anything is written; 6.0.0 or later without the flag ignores them, so plan keeps reporting them. An apply replaces the object's tags with this list, so [] removes them all, tags added in the UI included; omit the field to leave tags alone, in apply and in plan.", + "title": "Tags" + }, + "type": { + "const": "box_plot", + "title": "Type", + "type": "string" + }, + "metrics": { + "description": "The measure each observation takes, e.g. [\"SUM(sales)\"]: a box per group and metric, all on one value axis", + "items": { + "type": "string" + }, + "minItems": 1, + "title": "Metrics", + "type": "array" + }, + "distribute_across": { + "description": "The columns whose rows are the observations (Superset's Distribute across), e.g. [\"order_date\"] with time_grain P1D: one observation a day", + "items": { + "type": "string" + }, + "minItems": 1, + "title": "Distribute Across", + "type": "array" + }, + "groupby": { + "description": "One box per value of these columns (Superset's Dimensions), e.g. [\"month\"]; omitted, one box for every observation", + "items": { + "type": "string" + }, + "title": "Groupby", + "type": "array" + }, + "time_grain": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The period of a time column in distribute_across, e.g. P1D; omitted, each distinct timestamp is one observation", + "title": "Time Grain" + }, + "whiskers": { + "anyOf": [ + { + "enum": [ + "tukey", + "min_max" + ], + "type": "string" + }, + { + "items": { + "type": "integer" + }, + "maxItems": 2, + "minItems": 2, + "type": "array" + } + ], + "default": "tukey", + "description": "How far the whiskers reach: \"tukey\" (to the furthest value within 1.5 times the box's height, values beyond drawn as outliers; Superset's default), \"min_max\" (the lowest and highest values, no outliers), or two percentiles, e.g. [5, 95], with the values beyond them as outliers", + "title": "Whiskers" + }, + "number_format": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "d3 format for the value axis and tooltip, e.g. '$,.0f'", + "title": "Number Format" + }, + "x_label_format": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "d3 time format for groups of a time column, e.g. '%b %Y'", + "title": "X Label Format" + }, + "x_axis_title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Title under the x axis", + "title": "X Axis Title" + }, + "y_axis_title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Title above the value axis, e.g. its unit", + "title": "Y Axis Title" + }, + "x_label_rotation": { + "anyOf": [ + { + "enum": [ + 0, + 45, + 90 + ], + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Rotate the group labels: 0, 45 or 90 degrees (Superset's X Tick Layout); omitted, Superset lays them out itself", + "title": "X Label Rotation" + }, + "row_limit": { + "anyOf": [ + { + "minimum": 1, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The most rows the query returns, observations of every group together; past it, the boxes miss observations (apply's data check says when it is reached). Superset 6.0.0 or later declares it; omitted, 6.x stops at 10,000 rows and older releases at the server's ROW_LIMIT", + "title": "Row Limit" + } + }, + "required": [ + "name", + "dataset", + "type", + "metrics", + "distribute_across" + ], + "title": "BoxPlotChart", + "type": "object" + }, "ChartFilter": { "additionalProperties": false, "description": "A WHERE-clause condition on the chart's dataset: column/op/value, or\n``sql`` for a custom SQL condition.", @@ -7613,6 +7937,7 @@ "bar": "#/$defs/BarChart", "big_number_total": "#/$defs/BigNumberChart", "big_number_trend": "#/$defs/BigNumberTrendChart", + "box_plot": "#/$defs/BoxPlotChart", "funnel": "#/$defs/FunnelChart", "heatmap": "#/$defs/HeatmapChart", "histogram": "#/$defs/HistogramChart", @@ -7677,6 +8002,9 @@ }, { "$ref": "#/$defs/WaterfallChart" + }, + { + "$ref": "#/$defs/BoxPlotChart" } ] }, diff --git a/skill/SKILL.md b/skill/SKILL.md index ac5c5e1..2947ca8 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -1,6 +1,6 @@ --- name: superset-dashboard -description: Build or modify an Apache Superset dashboard from a natural-language request by emitting a typed spec and compiling it with the chartwright CLI (guaranteed-correct import, no freeform dashboard JSON). Covers 16 chart types incl. pivot tables and waterfall bridges, per-chart WHERE filters, a native dashboard filter bar, markdown blocks, and tabs. Use when the user asks to create, generate, or change a Superset dashboard or chart. Never create Superset dashboards any other way (no raw REST calls, no hand-written import bundles, no UI automation). +description: Build or modify an Apache Superset dashboard from a natural-language request by emitting a typed spec and compiling it with the chartwright CLI (guaranteed-correct import, no freeform dashboard JSON). Covers 17 chart types incl. pivot tables, waterfall bridges and box plots, per-chart WHERE filters, a native dashboard filter bar, markdown blocks, and tabs. Use when the user asks to create, generate, or change a Superset dashboard or chart. Never create Superset dashboards any other way (no raw REST calls, no hand-written import bundles, no UI automation). --- # Superset dashboard via chartwright @@ -35,11 +35,11 @@ approximate it with a different mechanism. ## Procedure -1. `CW schema`: read the contract. Surface: 16 chart types (big numbers, +1. `CW schema`: read the contract. Surface: 17 chart types (big numbers, timeseries line/bar/area/scatter, categorical bar, pie/donut, table, pivot_table, heatmap, histogram, funnel, treemap, mixed: bars + a line on two axes, waterfall: a bridge whose `opening`, `steps` and `closing` keep - its order on 6.1+), metrics as saved names, `AGG(col) [AS Label]` or + its order on 6.1+, box_plot: a distribution per group), metrics as saved names, `AGG(col) [AS Label]` or `SQL(expression) AS Label`, per-chart `filters` (WHERE: column/op/value or `sql`), goal lines (`annotations`) on line/bar/area/scatter/mixed, dashboard-level `filters` (select, time_range, numeric range, time_grain diff --git a/tests/fixtures/display_controls.json b/tests/fixtures/display_controls.json index 51af08d..216729e 100644 --- a/tests/fixtures/display_controls.json +++ b/tests/fixtures/display_controls.json @@ -243,6 +243,32 @@ "x_label_format": "%Y", "x_label_rotation": 0, "time_range": "Last year" + }, + { + "name": "Daily Sales Spread", + "type": "box_plot", + "dataset": {"database": "examples", "table": "cleaned_sales_data"}, + "metrics": ["SUM(sales)"], + "distribute_across": ["order_date"], + "time_grain": "P1D", + "groupby": ["deal_size"], + "whiskers": [5, 95], + "number_format": "$,.0f", + "x_label_format": "%b %Y", + "x_axis_title": "Deal size", + "y_axis_title": "Daily sales", + "x_label_rotation": 45, + "row_limit": 5000, + "color_scheme": "wavesOfBlue" + }, + { + "name": "Order Value Range", + "type": "box_plot", + "dataset": {"database": "examples", "table": "cleaned_sales_data"}, + "metrics": ["SUM(sales)"], + "distribute_across": ["order_number"], + "groupby": ["product_line"], + "whiskers": "min_max" } ], "layout": { @@ -256,7 +282,8 @@ ["Sales Pivot"], ["Line by Deal Size", "Price Spread"], ["Revenue and Orders"], - ["Status Bridge", "Yearly Sales Steps"] + ["Status Bridge", "Yearly Sales Steps"], + ["Daily Sales Spread", "Order Value Range"] ] } } diff --git a/tests/fixtures/kitchen_sink.json b/tests/fixtures/kitchen_sink.json index c6039d7..a8304ea 100644 --- a/tests/fixtures/kitchen_sink.json +++ b/tests/fixtures/kitchen_sink.json @@ -274,6 +274,24 @@ }, "x_column": "status", "metric": "SUM(sales)" + }, + { + "name": "Daily Sales by Month", + "type": "box_plot", + "dataset": { + "database": "examples", + "table": "cleaned_sales_data" + }, + "metrics": [ + "SUM(sales)" + ], + "distribute_across": [ + "order_date" + ], + "time_grain": "P1D", + "groupby": [ + "month" + ] } ], "layout": { @@ -327,7 +345,8 @@ "Sales and Price by Month" ], [ - "Sales by Status" + "Sales by Status", + "Daily Sales by Month" ] ] } diff --git a/tests/fixtures/live_display_controls.json b/tests/fixtures/live_display_controls.json index 7e26f42..47417fa 100644 --- a/tests/fixtures/live_display_controls.json +++ b/tests/fixtures/live_display_controls.json @@ -231,6 +231,31 @@ "y_axis_title": "Sales", "x_label_format": "%Y", "x_label_rotation": 0 + }, + { + "name": "Daily Sales Spread", + "type": "box_plot", + "dataset": {"database": "examples", "table": "cleaned_sales_data"}, + "metrics": ["SUM(sales)"], + "distribute_across": ["order_date"], + "time_grain": "P1D", + "groupby": ["deal_size"], + "whiskers": [5, 95], + "number_format": "$,.0f", + "x_axis_title": "Deal size", + "y_axis_title": "Daily sales", + "x_label_rotation": 45, + "row_limit": 5000, + "color_scheme": "wavesOfBlue" + }, + { + "name": "Order Value Range", + "type": "box_plot", + "dataset": {"database": "examples", "table": "cleaned_sales_data"}, + "metrics": ["SUM(sales)"], + "distribute_across": ["order_number"], + "groupby": ["product_line"], + "whiskers": "min_max" } ], "layout": { @@ -244,7 +269,8 @@ ["Sales Pivot"], ["Line by Deal Size", "Price Spread"], ["Revenue and Orders"], - ["Yearly Sales Steps"] + ["Yearly Sales Steps"], + ["Daily Sales Spread", "Order Value Range"] ] } } diff --git a/tests/test_box_plot_chart.py b/tests/test_box_plot_chart.py new file mode 100644 index 0000000..b49a1db --- /dev/null +++ b/tests/test_box_plot_chart.py @@ -0,0 +1,273 @@ +"""Box plot (Superset's `box_plot` plugin): a measure's distribution in each group. + +Key names follow BoxPlot/controlPanel.ts at 4.1.4, 5.0.0 and 6.1.0 (row_limit is a +6.0.0 control). The observations are the rows of `columns` (distribute_across), one +box per `groupby` value, turned into quartiles by the boxplot post-processing step: +seen rendering on 4.1.4 and 6.1.0, the daily sales of each month.""" + +import io +import json +import sys +import zipfile +from pathlib import Path + +import pytest +import yaml +from pydantic import ValidationError + +from chartwright.compiler import compile_bundle +from chartwright.dashdiff import _normalize +from chartwright.decompile import decompile_bundle +from chartwright.design import advise +from chartwright.design.presets import Overlay +from chartwright.resolver import Resolution, _check_chart_fields +from chartwright.smoke import _box_plot_query, _observations_warning +from chartwright.spec import load_spec +from chartwright.testing import edit_bundle, stub_resolution +from chartwright.versions import check_spec_version + +sys.path.insert(0, str(Path(__file__).parent.parent / "tools")) +from params_drift import CONTRACT, check, emitted_keys_by_viz_type # noqa: E402 +from test_design_data import FakeProber, resolution # noqa: E402 + +DS = {"database": "examples", "table": "t"} +EMPTY = Overlay() +DAILY = {"name": "Daily sales by month", "type": "box_plot", "dataset": DS, + "metrics": ["SUM(sales)"], "distribute_across": ["order_date"], "time_grain": "P1D", + "groupby": ["month"]} + + +def _spec(*charts): + return load_spec({ + "spec_version": "1", + "dashboard": {"title": "T", "slug": "sdc-t"}, + "charts": list(charts), + "layout": {"rows": [[c["name"]] for c in charts]}, + }) + + +def _params(spec, res=None): + zf = zipfile.ZipFile(io.BytesIO(compile_bundle(spec, res or stub_resolution(spec)))) + out = {} + for n in zf.namelist(): + if "/charts/" in n: + cy = yaml.safe_load(zf.read(n)) + out[cy["slice_name"]] = (cy["viz_type"], cy["params"]) + return out + + +def _lookup(spec): + ds = stub_resolution(spec).for_chart(spec.charts[0].dataset) + return lambda u: {"database": "examples", "schema": None, "table": "t"} if u == ds.uuid else None + + +# -- compile --------------------------------------------------------------------------- + + +def test_a_box_plot_writes_its_observations_and_groups(): + viz, p = _params(_spec(DAILY))["Daily sales by month"] + assert viz == "box_plot" + assert (p["columns"], p["groupby"]) == (["order_date"], ["month"]) + assert [m["label"] for m in p["metrics"]] == ["SUM(sales)"] + # Written even at Superset's default: buildQuery adds no boxplot step without it. + assert p["whiskerOptions"] == "Tukey" + # The grain buckets a column only where temporal_columns_lookup marks it. + assert p["time_grain_sqla"] == "P1D" and p["temporal_columns_lookup"] == {"order_date": True} + assert set(p) == {"datasource", "viz_type", "time_range", "adhoc_filters", "columns", "groupby", + "metrics", "whiskerOptions", "time_grain_sqla", "temporal_columns_lookup"} + + +def test_only_the_time_columns_take_the_grain(): + spec = _spec({**DAILY, "distribute_across": ["order_date", "customer"]}) + res = stub_resolution(spec) + res.for_chart(spec.charts[0].dataset).column_types = {"order_date": 2, "customer": 1} + _, p = _params(spec, res)["Daily sales by month"] + assert p["temporal_columns_lookup"] == {"order_date": True} + _, p = _params(_spec({**DAILY, "time_grain": None}))["Daily sales by month"] + assert "time_grain_sqla" not in p and "temporal_columns_lookup" not in p + + +@pytest.mark.parametrize("whiskers, option", [ + ("tukey", "Tukey"), ("min_max", "Min/max (no outliers)"), ([5, 95], "5/95 percentiles"), + ([0, 100], "0/100 percentiles")]) +def test_whiskers(whiskers, option): + _, p = _params(_spec({**DAILY, "whiskers": whiskers}))["Daily sales by month"] + assert p["whiskerOptions"] == option + + +def test_every_display_field_has_its_control(): + chart = {**DAILY, "number_format": "$,.0f", "x_label_format": "%b %Y", + "x_axis_title": "Month", "y_axis_title": "Daily sales", "x_label_rotation": 90, + "row_limit": 5000, "color_scheme": "wavesOfBlue"} + _, p = _params(_spec(chart))["Daily sales by month"] + assert (p["number_format"], p["date_format"], p["x_ticks_layout"]) == ("$,.0f", "%b %Y", "90°") + assert (p["x_axis_title"], p["x_axis_title_margin"]) == ("Month", 50) + assert (p["y_axis_title"], p["y_axis_title_margin"], p["y_axis_title_position"]) == ( + "Daily sales", 15, "Top") + assert (p["row_limit"], p["color_scheme"]) == (5000, "wavesOfBlue") + + +def test_a_dashboard_time_filter_reaches_it_through_the_dataset_time(): + spec = _spec({**DAILY, "time_range": "Last year"}) + res = stub_resolution(spec) + res.for_chart(spec.charts[0].dataset).main_dttm_col = "order_date" + _, p = _params(spec, res)["Daily sales by month"] + assert p["granularity_sqla"] == "order_date" + assert not [f for f in p["adhoc_filters"] if f["operator"] == "TEMPORAL_RANGE"] + + +# -- the spec -------------------------------------------------------------------------- + + +@pytest.mark.parametrize("change, message", [ + ({"groupby": ["order_date"]}, "in both distribute_across and groupby"), + ({"groupby": ["month", "month"]}, "groupby lists a value twice"), + ({"distribute_across": []}, "at least 1"), + ({"metrics": []}, "at least 1"), + ({"whiskers": [95, 5]}, "the lower first"), + ({"whiskers": [5, 101]}, "from 0 to 100"), + ({"whiskers": [5]}, "at least 2"), + ({"whiskers": [1, 2, 3]}, "at most 2"), + ({"whiskers": "iqr"}, "whiskers"), + ({"x_label_rotation": 30}, "x_label_rotation"), +]) +def test_a_box_plot_is_validated(change, message): + with pytest.raises(ValidationError, match=message): + _spec({**DAILY, **change}) + + +# -- decompile --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("chart", [DAILY, {**DAILY, "groupby": [], "time_grain": None}, { + **DAILY, "whiskers": [2, 98], "number_format": ",.0f", "x_label_format": "%b", + "x_axis_title": "M", "y_axis_title": "S", "x_label_rotation": 0, "row_limit": 9, + "color_scheme": "bnbColors", "metrics": ["SUM(sales)", "COUNT(*)"], "time_range": "Last year", + "filters": [{"column": "deal_size", "op": "==", "value": "Large"}]}, + {**DAILY, "whiskers": "min_max"}]) +def test_decompile_round_trips(chart): + spec = _spec(chart) + result = decompile_bundle(compile_bundle(spec, stub_resolution(spec)), _lookup(spec)) + assert result.losses == [], result.losses_json() + assert _normalize(load_spec(result.spec)) == _normalize(spec) + + +def test_a_box_plot_saved_in_superset_reads_back_whole(): + """The featured-charts export (6.1.0) holds a box plot made in Explore.""" + blob = (Path(__file__).parent / "fixtures" / "featured_charts_export.zip").read_bytes() + result = decompile_bundle(blob, lambda u: {"database": "examples", "schema": None, "table": "t"}) + chart = next(c for c in result.spec["charts"] if c["type"] == "box_plot") + assert not [loss for loss in result.losses if loss.where == chart["name"]] + assert {k: chart.get(k) for k in ("metrics", "distribute_across", "groupby", "time_grain", + "whiskers", "color_scheme")} == { + "metrics": ["count"], "distribute_across": ["order_date"], "groupby": ["product_line"], + "time_grain": "P1D", "whiskers": None, "color_scheme": "supersetColors"} + + +def _decompiled_after(**stored): + spec = _spec(DAILY) + + def edit(path, doc): + if "/charts/" in path: + doc["params"].update(stored) + + return decompile_bundle(edit_bundle(compile_bundle(spec, stub_resolution(spec)), edit), + _lookup(spec)) + + +def test_what_the_spec_cant_hold_is_named(): + out = _decompiled_after(whiskerOptions="Outer fences") + assert any("whisker option 'Outer fences' not preserved" in loss.what for loss in out.losses) + out = _decompiled_after(series_limit=5, zoomable=True) + assert any("series_limit=5" in loss.what and "zoomable=True" in loss.what for loss in out.losses) + out = _decompiled_after(zoomable=False, series_limit=None, x_ticks_layout="staggered") + assert out.losses == [] and out.spec["charts"][0]["x_label_rotation"] == 45 + out = _decompiled_after(groupby=[{"expressionType": "SQL", "sqlExpression": "x", "label": "x"}]) + assert out.spec["charts"] == [] and any("SQL columns" in loss.what for loss in out.losses) + + +# -- releases ---------------------------------------------------------------------------- + + +def test_a_row_limit_warns_before_6_0(): + spec = _spec({**DAILY, "row_limit": 5000}) + for version in ("4.1.4", "5.0.0"): + out = check_spec_version(spec, version) + assert out.errors == [] and [w["field"] for w in out.warnings] == ["row_limit"], version + assert "the dashboard's query stops at it" in out.warnings[0]["detail"] + assert check_spec_version(spec, "6.0.0").warnings == [] + assert check_spec_version(_spec(DAILY), "4.1.4").warnings == [] + + +def test_the_contract_declares_every_key_at_its_release(): + contract = json.loads(CONTRACT.read_text(encoding="utf-8")) + emitted = emitted_keys_by_viz_type()["box_plot"] + assert {"columns", "whiskerOptions", "temporal_columns_lookup", "row_limit"} <= emitted + for version in ("4.1.4", "5.0.0", "6.1.0"): + assert check(version, contract, {"box_plot": emitted}) == [] + assert "row_limit" in contract["6.1.0"]["box_plot"] + assert "row_limit" not in contract["5.0.0"]["box_plot"] + + +# -- resolve, smoke --------------------------------------------------------------------- + + +def test_resolve_checks_every_column_and_metric(): + spec = _spec({**DAILY, "metrics": ["SUM(nope)"], "groupby": ["missing"]}) + ds = stub_resolution(spec).for_chart(spec.charts[0].dataset) + ds.columns = ["order_date", "sales"] + res = Resolution() + _check_chart_fields(spec.charts[0], ds, res) + assert sorted(e.ref for e in res.errors) == ["missing", "nope"] + + +def test_smoke_queries_the_observations_as_the_plugin_does(): + spec = _spec(DAILY) + ds = stub_resolution(spec).for_chart(spec.charts[0].dataset) + q = _box_plot_query(spec.charts[0], spec, ds) + assert q["columns"][0]["columnType"] == "BASE_AXIS" and q["columns"][0]["timeGrain"] == "P1D" + assert q["columns"][1:] == ["month"] and q["row_limit"] == 10000 + + +def test_smoke_says_when_the_observations_reach_the_row_limit(): + chart = _spec({**DAILY, "row_limit": 37}).charts[0] + assert _observations_warning(chart, 36) is None + assert "reached its row_limit (37 rows)" in _observations_warning(chart, 37) + assert "10,000, where Superset 6.0.0 or later stops" in _observations_warning( + _spec(DAILY).charts[0], 10000) + + +# -- design brain ----------------------------------------------------------------------- + + +def _rules(chart, **kw): + return [f for f in advise(_spec(chart), overlay=EMPTY, **kw).findings + if f.rule.startswith("chart.box-plot") or f.rule == "chart.ordinal-order"] + + +def test_a_box_needs_many_observations(): + found = [f for f in _rules({**DAILY, "time_grain": "P1M"}) + if f.rule == "chart.box-plot-observations"] + assert [f.severity for f in found] == ["warn"] and "one observation per month" in found[0].detail + for quiet in (DAILY, {**DAILY, "groupby": ["day_of_week"], "time_grain": "P1D"}, + {**DAILY, "time_grain": None}): + assert not [f for f in _rules(quiet) if f.rule == "chart.box-plot-observations"] + + +def test_too_many_boxes_with_a_profile(): + res = resolution(order_date=2, month=0, sales=0) + orders = {**DAILY, "dataset": {"database": "db", "table": "orders"}} + found = [f for f in _rules(orders, resolution=res, prober=FakeProber({"month": 40})) + if f.rule == "chart.box-plot-groups"] + assert [f.severity for f in found] == ["warn"] + assert not [f for f in _rules(orders, resolution=res, prober=FakeProber({"month": 12})) + if f.rule == "chart.box-plot-groups"] + assert not [f for f in _rules(orders) if f.rule == "chart.box-plot-groups"] # offline + + +def test_ordinal_groups_sort_by_name_unless_the_dataset_says_numeric(): + named = {**DAILY, "dataset": {"database": "db", "table": "orders"}, "groupby": ["month_name"]} + assert "chart.ordinal-order" in {f.rule for f in _rules(named)} + numeric = {**named, "groupby": ["month"]} + assert "chart.ordinal-order" not in {f.rule for f in _rules( + numeric, resolution=resolution(order_date=2, month=0, sales=0))} diff --git a/tests/test_decompile.py b/tests/test_decompile.py index c683b2c..59c170c 100644 --- a/tests/test_decompile.py +++ b/tests/test_decompile.py @@ -39,7 +39,7 @@ def test_round_trip_tool_born_bundle(): def test_round_trip_kitchen_sink(): - """The full surface (16 chart types, chart filters, native filters, + """The full surface (17 chart types, chart filters, native filters, markdown, tabs) must survive compile->decompile losslessly.""" original = load_spec(json.loads((FIXTURES / "kitchen_sink.json").read_text())) bundle = compile_bundle(original, stub_resolution(original)) diff --git a/tests/test_spec_v2.py b/tests/test_spec_v2.py index f51566a..8e9b7e8 100644 --- a/tests/test_spec_v2.py +++ b/tests/test_spec_v2.py @@ -17,7 +17,7 @@ def _copy(): def test_kitchen_sink_valid(): spec = load_spec(SINK) - assert len(spec.charts) == 17 + assert len(spec.charts) == 18 assert {c.type for c in spec.charts} == set(CHART_TYPES) # every type covered assert len(spec.filters) == 3 assert spec.layout.tabs and len(spec.layout.tabs) == 2 diff --git a/tests/test_superset_version.py b/tests/test_superset_version.py index ee2d23c..e747866 100644 --- a/tests/test_superset_version.py +++ b/tests/test_superset_version.py @@ -92,6 +92,7 @@ def test_the_registry_gates_the_unsafe_fields_and_warns_for_the_ignored_ones(): "total_label": ("6.1.0", "warn"), "increase_label": ("6.1.0", "warn"), "decrease_label": ("6.1.0", "warn"), + "row_limit": ("6.0.0", "warn"), # a box plot's: the 4.1.4 and 5.0.0 panel has none } schema = json.dumps(json_schema()) for g in GATED_FIELDS: diff --git a/tools/contracts/params-contract.json b/tools/contracts/params-contract.json index e2e5582..0d00937 100644 --- a/tools/contracts/params-contract.json +++ b/tools/contracts/params-contract.json @@ -575,6 +575,26 @@ "x_ticks_layout", "y_axis_format", "y_axis_label" + ], + "box_plot": [ + "adhoc_filters", + "color_scheme", + "columns", + "date_format", + "groupby", + "metrics", + "number_format", + "series_limit", + "series_limit_metric", + "temporal_columns_lookup", + "time_grain_sqla", + "whiskerOptions", + "x_axis_title", + "x_axis_title_margin", + "x_ticks_layout", + "y_axis_title", + "y_axis_title_margin", + "y_axis_title_position" ] }, "5.0.0": { @@ -1158,6 +1178,26 @@ "x_ticks_layout", "y_axis_format", "y_axis_label" + ], + "box_plot": [ + "adhoc_filters", + "color_scheme", + "columns", + "date_format", + "groupby", + "metrics", + "number_format", + "series_limit", + "series_limit_metric", + "temporal_columns_lookup", + "time_grain_sqla", + "whiskerOptions", + "x_axis_title", + "x_axis_title_margin", + "x_ticks_layout", + "y_axis_title", + "y_axis_title_margin", + "y_axis_title_position" ] }, "6.1.0": { @@ -1808,6 +1848,28 @@ "x_ticks_layout", "y_axis_format", "y_axis_label" + ], + "box_plot": [ + "adhoc_filters", + "color_scheme", + "columns", + "date_format", + "groupby", + "metrics", + "number_format", + "row_limit", + "series_limit", + "series_limit_metric", + "temporal_columns_lookup", + "time_grain_sqla", + "whiskerOptions", + "x_axis_title", + "x_axis_title_margin", + "x_ticks_layout", + "y_axis_title", + "y_axis_title_margin", + "y_axis_title_position", + "zoomable" ] } } diff --git a/tools/extract_panel_contract.py b/tools/extract_panel_contract.py index 9143b7a..859174c 100644 --- a/tools/extract_panel_contract.py +++ b/tools/extract_panel_contract.py @@ -33,6 +33,7 @@ # viz_type -> its control panel. PANELS = { "waterfall": f"{EC}/Waterfall/controlPanel.tsx", + "box_plot": f"{EC}/BoxPlot/controlPanel.ts", } # A control named by string after an object in its row: [{ name: 'time_grain_sqla', ...}, # 'temporal_columns_lookup']. diff --git a/tools/params_drift.py b/tools/params_drift.py index be81c6c..5bff9ae 100644 --- a/tools/params_drift.py +++ b/tools/params_drift.py @@ -90,6 +90,7 @@ "waterfall:decrease_label": "6.1.0", "waterfall:show_total": "6.1.0", "waterfall:total_label": "6.1.0", + "box_plot:row_limit": "6.0.0", # BoxPlot/controlPanel.ts:81 at 6.0.0 and 6.1.0 } # The reverse: keys a later release dropped, still written for the releases before