From cc0fab7ab2614f923005a3c0a60f5b2b5509982f Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Tue, 4 Aug 2026 13:15:30 -0700 Subject: [PATCH 1/2] Stage the styling contract: compatibility= modes and the engine collision table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every image-export API (to_png, to_svg, to_image, write_image, and the write_images batch, at chart, figure, and module level) accepts compatibility=: "legacy" preserves today's behavior exactly, "warn" surfaces every declaration the export would drop as one StyleCompatibilityWarning, and "strict" raises StyleCompatibilityError before emission with the full preflight report attached. "lossless" is reserved and rejected: accepting the name before Engine.auto can actually re-route on preflight evidence would make it a lie. The default path pays one string comparison. The literal-"legacy" short-circuit returns before the preflight machinery is even imported, and export's StyleCompatibilityError/Warning aliases resolve through a module __getattr__, so importing xy.export stays exactly as heavy as it was before the modes existed (the preflight chain reaches the native library through the writers' constants — eagerly aliasing it would have added the dylib load to a module import that never had one). warn/strict pay the routing walk only when the chart carries class or per-slot declarations and the engine is native. Engine selection and compatibility stay orthogonal, and an explicit engine is a hard constraint: strict refuses on the pinned engine rather than re-routing to Chromium, a Chromium pin renders the full cascade and gives every mode nothing to do, and resolution errors (custom_css with a pinned native engine, Chromium SVG) precede and outrank mode logic in every mode. A strict batch fails whole while the plan is resolved, before any file is written. HTML export rejects a non-default mode like the other options it cannot honor, because a document that renders the full cascade has nothing to check. State-gated chrome never trips warn or strict in a clean static export: a file with no tooltip has dropped nothing by not styling one — the applicable-slot contract from the preflight change, now enforced rather than only reported. spec/process/style-compatibility-migration.md names the release each default flips in — warn in 0.0.7, strict at the 0.1.0 minor boundary, legacy removed in 0.2.0 — per the rule that a deprecation window is named when it opens, not "a future release". Facet grids deliberately do not accept the option yet rather than half-honoring it; their per-panel preflight is tracked there too. --- CHANGELOG.md | 9 + python/xy/_figure.py | 15 +- python/xy/components.py | 23 ++- python/xy/export.py | 61 +++++- python/xy/styling/preflight.py | 152 ++++++++++++--- spec/api/export.md | 9 +- spec/process/style-compatibility-migration.md | 64 +++++++ tests/test_components.py | 4 + tests/test_style_compatibility_modes.py | 173 ++++++++++++++++++ 9 files changed, 479 insertions(+), 31 deletions(-) create mode 100644 spec/process/style-compatibility-migration.md create mode 100644 tests/test_style_compatibility_modes.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 683641b5..995018de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,15 @@ in the README). ## [Unreleased] ### Added +- Every image-export API (`to_png`, `to_svg`, `to_image`, `write_image`, + `export.write_images`) accepts `compatibility=`: `"legacy"` (default — + behavior unchanged), `"warn"` (one `StyleCompatibilityWarning` naming each + declaration the export would drop), or `"strict"` + (`StyleCompatibilityError` before emission, preflight report attached). + Modes never re-route an explicit engine; `"lossless"` is reserved and + rejected until preflight routing exists. The default flips only on the + published schedule in `spec/process/style-compatibility-migration.md` + (warn in 0.0.7, strict in 0.1.0, legacy removed in 0.2.0). - `chart.style_compatibility_report(target=..., engine=..., custom_css=...)`: a report-only export preflight that routes every declared slot style into `survives`, `native-subset` (naming the kept and lost properties per diff --git a/python/xy/_figure.py b/python/xy/_figure.py index af80981f..4c1bde65 100644 --- a/python/xy/_figure.py +++ b/python/xy/_figure.py @@ -2241,13 +2241,17 @@ def to_svg( *, width: Optional[int] = None, height: Optional[int] = None, + compatibility: str = "legacy", ) -> str: """Static SVG (_svg.py): a pure-Python render of the same decimated payload the browser client consumes — resolution-independent, tiny (screen-bounded regardless of source size), and dependency-free. - `width`/`height` override the figure's pixel size.""" + `width`/`height` override the figure's pixel size. `compatibility` + stages the styling contract: "warn" surfaces any declaration this + vector export would drop, "strict" refuses to drop one.""" from . import _svg + export._enforce_compatibility(self, "svg", "native", None, compatibility) return _svg.to_svg(self, path, width=width, height=height) def to_png( @@ -2262,6 +2266,7 @@ def to_png( custom_css: Optional[str] = None, sandbox: bool = True, gl: str = "software", + compatibility: str = "legacy", ) -> bytes: """Static PNG (export.py). `engine=Engine.default` paints the decimated payload with the built-in Rust rasterizer — no browser, @@ -2283,6 +2288,7 @@ def to_png( custom_css=custom_css, sandbox=sandbox, gl=gl, + compatibility=compatibility, ) def to_image( @@ -2299,13 +2305,15 @@ def to_image( custom_css: Optional[str] = None, sandbox: bool = True, gl: str = "software", + compatibility: str = "legacy", ) -> bytes: """Unified static export: PNG/JPEG/WebP/SVG/PDF bytes (export.py). `engine=Engine.auto` is deterministic — the browser-free native path for every format, Chromium only when `custom_css` needs a real CSS engine. See `export.to_image` for the format, quality, and background - policies.""" + policies, and `compatibility=` ("legacy"/"warn"/"strict") for the + staged styling contract.""" return export.to_image( self, format, @@ -2319,6 +2327,7 @@ def to_image( custom_css=custom_css, sandbox=sandbox, gl=gl, + compatibility=compatibility, ) def write_image( @@ -2336,6 +2345,7 @@ def write_image( custom_css: Optional[str] = None, sandbox: bool = True, gl: str = "software", + compatibility: str = "legacy", ) -> bytes: """Atomic file export with extension-inferred format (export.py): .png/.jpg/.jpeg/.webp/.svg/.pdf, plus .html routing to `to_html`.""" @@ -2353,6 +2363,7 @@ def write_image( custom_css=custom_css, sandbox=sandbox, gl=gl, + compatibility=compatibility, ) def memory_report(self) -> dict[str, Any]: diff --git a/python/xy/components.py b/python/xy/components.py index cdc3934c..dcafe490 100644 --- a/python/xy/components.py +++ b/python/xy/components.py @@ -4196,9 +4196,15 @@ def to_svg( *, width: Optional[int] = None, height: Optional[int] = None, + compatibility: str = "legacy", ) -> str: - """A static SVG render of the chart (written to ``path`` if given).""" - return self.figure().to_svg(path, width=width, height=height) + """A static SVG render of the chart (written to ``path`` if given). + + ``compatibility`` stages the styling contract: ``"warn"`` surfaces + any declaration this export would drop, ``"strict"`` refuses to drop + one; the default preserves current behavior. + """ + return self.figure().to_svg(path, width=width, height=height, compatibility=compatibility) def to_png( self, @@ -4212,12 +4218,14 @@ def to_png( custom_css: Optional[str] = None, sandbox: bool = True, gl: str = "software", + compatibility: str = "legacy", ) -> bytes: """A PNG render of the chart, returned as bytes. ``scale`` multiplies the pixel density; ``engine`` picks the raster path (native or headless Chromium). Written to ``path`` - when given. + when given. ``compatibility`` stages the styling contract + (``"legacy"``/``"warn"``/``"strict"``). """ return self.figure().to_png( path, @@ -4229,6 +4237,7 @@ def to_png( custom_css=custom_css, sandbox=sandbox, gl=gl, + compatibility=compatibility, ) def _export_defaults( @@ -4278,12 +4287,14 @@ def to_image( custom_css: Optional[str] = None, sandbox: bool = True, gl: str = "software", + compatibility: str = "legacy", ) -> bytes: """Unified static export: PNG/JPEG/WebP/SVG/PDF bytes. Omitted width/height/scale/background/quality fall back to the chart's `export_config` defaults; explicit arguments override them. - See `export.to_image` for the full format/engine/background policy.""" + See `export.to_image` for the full format/engine/background policy + and `compatibility=` for the staged styling contract.""" fmt = export._normalize_format(format) resolved = export._resolve_image_engine(engine, fmt, custom_css) return self.figure().to_image( @@ -4293,6 +4304,7 @@ def to_image( custom_css=custom_css, sandbox=sandbox, gl=gl, + compatibility=compatibility, **self._export_defaults( fmt, width, @@ -4319,6 +4331,7 @@ def write_image( custom_css: Optional[str] = None, sandbox: bool = True, gl: str = "software", + compatibility: str = "legacy", ) -> bytes: """Atomic file export with extension-inferred format (.png/.jpg/ .jpeg/.webp/.svg/.pdf/.html). `export_config` defaults apply as in @@ -4356,6 +4369,7 @@ def write_image( custom_css=custom_css, sandbox=sandbox, gl=gl, + compatibility=compatibility, ) return self.figure().write_image( path, @@ -4365,6 +4379,7 @@ def write_image( custom_css=custom_css, sandbox=sandbox, gl=gl, + compatibility=compatibility, **defaults, ) diff --git a/python/xy/export.py b/python/xy/export.py index f2f3dec1..dc124292 100644 --- a/python/xy/export.py +++ b/python/xy/export.py @@ -43,6 +43,19 @@ class Engine(StrEnum): chromium = "chromium" +def __getattr__(name: str) -> object: + # StyleCompatibilityError / StyleCompatibilityWarning are catchable from + # the module users already import for `Engine`, but resolved lazily: the + # preflight chain reaches the native library via the writers' constants, + # and importing this module must stay exactly as heavy as it was before + # the compatibility modes existed. + if name in ("StyleCompatibilityError", "StyleCompatibilityWarning"): + from .styling import preflight as _preflight + + return getattr(_preflight, name) + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + # Warn above this payload size; base64 carries a stated ~33% tax (§29). EMBED_WARN_BYTES = 64 * 2**20 @@ -649,6 +662,7 @@ def write_images( custom_css: Optional[str] = None, sandbox: bool = True, gl: str = "software", + compatibility: str = "legacy", ) -> list[bytes]: """Export many figures through ONE amortized pipeline (mixed formats OK). @@ -665,7 +679,10 @@ def write_images( exactly as in `Chart.to_image`. Writes are atomic per file; on error, files already exported remain. Other options match `to_image`; quality applies to JPEG and Chromium WebP and is ignored by the other formats - (native WebP stays lossless), so mixed batches stay ergonomic.""" + (native WebP stays lossless), so mixed batches stay ergonomic. + `compatibility=` applies per figure while the plan is resolved, so a + strict batch refuses whole — before any file is written — rather than + after a partial export.""" if figures is not None: if figs is not None: raise ValueError("pass figs positionally or figures=, not both") @@ -709,6 +726,9 @@ def write_images( plan.append((fig, path, fmt, "html", {}, None, None)) continue resolved = _resolve_image_engine(engine, fmt, custom_css) + # Per figure, up front with the rest of the plan: a strict batch + # fails whole before any file is written, never after a partial one. + _enforce_compatibility(fig, fmt, resolved, custom_css, compatibility) if callable(getattr(obj, "_export_defaults", None)): settings = obj._export_defaults( fmt, @@ -792,6 +812,7 @@ def to_png( custom_css: Optional[str] = None, sandbox: bool = True, gl: str = "software", + compatibility: str = "legacy", ) -> bytes: """Rasterize `fig` to a PNG (bytes, optionally saved). @@ -819,6 +840,7 @@ def to_png( optimize = _bool_option(optimize, "PNG optimize") sandbox = _bool_option(sandbox, "PNG sandbox") resolved_engine = _png_engine(engine) + _enforce_compatibility(fig, "png", resolved_engine, custom_css, compatibility) if resolved_engine == "native": if custom_css is not None: raise ValueError("custom_css requires engine=Engine.chromium") @@ -899,6 +921,35 @@ def _infer_format(path: str | PathLike[str]) -> str: ) from None +def _enforce_compatibility( + fig: "Figure", + fmt: str, + resolved_engine: str, + custom_css: Optional[str], + compatibility: str, +) -> None: + """Apply the staged compatibility mode to one already-resolved export. + + The literal-"legacy" short-circuit is the whole performance contract: + the default export path does one string comparison and never imports the + preflight machinery. Everything else — mode validation, the constant-time + unstyled path, warning versus refusing — lives in + `styling.preflight.enforce`. Modes never re-route an engine; they decide + whether to proceed, warn, or refuse on the engine the caller resolved. + """ + if compatibility == "legacy": + return + from .styling import preflight as _preflight + + _preflight.enforce( + fig, + fmt=fmt, + resolved_engine=resolved_engine, + custom_css=custom_css, + compatibility=compatibility, + ) + + def _resolve_image_engine(engine: object, fmt: str, custom_css: Optional[str]) -> str: """Deterministic engine selection: -> "native" | "browser". @@ -1137,6 +1188,7 @@ def to_image( custom_css: Optional[str] = None, sandbox: bool = True, gl: str = "software", + compatibility: str = "legacy", ) -> bytes: """Render `fig` to image bytes in the requested `format`. @@ -1154,6 +1206,7 @@ def to_image( bounded rasters (the documented hybrid-vector policy).""" fmt = _normalize_format(format) resolved_engine = _resolve_image_engine(engine, fmt, custom_css) + _enforce_compatibility(fig, fmt, resolved_engine, custom_css, compatibility) quality = _validated_quality(quality, fmt, resolved_engine) background = _validated_background(background, fmt) w, h = _export_dimensions(fig, width, height) @@ -1201,6 +1254,7 @@ def write_image( custom_css: Optional[str] = None, sandbox: bool = True, gl: str = "software", + compatibility: str = "legacy", ) -> bytes: """Export `fig` to `path`, inferring the format from the extension. @@ -1221,6 +1275,10 @@ def write_image( ("background", background, None), ("quality", quality, None), ("optimize", optimize, False), + # HTML renders the full cascade in the browser — nothing can + # drop, so a compatibility mode has nothing to check and is + # rejected like the other options that cannot apply. + ("compatibility", compatibility, "legacy"), ) if value != default ] @@ -1246,6 +1304,7 @@ def write_image( custom_css=custom_css, sandbox=sandbox, gl=gl, + compatibility=compatibility, ) _atomic_write_bytes(path, data) return data diff --git a/python/xy/styling/preflight.py b/python/xy/styling/preflight.py index d6d3d523..04d928fe 100644 --- a/python/xy/styling/preflight.py +++ b/python/xy/styling/preflight.py @@ -39,6 +39,32 @@ ROUTE_BROWSER_ONLY = "browser-only" ROUTE_STATE_GATED = "state-gated" +#: The staged compatibility modes, in rollout order. `legacy` is today's +#: behavior and the default; `warn` surfaces every loss as one +#: `StyleCompatibilityWarning`; `strict` refuses to emit bytes that drop a +#: declaration. "lossless" is reserved for the phase that lets `Engine.auto` +#: re-route on preflight evidence — accepting it before that phase would make +#: the name a lie, so it is rejected now. The default flips on the schedule in +#: `spec/process/style-compatibility-migration.md`, never silently. +COMPATIBILITY_MODES: tuple[str, ...] = ("legacy", "warn", "strict") + + +class StyleCompatibilityWarning(UserWarning): + """An export in `compatibility="warn"` mode dropped declared styling.""" + + +class StyleCompatibilityError(ValueError): + """An export in `compatibility="strict"` mode refused to drop styling. + + Carries the full preflight `report`; the message is its `explain()` plus + the ways out, so the fix is in the traceback rather than a docs hunt. + """ + + def __init__(self, message: str, report: StyleCompatibilityReport) -> None: + super().__init__(message) + self.report = report + + _RASTER_FORMATS = frozenset({"png", "jpeg", "webp"}) _VECTOR_FORMATS = frozenset({"svg", "pdf"}) @@ -238,39 +264,28 @@ def _styles_finding(slot: str, decls: dict[str, Any], fmt: str) -> SlotFinding: ) -def preflight( +def route_resolved( figure: Figure, *, - target: str = "png", - engine: object = None, + fmt: str, + resolved_engine: str, custom_css: Optional[str] = None, ) -> StyleCompatibilityReport: - """Route every declared style for one export target, without exporting. + """The routing core, for callers that already resolved format and engine. - `engine` accepts the same values as the export APIs (`Engine`, its string - aliases, or None for auto). The report mirrors the export path's actual - behavior, including its refusals — it never predicts a different outcome - than running the export would produce. + The export paths call this with their own resolution result so the + resolver (and its deprecation warnings) runs exactly once per export; + `preflight()` wraps it with a resolution of its own for standalone use. """ - fmt, resolved, error = _resolve(target, engine, custom_css) sources = _sources(figure, custom_css) - if error: - return StyleCompatibilityReport( - target=fmt, - engine=resolved, - sources=sources, - lossless=False, - losses=(error,), - error=error, - ) - if resolved == "browser": + if resolved_engine == "browser": # The live client renders the full cascade; nothing can drop. - return StyleCompatibilityReport(target=fmt, engine=resolved, sources=sources) + return StyleCompatibilityReport(target=fmt, engine=resolved_engine, sources=sources) if not (figure.class_names or figure.chrome_styles): # The constant-time path: chart-level `style=` and mark/axis `style=` # are full in every renderer (see the capability matrix), so with no # class or per-slot declarations there is nothing that can drop. - return StyleCompatibilityReport(target=fmt, engine=resolved, sources=sources) + return StyleCompatibilityReport(target=fmt, engine=resolved_engine, sources=sources) findings: list[SlotFinding] = [] for slot in figure.class_names: @@ -287,7 +302,7 @@ def preflight( ) return StyleCompatibilityReport( target=fmt, - engine=resolved, + engine=resolved_engine, sources=sources, findings=tuple(findings), losses=losses, @@ -295,12 +310,105 @@ def preflight( ) +def preflight( + figure: Figure, + *, + target: str = "png", + engine: object = None, + custom_css: Optional[str] = None, +) -> StyleCompatibilityReport: + """Route every declared style for one export target, without exporting. + + `engine` accepts the same values as the export APIs (`Engine`, its string + aliases, or None for auto). The report mirrors the export path's actual + behavior, including its refusals — it never predicts a different outcome + than running the export would produce. + """ + fmt, resolved, error = _resolve(target, engine, custom_css) + if error: + return StyleCompatibilityReport( + target=fmt, + engine=resolved, + sources=_sources(figure, custom_css), + lossless=False, + losses=(error,), + error=error, + ) + return route_resolved(figure, fmt=fmt, resolved_engine=resolved, custom_css=custom_css) + + +def validate_compatibility(value: object) -> str: + """The mode, or a loud error naming the vocabulary (and the reserved word).""" + if isinstance(value, str) and value in COMPATIBILITY_MODES: + return value + if value == "lossless": + raise ValueError( + 'compatibility="lossless" is reserved for the lossless-routing phase ' + "(spec/process/style-compatibility-migration.md); until it can re-route " + f"engines on preflight evidence, pick one of {COMPATIBILITY_MODES}" + ) + raise ValueError(f"compatibility must be one of {COMPATIBILITY_MODES}, got {value!r}") + + +def enforce( + figure: Figure, + *, + fmt: str, + resolved_engine: str, + custom_css: Optional[str], + compatibility: str, +) -> None: + """Apply a compatibility mode to one already-resolved export. + + `legacy` returns before doing anything at all — the default path pays + zero. `warn` and `strict` pay the preflight only when the chart carries + class or per-slot declarations and the engine is native; a lossless + report also returns quietly. Engines are never re-routed here: the mode + decides whether to proceed, warn, or refuse — never where to render + (spec/process/style-compatibility-migration.md pins that contract). + """ + import warnings + + mode = validate_compatibility(compatibility) + if mode == "legacy": + return + if resolved_engine == "browser" or not (figure.class_names or figure.chrome_styles): + return + report = route_resolved(figure, fmt=fmt, resolved_engine=resolved_engine, custom_css=custom_css) + if report.lossless: + return + if mode == "warn": + warnings.warn( + StyleCompatibilityWarning( + f"this {fmt} export drops declared styling — " + + "; ".join(report.losses) + + " — chart.style_compatibility_report() has the full routing; " + "engine=Engine.chromium or to_html() keeps everything" + ), + stacklevel=2, + ) + return + raise StyleCompatibilityError( + report.explain() + "\nstrict compatibility refuses to drop declared styling; keep it with " + "engine=Engine.chromium or to_html(), move it into chart/mark style= or a " + 'supported styles= subset, or export with compatibility="warn" during ' + "migration", + report, + ) + + __all__ = [ + "COMPATIBILITY_MODES", "ROUTE_BROWSER_ONLY", "ROUTE_STATE_GATED", "ROUTE_SUBSET", "ROUTE_SURVIVES", "SlotFinding", + "StyleCompatibilityError", "StyleCompatibilityReport", + "StyleCompatibilityWarning", + "enforce", "preflight", + "route_resolved", + "validate_compatibility", ] diff --git a/spec/api/export.md b/spec/api/export.md index 1cf8134a..5deb2dd9 100644 --- a/spec/api/export.md +++ b/spec/api/export.md @@ -263,8 +263,13 @@ into exactly one of four outcomes — `survives`, `native-subset` (with the kept and lost property names), `browser-only`, or `state-gated` — and mirrors the export path's refusals (`custom_css` with a pinned native engine, Chromium SVG) rather than re-deciding them. It is report-only: computing it never -changes an export. The staged `compatibility=` modes that act on the report -are a separate, later contract. +changes an export. The staged `compatibility=` modes act on this report: +`legacy` (the default — today's behavior, one string comparison of cost), +`warn` (one `StyleCompatibilityWarning` naming every loss), and `strict` +(`StyleCompatibilityError` before emission, report attached). `"lossless"` +is reserved and rejected until preflight routing exists. Modes never +re-route an explicit engine, and the default only flips on the schedule in +`spec/process/style-compatibility-migration.md`. Two properties are load-bearing. First, the report is constant-time when there is nothing to route: no `class_names`, no per-slot `styles`, and no diff --git a/spec/process/style-compatibility-migration.md b/spec/process/style-compatibility-migration.md new file mode 100644 index 00000000..879ab1ea --- /dev/null +++ b/spec/process/style-compatibility-migration.md @@ -0,0 +1,64 @@ +# Style-compatibility migration + +The staged path from "native exports silently drop `class_names`" to "no +renderer drops a declaration without saying so" — with the release each step +ships in named now, so none of them can quietly become permanent. The +programmatic foundation is `chart.style_compatibility_report()` +(`spec/api/export.md` §9) and the `compatibility=` export option. + +## The modes + +| Mode | Behavior | Cost on the unstyled path | +| --- | --- | --- | +| `legacy` | Exactly today's behavior: browser-only declarations drop silently from native exports. | One string comparison; the preflight machinery is not even imported. | +| `warn` | Every export that would drop a declaration emits one `StyleCompatibilityWarning` naming each loss. Bytes are still produced. | Zero for charts with no `class_names`/`styles`/`custom_css` (constant-time early-out). | +| `strict` | An export that would drop a declaration raises `StyleCompatibilityError` **before emission**, carrying the full preflight report and the ways out. | Same early-out as `warn`. | +| `lossless` | **Reserved, rejected today.** Arrives with the preflight-routing phase, where `Engine.auto` may choose a different lossless route on report evidence. Accepting the name before the routing exists would make it a lie. | — | + +State-gated chrome never trips `warn`/`strict` in a clean static export: a +file with no tooltip has dropped nothing by not styling one (the +applicable-slot contract, `spec/api/export.md` §9). + +## The engine contract + +Engine selection and compatibility are orthogonal, and **an explicit engine +is a hard constraint — no compatibility mode may re-route it**: + +| Request | Behavior | +| --- | --- | +| `compatibility="strict"`, explicit engine | Stay on the pinned engine; fail before emission on every unsupported declaration. | +| `compatibility="warn"` or `"legacy"`, explicit engine | Stay on the pinned engine; warn, or preserve legacy behavior, respectively. | +| any mode, `engine=Engine.chromium` (or resolved browser) | The live client renders the full cascade; nothing can drop, so the mode has nothing to do. | +| any mode, `custom_css` with a pinned native engine | Today's `ValueError` fires unchanged — resolution errors precede and outrank mode logic. | +| future `"lossless"`, `engine=Engine.auto` | Native only when preflight proves lossless; otherwise Chromium where the format supports it; otherwise raise. | +| future `"lossless"`, pinned engine | Never overridden: raise "cannot satisfy lossless natively; supply a snapshot/stylesheet or unpin the engine." | + +`Engine.auto`'s current rule — native for every format, Chromium only when +`custom_css` needs a real CSS engine — is unchanged until the lossless phase, +and changing it then requires prominent release notes (speed, determinism, +dependency, and security posture all shift with an engine). + +## The schedule + +Pre-1.0, minor versions may break (README stability table); these are the +concrete releases each default flips in. Moving a step **later** needs only a +changelog note; moving one **earlier** is a breaking change and needs the +same notice a breaking release gets. + +| Release | Change | +| --- | --- | +| 0.0.6 | `compatibility=` ships, default `legacy`. `warn`/`strict` are opt-in. Docs recommend `warn`. | +| 0.0.7 | Default flips to `warn`: silent drops end. `legacy` silences per call site. | +| 0.1.0 | Default flips to `strict` at the minor boundary. `warn` and `legacy` remain as opt-outs. | +| 0.2.0 | `legacy` is **removed** (per the Phase-0 rule that the removal release is named at announcement). `warn` remains indefinitely as the non-fatal mode. | +| lossless phase | `"lossless"` unreserved once preflight routing exists; `Engine.auto` may then take the lossless route by default, behind its own release note. | + +## Out of scope here, tracked + +- **Facet grids** keep `legacy` behavior regardless of the option until their + per-panel preflight lands; `FacetGrid` export does not accept + `compatibility=` yet rather than accepting and half-honoring it. +- The **resolved-style snapshot** (shared IR) makes the legend slot's + declaration-level qualification property-exact; until then `strict` does + not fail on legend box properties it cannot prove either way (§28: unsure + is said out loud, not rounded to an error). diff --git a/tests/test_components.py b/tests/test_components.py index 5e0007cb..c5fac5a3 100644 --- a/tests/test_components.py +++ b/tests/test_components.py @@ -1982,6 +1982,7 @@ def fake_to_png( custom_css=None, sandbox=True, gl="software", + compatibility="legacy", ): seen.update( { @@ -1995,6 +1996,7 @@ def fake_to_png( "custom_css": custom_css, "sandbox": sandbox, "gl": gl, + "compatibility": compatibility, } ) return b"PNG" @@ -2011,6 +2013,7 @@ def fake_to_png( custom_css=".chart { color: rebeccapurple; }", sandbox=False, gl="hardware", + compatibility="warn", ) assert data == b"PNG" @@ -2025,6 +2028,7 @@ def fake_to_png( "custom_css": ".chart { color: rebeccapurple; }", "sandbox": False, "gl": "hardware", + "compatibility": "warn", } diff --git a/tests/test_style_compatibility_modes.py b/tests/test_style_compatibility_modes.py new file mode 100644 index 00000000..a3db4f30 --- /dev/null +++ b/tests/test_style_compatibility_modes.py @@ -0,0 +1,173 @@ +"""The staged `compatibility=` modes: legacy is untouched, warn says every +loss out loud once, strict refuses before emission, and no mode ever +re-routes an explicit engine (spec/process/style-compatibility-migration.md). +""" + +from __future__ import annotations + +import warnings + +import pytest + +import xy +from xy import export +from xy.styling.preflight import ( + StyleCompatibilityError, + StyleCompatibilityWarning, + validate_compatibility, +) + + +def _chart(**props): + return xy.scatter_chart(xy.scatter([1.0, 2.0, 3.0], [2.0, 1.0, 3.0]), **props) + + +def _lossy_chart(): + return _chart(class_names={"legend": "bg-slate-900"}) + + +# -- vocabulary -------------------------------------------------------------- + + +def test_unknown_modes_fail_loudly_and_lossless_is_reserved() -> None: + with pytest.raises(ValueError, match="legacy"): + _chart().to_png(compatibility="Legacy") + with pytest.raises(ValueError, match="reserved"): + _lossy_chart().to_png(compatibility="lossless") + # An invalid mode fails even when nothing could drop: vocabulary errors + # must not depend on what happens to be styled. + with pytest.raises(ValueError, match="compatibility"): + _chart().to_png(compatibility="stricted") + assert validate_compatibility("warn") == "warn" + + +# -- legacy: byte-identical, zero machinery ---------------------------------- + + +def test_legacy_output_is_byte_identical_to_the_default() -> None: + chart = _lossy_chart() + assert chart.to_png() == chart.to_png(compatibility="legacy") + assert chart.to_svg() == chart.to_svg(compatibility="legacy") + + +def test_legacy_never_warns() -> None: + with warnings.catch_warnings(): + warnings.simplefilter("error", StyleCompatibilityWarning) + _lossy_chart().to_png() + _lossy_chart().to_png(compatibility="legacy") + + +# -- warn -------------------------------------------------------------------- + + +def test_warn_names_each_loss_once_and_still_emits_bytes() -> None: + with pytest.warns(StyleCompatibilityWarning, match=r"class_names\['legend'\]") as caught: + data = _lossy_chart().to_png(compatibility="warn") + assert data[:8] == b"\x89PNG\r\n\x1a\n" + assert len([w for w in caught if w.category is StyleCompatibilityWarning]) == 1 + + +def test_warn_is_silent_when_nothing_drops() -> None: + with warnings.catch_warnings(): + warnings.simplefilter("error", StyleCompatibilityWarning) + # Unstyled: the constant-time early-out. + _chart().to_png(compatibility="warn") + # State-gated only: a clean static file contains no tooltip to lose. + _chart(styles={"tooltip": {"color": "red"}}).to_png(compatibility="warn") + # Vector keeps the full text subset this declaration uses. + _chart(styles={"tick_label": {"letter_spacing": "0.08em"}}).to_svg(compatibility="warn") + + +def test_warn_applies_to_the_vector_writers_too() -> None: + with pytest.warns(StyleCompatibilityWarning, match="tick_label"): + _chart(styles={"tick_label": {"letter_spacing": "0.08em"}}).to_png(compatibility="warn") + + +# -- strict ------------------------------------------------------------------ + + +def test_strict_refuses_before_emission_with_the_report_attached(tmp_path) -> None: + target = tmp_path / "chart.png" + with pytest.raises(StyleCompatibilityError) as excinfo: + _lossy_chart().write_image(target, compatibility="strict") + assert not target.exists(), "strict must fail before any bytes are written" + report = excinfo.value.report + assert not report.lossless + assert any(f.slot == "legend" for f in report.findings) + assert "chromium" in str(excinfo.value).lower() + + +def test_strict_passes_lossless_exports_untouched() -> None: + chart = _chart(styles={"title": {"font-size": 18}}) + assert chart.to_png(compatibility="strict") == chart.to_png() + assert chart.to_svg(compatibility="strict") == chart.to_svg() + + +def test_strict_batch_fails_whole_before_any_file(tmp_path) -> None: + clean, lossy = _chart(), _lossy_chart() + paths = [tmp_path / "a.png", tmp_path / "b.png"] + with pytest.raises(StyleCompatibilityError): + export.write_images([clean, lossy], [str(p) for p in paths], compatibility="strict") + assert not any(p.exists() for p in paths) + + +# -- the engine contract ----------------------------------------------------- + + +def test_no_mode_reroutes_an_explicit_engine() -> None: + # Chromium pin + lossy styling: the browser renders the cascade, so every + # mode proceeds without warning or error — and none of them may fall back + # to native. Native pin + lossy styling: strict refuses rather than + # re-routing to Chromium. + chart = _lossy_chart() + if export.find_chromium() is not None: + with warnings.catch_warnings(): + warnings.simplefilter("error", StyleCompatibilityWarning) + data = chart.to_png(engine=export.Engine.chromium, compatibility="strict") + assert data[:8] == b"\x89PNG\r\n\x1a\n" + with pytest.raises(StyleCompatibilityError): + chart.to_png(engine=export.Engine.default, compatibility="strict") + + +def test_resolution_errors_precede_and_outrank_mode_logic() -> None: + # custom_css with a pinned native engine raises today's ValueError in + # every mode — never a StyleCompatibilityError, never a silent re-route. + for mode in ("legacy", "warn", "strict"): + with pytest.raises(ValueError, match="custom_css requires"): + _chart().to_png(engine=export.Engine.default, custom_css=".x{}", compatibility=mode) + + +def test_auto_with_custom_css_is_lossless_in_every_mode() -> None: + if export.find_chromium() is None: + pytest.skip("Chromium unavailable") + chart = _lossy_chart() + with warnings.catch_warnings(): + warnings.simplefilter("error", StyleCompatibilityWarning) + data = chart.to_image("png", custom_css=".x{}", compatibility="strict") + assert data[:8] == b"\x89PNG\r\n\x1a\n" + + +# -- routing symmetry -------------------------------------------------------- + + +def test_every_image_entry_point_honors_the_mode(tmp_path) -> None: + chart = _lossy_chart() + with pytest.raises(StyleCompatibilityError): + chart.to_image("jpeg", compatibility="strict") + with pytest.raises(StyleCompatibilityError): + chart.to_svg(compatibility="strict") + with pytest.raises(StyleCompatibilityError): + chart.figure().write_image(tmp_path / "x.pdf", compatibility="strict") + with pytest.raises(StyleCompatibilityError): + export.to_png(chart.figure(), compatibility="strict") + + +def test_html_rejects_a_compatibility_mode_like_other_inapplicable_options( + tmp_path, +) -> None: + # HTML renders the full cascade; there is nothing for a mode to check, so + # write_image treats it like the other options HTML cannot honor. + with pytest.raises(ValueError, match="compatibility"): + _chart().write_image(tmp_path / "chart.html", compatibility="strict") + # The default passes through untouched. + _chart().write_image(tmp_path / "chart2.html") From 10b5030268e6c0d6bf1acfafcab05687333fbfe3 Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Tue, 4 Aug 2026 13:51:55 -0700 Subject: [PATCH 2/2] Review follow-ups: precedence, attribution, remediation, and batch vocabulary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolution errors now precede mode logic in to_png for styled charts too: the custom_css/native refusal is checked right after engine resolution, before enforcement can warn or raise, so it stays the documented ValueError in every mode. The precedence test now feeds a lossy chart — the case that previously slipped past it — and asserts the error is not the strict subclass. Compatibility warnings land on the caller's export line. The distance from the warn call to user code varies by entry point (module function, Figure method, Chart method), so the stacklevel is measured: walk outward from enforce to the first frame outside the package. A test pins the attribution for three different entry points. Strict SVG failures no longer recommend engine=Engine.chromium, which that format rejects; the remediation is format-aware. write_images validates the compatibility vocabulary once up front, so a typo fails even an all-HTML batch instead of passing silently — while HTML entries themselves stay exempt from the mode, since a document that renders the full cascade has nothing to check. The changelog entry now names the facet-grid exclusion instead of overstating coverage, and the raster-drops-vector-only test is named for what it exercises. --- CHANGELOG.md | 7 ++- python/xy/export.py | 16 +++++-- python/xy/styling/preflight.py | 44 +++++++++++++++--- tests/test_style_compatibility_modes.py | 59 +++++++++++++++++++++++-- 4 files changed, 111 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 995018de..47364734 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,11 @@ in the README). ## [Unreleased] ### Added -- Every image-export API (`to_png`, `to_svg`, `to_image`, `write_image`, - `export.write_images`) accepts `compatibility=`: `"legacy"` (default — +- Every chart-, figure-, and module-level image-export API (`to_png`, + `to_svg`, `to_image`, `write_image`, `export.write_images`) accepts + `compatibility=` — facet-grid exports deliberately do not yet (their + per-panel preflight is tracked in the migration document): `"legacy"` + (default — behavior unchanged), `"warn"` (one `StyleCompatibilityWarning` naming each declaration the export would drop), or `"strict"` (`StyleCompatibilityError` before emission, preflight report attached). diff --git a/python/xy/export.py b/python/xy/export.py index dc124292..d97380fc 100644 --- a/python/xy/export.py +++ b/python/xy/export.py @@ -682,7 +682,14 @@ def write_images( (native WebP stays lossless), so mixed batches stay ergonomic. `compatibility=` applies per figure while the plan is resolved, so a strict batch refuses whole — before any file is written — rather than - after a partial export.""" + after a partial export. Its vocabulary is validated once up front, so an + invalid mode fails even an all-HTML batch; HTML entries themselves are + exempt from the mode, because a document that renders the full cascade + has nothing to check.""" + if compatibility != "legacy": + from .styling.preflight import validate_compatibility + + validate_compatibility(compatibility) if figures is not None: if figs is not None: raise ValueError("pass figs positionally or figures=, not both") @@ -840,10 +847,13 @@ def to_png( optimize = _bool_option(optimize, "PNG optimize") sandbox = _bool_option(sandbox, "PNG sandbox") resolved_engine = _png_engine(engine) + # Resolution errors precede and outrank mode logic (the migration spec's + # contract): the custom_css/native refusal must stay a ValueError in + # every mode, so it fires before enforcement can warn or raise. + if resolved_engine == "native" and custom_css is not None: + raise ValueError("custom_css requires engine=Engine.chromium") _enforce_compatibility(fig, "png", resolved_engine, custom_css, compatibility) if resolved_engine == "native": - if custom_css is not None: - raise ValueError("custom_css requires engine=Engine.chromium") from . import _raster data = _raster.to_png(fig, None, width=w, height=h, scale=scale, fast=not optimize) diff --git a/python/xy/styling/preflight.py b/python/xy/styling/preflight.py index cb0256ed..73246a18 100644 --- a/python/xy/styling/preflight.py +++ b/python/xy/styling/preflight.py @@ -414,26 +414,58 @@ class or per-slot declarations and the engine is native; a lossless report = route_resolved(figure, fmt=fmt, resolved_engine=resolved_engine, custom_css=custom_css) if report.lossless: return + # SVG is native-only (a browser screenshot cannot emit vector SVG), so + # recommending engine=Engine.chromium there would recommend a refusal. + keeps = ( + "to_html() keeps everything (SVG is native-only, so Engine.chromium " + "is not a route for this format)" + if fmt == "svg" + else "engine=Engine.chromium or to_html() keeps everything" + ) if mode == "warn": warnings.warn( StyleCompatibilityWarning( f"this {fmt} export drops declared styling — " + "; ".join(report.losses) + " — chart.style_compatibility_report() has the full routing; " - "engine=Engine.chromium or to_html() keeps everything" + + keeps ), - stacklevel=2, + stacklevel=_caller_stacklevel(), ) return raise StyleCompatibilityError( - report.explain() + "\nstrict compatibility refuses to drop declared styling; keep it with " - "engine=Engine.chromium or to_html(), move it into chart/mark style= or a " - 'supported styles= subset, or export with compatibility="warn" during ' - "migration", + report.explain() + + "\nstrict compatibility refuses to drop declared styling; " + + keeps + + ", or move it into chart/mark style= or a supported styles= subset, " + 'or export with compatibility="warn" during migration', report, ) +def _caller_stacklevel() -> int: + """The stacklevel that lands the warning on the caller's export line. + + The distance from `enforce` to user code varies by entry point (module + function, Figure method, Chart method, batch plan), so it is measured: + walk outward from `enforce` until the first frame outside the xy + package. Counted so `warnings.warn(..., stacklevel=n)` inside `enforce` + attributes the warning to that frame. + """ + import sys + from pathlib import Path + + package_dir = str(Path(__file__).resolve().parents[1]) + frame = sys._getframe(1) # enforce's frame + level = 1 + while frame.f_back is not None and str(Path(frame.f_code.co_filename).resolve()).startswith( + package_dir + ): + frame = frame.f_back + level += 1 + return level + + __all__ = [ "COMPATIBILITY_MODES", "ROUTE_BROWSER_ONLY", diff --git a/tests/test_style_compatibility_modes.py b/tests/test_style_compatibility_modes.py index a3db4f30..9d77077c 100644 --- a/tests/test_style_compatibility_modes.py +++ b/tests/test_style_compatibility_modes.py @@ -78,11 +78,25 @@ def test_warn_is_silent_when_nothing_drops() -> None: _chart(styles={"tick_label": {"letter_spacing": "0.08em"}}).to_svg(compatibility="warn") -def test_warn_applies_to_the_vector_writers_too() -> None: +def test_warn_fires_when_the_raster_drops_a_vector_only_declaration() -> None: + # letter-spacing survives the vector writers but not the raster one, so + # the PNG path must warn where the SVG path (covered above) stays silent. with pytest.warns(StyleCompatibilityWarning, match="tick_label"): _chart(styles={"tick_label": {"letter_spacing": "0.08em"}}).to_png(compatibility="warn") +def test_warnings_land_on_the_callers_line_not_export_plumbing() -> None: + # The distance from the warn call to user code differs per entry point, + # so the stacklevel is measured; every public route must attribute the + # warning to this file, not to export.py or preflight.py internals. + chart = _lossy_chart() + with pytest.warns(StyleCompatibilityWarning) as caught: + chart.to_png(compatibility="warn") + chart.figure().to_svg(compatibility="warn") + chart.to_image("jpeg", compatibility="warn") + assert [w.filename for w in caught] == [__file__] * len(caught) + + # -- strict ------------------------------------------------------------------ @@ -132,9 +146,22 @@ def test_no_mode_reroutes_an_explicit_engine() -> None: def test_resolution_errors_precede_and_outrank_mode_logic() -> None: # custom_css with a pinned native engine raises today's ValueError in # every mode — never a StyleCompatibilityError, never a silent re-route. - for mode in ("legacy", "warn", "strict"): - with pytest.raises(ValueError, match="custom_css requires"): - _chart().to_png(engine=export.Engine.default, custom_css=".x{}", compatibility=mode) + # The lossy chart is the load-bearing case: enforcement would otherwise + # run its preflight (and warn or raise) before the resolution check. + for chart in (_chart(), _lossy_chart()): + for mode in ("legacy", "warn", "strict"): + with warnings.catch_warnings(): + warnings.simplefilter("error", StyleCompatibilityWarning) + with pytest.raises(ValueError, match="custom_css requires") as excinfo: + chart.to_png( + engine=export.Engine.default, custom_css=".x{}", compatibility=mode + ) + assert not isinstance(excinfo.value, StyleCompatibilityError) + with pytest.raises(ValueError, match="custom_css requires") as excinfo: + chart.to_image( + "png", engine=export.Engine.default, custom_css=".x{}", compatibility=mode + ) + assert not isinstance(excinfo.value, StyleCompatibilityError) def test_auto_with_custom_css_is_lossless_in_every_mode() -> None: @@ -171,3 +198,27 @@ def test_html_rejects_a_compatibility_mode_like_other_inapplicable_options( _chart().write_image(tmp_path / "chart.html", compatibility="strict") # The default passes through untouched. _chart().write_image(tmp_path / "chart2.html") + + +def test_batch_validates_the_mode_vocabulary_even_for_all_html(tmp_path) -> None: + # A mixed batch legitimately carries a mode for its image entries, so + # HTML entries are exempt rather than rejecting the whole batch — but the + # vocabulary is validated up front, so a typo fails an all-HTML batch too + # instead of passing silently. + chart = _lossy_chart() + with pytest.raises(ValueError, match="compatibility"): + export.write_images([chart], [str(tmp_path / "a.html")], compatibility="stricted") + # strict + only HTML: nothing to check, bytes written. + export.write_images([chart], [str(tmp_path / "b.html")], compatibility="strict") + assert (tmp_path / "b.html").exists() + + +def test_strict_svg_remediation_does_not_recommend_a_refused_engine() -> None: + # SVG is native-only; a strict SVG failure must not point at + # engine=Engine.chromium, which that format rejects. + with pytest.raises(StyleCompatibilityError) as svg_err: + _lossy_chart().to_svg(compatibility="strict") + assert "SVG is native-only" in str(svg_err.value) + with pytest.raises(StyleCompatibilityError) as png_err: + _lossy_chart().to_png(compatibility="strict") + assert "engine=Engine.chromium or to_html()" in str(png_err.value)