diff --git a/CHANGELOG.md b/CHANGELOG.md index 683641b5..47364734 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,18 @@ in the README). ## [Unreleased] ### Added +- 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). + 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 98df36d4..284da824 100644 --- a/python/xy/_figure.py +++ b/python/xy/_figure.py @@ -2244,13 +2244,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( @@ -2265,6 +2269,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, @@ -2286,6 +2291,7 @@ def to_png( custom_css=custom_css, sandbox=sandbox, gl=gl, + compatibility=compatibility, ) def to_image( @@ -2302,13 +2308,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, @@ -2322,6 +2330,7 @@ def to_image( custom_css=custom_css, sandbox=sandbox, gl=gl, + compatibility=compatibility, ) def write_image( @@ -2339,6 +2348,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`.""" @@ -2356,6 +2366,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 5a457e65..5439d56d 100644 --- a/python/xy/components.py +++ b/python/xy/components.py @@ -4199,9 +4199,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, @@ -4215,12 +4221,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, @@ -4232,6 +4240,7 @@ def to_png( custom_css=custom_css, sandbox=sandbox, gl=gl, + compatibility=compatibility, ) def _export_defaults( @@ -4281,12 +4290,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( @@ -4296,6 +4307,7 @@ def to_image( custom_css=custom_css, sandbox=sandbox, gl=gl, + compatibility=compatibility, **self._export_defaults( fmt, width, @@ -4322,6 +4334,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 @@ -4359,6 +4372,7 @@ def write_image( custom_css=custom_css, sandbox=sandbox, gl=gl, + compatibility=compatibility, ) return self.figure().write_image( path, @@ -4368,6 +4382,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..d97380fc 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,17 @@ 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. 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") @@ -709,6 +733,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 +819,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,9 +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) @@ -899,6 +931,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 +1198,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 +1216,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 +1264,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 +1285,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 +1314,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 625a7b0a..73246a18 100644 --- a/python/xy/styling/preflight.py +++ b/python/xy/styling/preflight.py @@ -40,6 +40,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"}) @@ -266,39 +292,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) # Mirror the spec build's own validation rather than skipping entries it # would refuse: `Figure.class_names`/`chrome_styles` are assignable, so a @@ -324,7 +339,7 @@ def preflight( ) return StyleCompatibilityReport( target=fmt, - engine=resolved, + engine=resolved_engine, sources=sources, findings=tuple(findings), losses=losses, @@ -332,12 +347,137 @@ 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 + # 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; " + + keeps + ), + stacklevel=_caller_stacklevel(), + ) + return + raise StyleCompatibilityError( + 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", "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..9d77077c --- /dev/null +++ b/tests/test_style_compatibility_modes.py @@ -0,0 +1,224 @@ +"""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_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 ------------------------------------------------------------------ + + +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. + # 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: + 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") + + +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)