From 4ea855ece8d4392ef453fdff15a017f91b50a1ae Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Sun, 26 Jul 2026 02:13:39 +0000 Subject: [PATCH] =?UTF-8?q?Turn=20Plotly=20compat=20into=20a=20number,=20a?= =?UTF-8?q?nd=20correct=20=C2=A724's=20two=20wrong=20premises?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §24 has asked since the audit for compat to be "a measured number" instead of vibes. Implementing it turned up two things the plan assumed that are not true. **No published Plotly wheel ships `plot-schema.json`.** Checked against 4.14.3, 5.24.1, and 6.9.0: it is a plotly.js artifact, and the Python package generates validator classes from it. The equivalent — and a better shape for this job, being one flat entry per dotted path — is `plotly/validators/_validators.json`. The real surface is 9,472 leaf attributes once compound namespaces and `*src` column-reference companions come out, three times the ~3,000 the dossier estimated. **There is no Plotly shim to warn.** The shipped shim is `xy.pyplot`, which is Matplotlib-flavored, so "supported" cannot mean "the shim accepts this attribute" — nothing would be accepting it. It means XY can express the same visual outcome, decided by a committed rule table in the script. 9,472 attributes cannot be audited one at a time, and a table implying they were would be fiction; every rule names the XY surface that answers it, so any row can be traced to the claim that produced it. The number, scoped to the trace types XY implements plus `layout`: 344 supported and 126 mapped-with-difference of 3,387. Whole-schema: 345 and 126 of 9,472, where the denominator is dominated by 3-D, geo, polar, and financial traces XY does not implement. Both are published because either alone misleads — the first overstates by hiding its scope, the second understates by counting work never attempted. 2,190 attributes land in `unsupported/unclassified`: no committed rule claims them. That bucket is reported rather than folded into a friendlier one, and `tests/test_plotly_schema_coverage.py` fails if it disappears, if the report is hand-edited, if `*src` companions pad the denominator, or if the scoped number loses the sentence that scopes it. --- CHANGELOG.md | 10 + scripts/plotly_schema_coverage.py | 306 +++++++++++++++++++++++++++ scripts/verify_sdist.py | 1 + spec/README.md | 4 + spec/api/plotly-coverage.md | 62 ++++++ spec/design-dossier.md | 25 ++- tests/test_plotly_schema_coverage.py | 99 +++++++++ 7 files changed, 506 insertions(+), 1 deletion(-) create mode 100644 scripts/plotly_schema_coverage.py create mode 100644 spec/api/plotly-coverage.md create mode 100644 tests/test_plotly_schema_coverage.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 77a1bf6c..4119dfe4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,16 @@ in the README). ## [Unreleased] ### Added +- **Plotly attribute coverage is now a number** (§24): + `scripts/plotly_schema_coverage.py` ingests Plotly's schema and classifies + every attribute `supported | mapped-with-difference | unsupported` by a + committed rule table, writing `spec/api/plotly-coverage.md`. Two corrections + to the plan it implements: no published Plotly wheel ships `plot-schema.json` + (the equivalent is `validators/_validators.json`, and the real surface is + 9,472 leaf attributes rather than ~3,000), and there is no Plotly shim to warn + — so "supported" means XY can express the same visual outcome, not that a shim + accepts the key. Scoped to the trace types XY implements plus `layout`: 344 + supported and 126 mapped-with-difference of 3,387. - A **committed customization comparison** against Plotly, Vega-Lite, Bokeh, and Matplotlib (`spec/api/customization-vs-alternatives.md`), with the same discipline as the benchmark harness: pinned competitor versions, a named diff --git a/scripts/plotly_schema_coverage.py b/scripts/plotly_schema_coverage.py new file mode 100644 index 00000000..93d01e97 --- /dev/null +++ b/scripts/plotly_schema_coverage.py @@ -0,0 +1,306 @@ +#!/usr/bin/env python3 +"""Classify Plotly's attribute schema against what XY can express (§24). + +§24 says compat should be "a measured number", and names Plotly's +`plot-schema.json` as the thing to ingest. Two facts about the world have +changed since that was written, and this script is written to both of them: + +1. **No published Plotly wheel ships `plot-schema.json`.** Verified against + 4.14.3, 5.24.1, and 6.9.0 — the file is a plotly.js artifact and the Python + package generates validator classes from it instead. The equivalent, and a + better shape for this job, is `plotly/validators/_validators.json`: one flat + entry per dotted attribute path with its validator class. +2. **XY has no Plotly shim.** The shipped shim is `xy.pyplot`, which is + Matplotlib-flavored. So "supported" here cannot mean "the shim accepts the + attribute" — there is nothing to accept it. It means *XY can express the + same visual outcome*, and the rule that says so is committed below where it + can be argued with. + +Classification is rule-driven, not hand-enumerated: 9,472 attributes cannot be +audited one at a time, and a table that claims otherwise would be fiction. Each +rule names the XY surface that answers the attribute, so an unfamiliar reader +can check any row by following the pointer. Attributes no rule claims land in +`unsupported/unclassified`, which is the honest bucket — it is reported, never +folded into a friendlier one. + + uv run --extra bench python scripts/plotly_schema_coverage.py --check + uv run --extra bench python scripts/plotly_schema_coverage.py --write +""" + +from __future__ import annotations + +import argparse +import fnmatch +import json +import sys +from collections import Counter +from pathlib import Path +from typing import NamedTuple + +ROOT = Path(__file__).resolve().parents[1] +REPORT = ROOT / "spec" / "api" / "plotly-coverage.md" + +SUPPORTED = "supported" +MAPPED = "mapped-with-difference" +UNSUPPORTED = "unsupported" + +#: Trace types XY implements, mapped to the XY mark that answers them. Every +#: other Plotly trace type is `unsupported/no-such-trace` — a truthful bucket, +#: and by far the largest one. +TRACE_EQUIVALENTS: dict[str, str] = { + "scatter": "xy.scatter / xy.line", + "scattergl": "xy.scatter", + "bar": "xy.bar / xy.column", + "histogram": "xy.histogram", + "histogram2d": "xy.heatmap", + "heatmap": "xy.heatmap", + "box": "xy.box", + "violin": "xy.violin", + "contour": "xy.contour", + "mesh3d": "", # deliberately absent: 3-D is out of v1 +} + +#: Roots that are not trace types at all. +META_ROOTS = frozenset({"data", "frame", "frames"}) + + +class Rule(NamedTuple): + """One committed claim about a family of Plotly attributes.""" + + pattern: str + verdict: str + surface: str + note: str + + +#: Order matters: the first matching rule wins, so specific patterns precede +#: general ones. `*` matches within a path segment and across segments alike +#: (fnmatch semantics), which is what makes `*.marker.symbol` reach both +#: `scatter.marker.symbol` and `scatter.selected.marker.symbol`. +RULES: tuple[Rule, ...] = ( + # --- paint and geometry XY draws exactly ------------------------------- + Rule("*.marker.symbol", SUPPORTED, "style={'marker-shape': ...}", "17 shared shapes"), + Rule("*.marker.color", SUPPORTED, "color= / style={'fill': ...}", ""), + Rule("*.marker.opacity", SUPPORTED, "style={'fill-opacity': ...}", ""), + Rule("*.marker.size", SUPPORTED, "size=", ""), + Rule("*.marker.line.color", SUPPORTED, "style={'stroke': ...}", ""), + Rule("*.marker.line.width", SUPPORTED, "style={'stroke-width': ...}", ""), + Rule("*.line.dash", SUPPORTED, "style={'stroke-dasharray': ...}", "named presets and px lists"), + Rule("*.line.color", SUPPORTED, "style={'stroke': ...}", ""), + Rule("*.line.width", SUPPORTED, "style={'stroke-width': ...}", ""), + Rule("*.line.shape", MAPPED, "curve=", "XY has linear and monotone-cubic, not all six"), + Rule("*.opacity", SUPPORTED, "style={'opacity': ...}", ""), + Rule("*.name", SUPPORTED, "name=", ""), + Rule("*.visible", MAPPED, "compose the chart without the mark", "no per-trace toggle prop"), + Rule("*.colorscale", SUPPORTED, "colormap=", "custom ramps accepted"), + Rule("*.showscale", SUPPORTED, "xy.colorbar()", ""), + Rule("*.cmin", SUPPORTED, "color_domain=", ""), + Rule("*.cmax", SUPPORTED, "color_domain=", ""), + # --- layout XY answers -------------------------------------------------- + Rule("layout.xaxis.type", SUPPORTED, "xy.x_axis(scale=...)", ""), + Rule("layout.yaxis.type", SUPPORTED, "xy.y_axis(scale=...)", ""), + Rule("layout.*axis.title.text", SUPPORTED, "xy.x_axis(label=...)", ""), + Rule("layout.*axis.range", SUPPORTED, "xy.x_axis(domain=...)", ""), + Rule("layout.*axis.showgrid", SUPPORTED, "xy.x_axis(grid=...)", ""), + Rule("layout.*axis.gridcolor", SUPPORTED, "style={'grid-color': ...}", ""), + Rule("layout.*axis.gridwidth", SUPPORTED, "style={'grid-width': ...}", ""), + Rule("layout.*axis.showline", SUPPORTED, "xy.x_axis(line=...)", ""), + Rule("layout.*axis.linecolor", SUPPORTED, "style={'axis-color': ...}", ""), + Rule("layout.*axis.ticklen", SUPPORTED, "style={'tick-length': ...}", ""), + Rule("layout.*axis.tickcolor", SUPPORTED, "style={'tick-color': ...}", ""), + Rule("layout.*axis.ticks", SUPPORTED, "style={'tick-direction': ...}", ""), + Rule("layout.*axis.showticklabels", SUPPORTED, "xy.x_axis(text=...)", ""), + Rule("layout.title.text", SUPPORTED, "title=", ""), + Rule("layout.showlegend", SUPPORTED, "xy.legend()", ""), + Rule("layout.legend.*", MAPPED, "xy.legend(...) + slot styling", "different option names"), + Rule("layout.colorway", SUPPORTED, "xy.theme(palette=[...])", ""), + Rule("layout.template", MAPPED, "xy.theme(...)", "not a serializable template format"), + Rule("layout.paper_bgcolor", SUPPORTED, "style={'--chart-bg': ...}", ""), + Rule("layout.plot_bgcolor", SUPPORTED, "style={'--chart-plot-bg': ...}", ""), + Rule("layout.width", SUPPORTED, "width=", ""), + Rule("layout.height", SUPPORTED, "height=", ""), + Rule("layout.margin.*", SUPPORTED, "padding=", ""), + Rule("layout.annotations.*", MAPPED, "xy.text() / xy.callout()", "different geometry model"), + Rule("layout.shapes.*", MAPPED, "xy.rule() / xy.band()", "no arbitrary path shapes"), + Rule("layout.hovermode", MAPPED, "xy.tooltip(...)", ""), + Rule("layout.dragmode", MAPPED, "xy.modebar(...) / interaction options", ""), + # --- deliberate exclusions, named as such ------------------------------- + Rule("layout.scene*", UNSUPPORTED, "", "3-D is explicitly out of v1"), + Rule("layout.geo*", UNSUPPORTED, "", "geo is explicitly out of v1"), + Rule("layout.map*", UNSUPPORTED, "", "mapbox/map is explicitly out of v1"), + Rule("layout.polar*", UNSUPPORTED, "", "polar axes are not implemented"), + Rule("layout.ternary*", UNSUPPORTED, "", "ternary axes are not implemented"), + Rule("layout.smith*", UNSUPPORTED, "", "smith axes are not implemented"), + Rule("layout.transition*", MAPPED, "xy.animation(...)", "different easing vocabulary"), + Rule("layout.updatemenus*", UNSUPPORTED, "", "no in-chart widget layer"), + Rule("layout.sliders*", UNSUPPORTED, "", "no in-chart widget layer"), + Rule("*.hovertemplate", MAPPED, "xy.tooltip(...)", "not a mini-language"), + Rule("*.customdata", MAPPED, "tooltip sources", ""), +) + + +def load_schema() -> tuple[dict[str, dict], str]: + try: + import plotly + except ModuleNotFoundError: # pragma: no cover - depends on the extra + raise SystemExit( + "plotly is not installed; run with `uv run --extra bench` " + "(the pinned version is in benchmarks/requirements-ci.in)" + ) from None + path = Path(plotly.__file__).parent / "validators" / "_validators.json" + if not path.exists(): # pragma: no cover - depends on the plotly release + raise SystemExit( + f"plotly {plotly.__version__} has no {path.name}; see this script's docstring" + ) + return json.loads(path.read_text(encoding="utf-8")), plotly.__version__ + + +def attribute_paths(schema: dict[str, dict]) -> list[str]: + """Leaf attributes only, minus the `*src` column-reference companions. + + A compound node is a namespace, not a knob, and `marker.colorsrc` is not a + second styling decision beside `marker.color` — counting either would pad + the denominator in XY's favour, which is the wrong direction to be wrong in. + """ + skip = {"CompoundValidator", "CompoundArrayValidator", "SrcValidator"} + return sorted(path for path, node in schema.items() if node["superclass"] not in skip) + + +def classify(path: str) -> tuple[str, str, str]: + """-> (verdict, xy surface, reason). First matching rule wins.""" + root = path.split(".", 1)[0] + if root not in META_ROOTS and root != "layout": + equivalent = TRACE_EQUIVALENTS.get(root) + if equivalent is None: + return UNSUPPORTED, "", f"no such trace: XY does not implement {root!r}" + if not equivalent: + return UNSUPPORTED, "", f"{root!r} is explicitly out of v1" + for rule in RULES: + if fnmatch.fnmatch(path, rule.pattern): + return rule.verdict, rule.surface, rule.note + return UNSUPPORTED, "", "unclassified" + + +def build_report(schema: dict[str, dict], version: str) -> tuple[str, dict]: + paths = attribute_paths(schema) + rows = [(path, *classify(path)) for path in paths] + verdicts = Counter(verdict for _, verdict, _, _ in rows) + reasons = Counter(reason for _, verdict, _, reason in rows if verdict == UNSUPPORTED) + + implemented = sorted(k for k, v in TRACE_EQUIVALENTS.items() if v) + in_scope = [r for r in rows if r[0].split(".", 1)[0] in {*implemented, "layout"}] + in_scope_verdicts = Counter(verdict for _, verdict, _, _ in in_scope) + + summary = { + "plotly_version": version, + "attributes": len(paths), + "verdicts": dict(verdicts), + "in_scope_attributes": len(in_scope), + "in_scope_verdicts": dict(in_scope_verdicts), + "unsupported_reasons": dict(reasons.most_common()), + "implemented_trace_types": implemented, + } + + def pct(n: int, d: int) -> str: + return f"{100.0 * n / d:.1f}%" if d else "n/a" + + lines = [ + "# Plotly attribute coverage", + "", + "", + "", + f"Measured against **plotly {version}**, " + f"`plotly/validators/_validators.json`, {len(paths):,} leaf attributes " + "(compound namespaces and `*src` column-reference companions excluded).", + "", + "## Whole schema", + "", + "| verdict | attributes | share |", + "|---|---|---|", + ] + for verdict in (SUPPORTED, MAPPED, UNSUPPORTED): + count = verdicts.get(verdict, 0) + lines.append(f"| {verdict} | {count:,} | {pct(count, len(paths))} |") + lines += [ + "", + "That denominator is dominated by trace types XY does not implement, so", + "the whole-schema share is a poor headline number. The scoped one below", + "is the number worth quoting, and it must be quoted *with* its scope.", + "", + "## Scoped to the trace types XY implements, plus `layout`", + "", + f"Trace types: {', '.join(f'`{name}`' for name in implemented)}. " + f"{len(in_scope):,} attributes.", + "", + "| verdict | attributes | share |", + "|---|---|---|", + ] + for verdict in (SUPPORTED, MAPPED, UNSUPPORTED): + count = in_scope_verdicts.get(verdict, 0) + lines.append(f"| {verdict} | {count:,} | {pct(count, len(in_scope))} |") + lines += [ + "", + "## Why attributes are unsupported", + "", + "| reason | attributes |", + "|---|---|", + ] + for reason, count in reasons.most_common(15): + lines.append(f"| {reason} | {count:,} |") + lines += [ + "", + "`unclassified` is the honest residue: attributes no committed rule in", + "`scripts/plotly_schema_coverage.py` claims. It is reported rather than", + "folded into a friendlier bucket, and shrinking it is how this table", + "improves.", + "", + "## Reproducing", + "", + "```bash", + "uv run --extra bench python scripts/plotly_schema_coverage.py --check", + "```", + "", + "`--check` fails if this file is stale. The rules it applies are in the", + "`RULES` table of that script, one line per claim, so any row here can be", + "traced to the claim that produced it.", + "", + ] + return "\n".join(lines), summary + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--write", action="store_true", help="regenerate the committed report") + parser.add_argument( + "--check", action="store_true", help="fail if the committed report is stale" + ) + parser.add_argument("--json", type=Path, help="also write the summary as JSON") + args = parser.parse_args(argv) + + schema, version = load_schema() + report, summary = build_report(schema, version) + + if args.json: + args.json.write_text(json.dumps(summary, indent=2) + "\n", encoding="utf-8") + if args.write: + REPORT.parent.mkdir(parents=True, exist_ok=True) + REPORT.write_text(report, encoding="utf-8") + print(f"wrote {REPORT.relative_to(ROOT)}") + return 0 + if args.check: + current = REPORT.read_text(encoding="utf-8") if REPORT.exists() else "" + if current != report: + print( + f"{REPORT.relative_to(ROOT)} is stale; " + "run scripts/plotly_schema_coverage.py --write", + file=sys.stderr, + ) + return 1 + print(f"{REPORT.relative_to(ROOT)} is current") + return 0 + print(json.dumps(summary, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verify_sdist.py b/scripts/verify_sdist.py index e0fdb2ab..0963c67d 100644 --- a/scripts/verify_sdist.py +++ b/scripts/verify_sdist.py @@ -106,6 +106,7 @@ "scripts/check_public_api.py", "scripts/check_claim_guardrails.py", "scripts/gen_capability_matrix.py", + "scripts/plotly_schema_coverage.py", "scripts/check_python_floor.py", "scripts/check_regressions.py", "scripts/bench_dashboard.py", diff --git a/spec/README.md b/spec/README.md index cb5a6ce0..aca1f4c7 100644 --- a/spec/README.md +++ b/spec/README.md @@ -34,6 +34,10 @@ The public surface: what callers can build, style, export, and interact with. the committed comparison of customization and extensibility against Plotly, Vega-Lite, Bokeh, and Matplotlib, with its method, its pinned versions, and the rows XY loses. Pinned by `tests/test_customization_comparison.py`. +- [`plotly-coverage.md`](api/plotly-coverage.md) — **generated**: every Plotly + attribute classified `supported | mapped-with-difference | unsupported` (§24). + Regenerate with `scripts/plotly_schema_coverage.py --write` (needs the `bench` + extra). - [`export.md`](api/export.md) — how a figure becomes bytes: one entry point across five image formats, deterministic engine choice, browser-free default. - [`interaction.md`](api/interaction.md) — the authority on which browser diff --git a/spec/api/plotly-coverage.md b/spec/api/plotly-coverage.md new file mode 100644 index 00000000..a4d1fa6f --- /dev/null +++ b/spec/api/plotly-coverage.md @@ -0,0 +1,62 @@ +# Plotly attribute coverage + + + +Measured against **plotly 6.9.0**, `plotly/validators/_validators.json`, 9,472 leaf attributes (compound namespaces and `*src` column-reference companions excluded). + +## Whole schema + +| verdict | attributes | share | +|---|---|---| +| supported | 345 | 3.6% | +| mapped-with-difference | 126 | 1.3% | +| unsupported | 9,001 | 95.0% | + +That denominator is dominated by trace types XY does not implement, so +the whole-schema share is a poor headline number. The scoped one below +is the number worth quoting, and it must be quoted *with* its scope. + +## Scoped to the trace types XY implements, plus `layout` + +Trace types: `bar`, `box`, `contour`, `heatmap`, `histogram`, `histogram2d`, `scatter`, `scattergl`, `violin`. 3,387 attributes. + +| verdict | attributes | share | +|---|---|---| +| supported | 344 | 10.2% | +| mapped-with-difference | 126 | 3.7% | +| unsupported | 2,917 | 86.1% | + +## Why attributes are unsupported + +| reason | attributes | +|---|---| +| unclassified | 2,190 | +| no such trace: XY does not implement 'scatter3d' | 290 | +| 3-D is explicitly out of v1 | 281 | +| no such trace: XY does not implement 'carpet' | 200 | +| no such trace: XY does not implement 'treemap' | 198 | +| no such trace: XY does not implement 'funnel' | 197 | +| no such trace: XY does not implement 'icicle' | 192 | +| no such trace: XY does not implement 'scatterpolar' | 187 | +| no such trace: XY does not implement 'scattercarpet' | 184 | +| no such trace: XY does not implement 'scatterternary' | 184 | +| no such trace: XY does not implement 'surface' | 183 | +| no such trace: XY does not implement 'histogram2dcontour' | 182 | +| no such trace: XY does not implement 'scattersmith' | 182 | +| no such trace: XY does not implement 'scattergeo' | 180 | +| no such trace: XY does not implement 'sunburst' | 177 | + +`unclassified` is the honest residue: attributes no committed rule in +`scripts/plotly_schema_coverage.py` claims. It is reported rather than +folded into a friendlier bucket, and shrinking it is how this table +improves. + +## Reproducing + +```bash +uv run --extra bench python scripts/plotly_schema_coverage.py --check +``` + +`--check` fails if this file is stale. The rules it applies are in the +`RULES` table of that script, one line per claim, so any row here can be +traced to the claim that produced it. diff --git a/spec/design-dossier.md b/spec/design-dossier.md index 2e0e225b..3d2d93cd 100644 --- a/spec/design-dossier.md +++ b/spec/design-dossier.md @@ -650,7 +650,7 @@ missing entirely.* | 10 | Autorange is an O(n) full scan per update | Moderate | Chunk zone maps make it O(chunks) (§22) | | 11 | No VRAM budget/eviction; no device-loss recovery | Moderate | Byte-budgeted caches; rebuildable GPU state (§6, §18) | | 12 | No bundle-size budget — a fat WASM blob forfeits a real Plotly pain point (3.5 MB+) | Moderate | Feature-gated modules + CI size budget (§23) | -| 13 | Compat shim scope unquantified (~3,000 Plotly schema attributes) | Moderate | Generated conformance suite + explicit degradation contract (§24) | +| 13 | Compat shim scope unquantified (~3,000 Plotly schema attributes) | Moderate | **Quantified**: 9,472 leaf attributes, classified by committed rules (§24, `spec/api/plotly-coverage.md`). The estimate was 3x low. | | 14 | Benchmarks measured throughput but not interaction latency; "60fps" undefined | Minor | Latency budgets + p99 framing added (§17, §12) | | 15 | No extensibility story (Plotly has custom traces) | Minor | **Shipped v0**: composition mark plugins, `xy.register_mark` (§24). Custom shaders still deferred. | @@ -902,6 +902,29 @@ partial-bundle pain). Neither exists today. `supported | mapped-with-difference | unsupported`, the shim **warns loudly** on unsupported attributes (never silently drops), and the docs publish the coverage table per release. "Drop-in for the common 80%" becomes checkable, not vibes. + + **Shipped, with two amendments the original wording did not anticipate** + (`scripts/plotly_schema_coverage.py` → `spec/api/plotly-coverage.md`): + + 1. *No published Plotly wheel ships `plot-schema.json`.* Verified against + 4.14.3, 5.24.1, and 6.9.0 — it is a plotly.js artifact, and the Python + package generates validator classes from it instead. The equivalent, and a + better shape for this job, is `plotly/validators/_validators.json`: one flat + entry per dotted path. **9,472 leaf attributes** once compound namespaces + and `*src` column-reference companions come out — three times the estimate. + 2. *There is no Plotly shim to warn.* The shipped shim is `xy.pyplot`, which is + Matplotlib-flavored. So "supported" cannot mean "the shim accepts the + attribute"; it means XY can express the same visual outcome, decided by a + committed rule table rather than by hand — 9,472 attributes cannot be + audited one at a time, and a table claiming otherwise would be fiction. + + The number, scoped to the trace types XY implements plus `layout`: **344 + supported, 126 mapped-with-difference of 3,387**. Whole-schema it is 345 and + 126 of 9,472, a denominator dominated by trace types XY does not implement. + Both are published, because either one quoted alone misleads in a different + direction. Attributes no rule claims land in `unsupported/unclassified` — + currently 2,190 — which is reported rather than folded somewhere friendlier; + shrinking it is how the table improves. - **Custom traces without forking:** a registered *mark plugin* provides (a) a calc function over columns → columns (runs in the worker, gets zone maps), (b) either a composition of built-in GPU primitives (instanced marks, density diff --git a/tests/test_plotly_schema_coverage.py b/tests/test_plotly_schema_coverage.py new file mode 100644 index 00000000..52d78fd7 --- /dev/null +++ b/tests/test_plotly_schema_coverage.py @@ -0,0 +1,99 @@ +"""The Plotly coverage number has to be reproducible, and honest about its tail. + +§24 asks for compat to be "a measured number". A measured number that anyone +can inflate by widening a rule without noticing is not one, so these tests hold +the properties that make the number mean something: it is regenerated rather +than hand-written, its `unclassified` residue is reported rather than absorbed, +and the classifier stays deterministic. + +The suite skips when plotly is absent — it lives in the `bench` extra, and the +committed report is what a reader without it consults. +""" + +from __future__ import annotations + +import importlib.util +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "scripts" / "plotly_schema_coverage.py" +REPORT = ROOT / "spec" / "api" / "plotly-coverage.md" + +pytestmark = pytest.mark.skipif( + importlib.util.find_spec("plotly") is None, + reason="plotly is in the bench extra; the committed report stands in without it", +) + + +def _module(): + spec = importlib.util.spec_from_file_location("plotly_schema_coverage", SCRIPT) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def test_the_committed_report_is_regenerated_not_hand_edited() -> None: + result = subprocess.run( + [sys.executable, str(SCRIPT), "--check"], capture_output=True, text=True + ) + assert result.returncode == 0, result.stdout + result.stderr + + +def test_src_companions_and_namespaces_are_out_of_the_denominator() -> None: + # `marker.colorsrc` is not a second styling decision beside `marker.color`, + # and a compound node is a namespace rather than a knob. Counting either + # would pad the denominator in XY's favour. + module = _module() + schema, _ = module.load_schema() + paths = module.attribute_paths(schema) + assert not any(path.endswith("src") for path in paths) + assert all( + schema[path]["superclass"] not in {"CompoundValidator", "CompoundArrayValidator"} + for path in paths + ) + + +def test_unclassified_is_reported_rather_than_absorbed() -> None: + # The residue is large and shrinking it is the work. Folding it into a + # friendlier bucket would make the table look better and mean less. + module = _module() + schema, version = module.load_schema() + _, summary = module.build_report(schema, version) + assert summary["unsupported_reasons"]["unclassified"] > 0 + assert "unclassified" in REPORT.read_text(encoding="utf-8") + + +def test_every_verdict_is_one_of_the_three_section_24_names() -> None: + module = _module() + schema, version = module.load_schema() + _, summary = module.build_report(schema, version) + assert set(summary["verdicts"]) <= { + module.SUPPORTED, + module.MAPPED, + module.UNSUPPORTED, + } + + +def test_classification_is_deterministic_and_first_rule_wins() -> None: + module = _module() + # A path matched by a specific rule must not fall through to a general one. + verdict, surface, _ = module.classify("scatter.marker.symbol") + assert verdict == module.SUPPORTED and "marker-shape" in surface + # A trace XY does not implement is unsupported regardless of the attribute. + verdict, _, reason = module.classify("scatter3d.marker.symbol") + assert verdict == module.UNSUPPORTED and "no such trace" in reason + + +def test_the_scoped_number_is_reported_next_to_the_whole_schema_one() -> None: + # Quoting 3.6% without its scope understates; quoting 10.2% without its + # scope overstates. The report has to carry both. + text = REPORT.read_text(encoding="utf-8") + assert "## Whole schema" in text + assert "## Scoped to the trace types XY implements" in text + assert "must be quoted *with* its scope" in text