From 497e28d8eee1dedaf19bdfec30e24abd70676939 Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Sun, 26 Jul 2026 01:57:24 +0000 Subject: [PATCH] =?UTF-8?q?Mark=20plugins:=20contribute=20a=20chart=20kind?= =?UTF-8?q?=20without=20forking=20(=C2=A724=20v0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dossier has listed "no extensibility story" as an open risk since the audit, and it was the one dimension where XY lost outright rather than traded: Matplotlib has custom Artists, Bokeh has custom models, and XY had a hardcoded dict of nineteen appliers. This ships the half of §24 that can be shipped honestly. A plugin declares columns, a `calc` over them, and a `build` that returns built-in `Mark` objects. `build` never receives the `Figure`, the trace list, or the column store — it returns marks, and `_plugin_applier` runs them through the same appliers, the same axis assignment, and the same post-processing as marks written by hand. That containment is the design, not a safety rail bolted on. Because a plugin's output is ordinary traces, it inherits decimation on large inputs, picking, hover, the a11y summary, and all three export paths without writing a line for any of them — and it cannot draw anything the engine could not already draw. §24's third requirement, hover/a11y descriptors, turns out to need nothing at all for the same reason. The custom-shader half of §24 stays deferred, and the argument above is exactly why: a plugin carrying its own GLSL inherits none of that and has to re-earn all of it. Deferring is the position, not the backlog. Composition is one level deep. Plugins compose built-ins, not each other, which keeps the registry a lookup instead of a dependency graph with cycles. The registry also refuses to shadow a built-in kind and refuses to silently replace another plugin — two libraries registering "candlestick" is a conflict their user needs to see, not a race that import order settles. Also records the dividing line in the chart-kind contract: a kind that needs a new GPU primitive is a core kind and pays the six-touch-point checklist; a kind that only composes existing ones is plugin territory and pays nothing. --- CHANGELOG.md | 10 ++ docs/advanced/custom-marks.md | 110 ++++++++++++++++++ docs/app/xy_docs/config.py | 1 + python/xy/__init__.py | 12 ++ python/xy/components.py | 135 +++++++++++++++++++++- python/xy/plugins.py | 147 +++++++++++++++++++++++ spec/api/chart-kind-contract.md | 18 +++ spec/design-dossier.md | 15 ++- tests/test_mark_plugins.py | 199 ++++++++++++++++++++++++++++++++ 9 files changed, 644 insertions(+), 3 deletions(-) create mode 100644 docs/advanced/custom-marks.md create mode 100644 python/xy/plugins.py create mode 100644 tests/test_mark_plugins.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bc3ea4b..4b19e8b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,16 @@ in the README). ## [Unreleased] ### Added +- **Mark plugins**: `xy.register_mark(xy.MarkPlugin(...))` adds a chart kind XY + does not ship, and `xy.mark("name", ...)` uses it. A plugin supplies a `calc` + over its declared columns and a `build` that returns *built-in* marks — the + composition half of dossier §24, with the custom-shader half deliberately + deferred. Because a plugin's output is ordinary traces it inherits decimation, + hover, picking, the a11y summary, and every export path including the two with + no browser; `build` never sees the `Figure`, the trace list, or the column + store, so it cannot draw anything the engine could not already draw. The + registry refuses to shadow a built-in kind and refuses to silently replace + another plugin. - Mark `style=` accepts **`stroke-linecap`** (`butt`/`round`/`square`) on the line family, with the standard SVG semantics: it shapes the polyline's two ends and each dash end. Only the line family takes it, because it describes diff --git a/docs/advanced/custom-marks.md b/docs/advanced/custom-marks.md new file mode 100644 index 00000000..e7519287 --- /dev/null +++ b/docs/advanced/custom-marks.md @@ -0,0 +1,110 @@ +--- +title: Custom Marks +description: Add a chart kind XY does not ship by composing its built-in marks, without forking the renderer. +--- + +# Custom Marks + +XY ships twenty mark kinds. When you need one it does not have — a candlestick, +a high-low band, a ribbon, a dumbbell — you can register it instead of waiting +for it or forking the renderer. + +A mark plugin is two functions and a name: + +- **`calc`** turns your input columns into the columns you want to draw. It runs + once, on arrays, before anything is built. +- **`build`** returns ordinary XY marks. Not shaders, not draw calls — the same + `xy.segments(...)`, `xy.scatter(...)`, `xy.line(...)` you would write by hand. + +That second constraint is the point rather than a limitation. Because a plugin's +output is ordinary traces, it gets decimation on large inputs, hover and +picking, the accessibility summary, and every export path — including native PNG +and SVG, which have no browser — without writing a line of code for any of them. + +## A worked example + +~~~python +import numpy as np +import xy + + +def _calc(columns): + """Columns in, columns out. Add whatever `build` needs to draw.""" + return {**columns, "mid": (columns["low"] + columns["high"]) / 2.0} + + +def _build(ctx): + """Return built-in marks. `ctx.columns` is `_calc`'s output.""" + return [ + xy.segments( + x0=ctx.columns["t"], + x1=ctx.columns["t"], + y0=ctx.columns["low"], + y1=ctx.columns["high"], + name=ctx.name, + style=ctx.style, + ), + xy.scatter( + x=ctx.columns["t"], + y=ctx.columns["mid"], + size=ctx.options.get("mid_size", 6), + ), + ] + + +xy.register_mark( + xy.MarkPlugin( + name="hilo", + columns=("t", "low", "high"), + calc=_calc, + build=_build, + doc="A high-low band with a midpoint marker.", + ) +) +~~~ + +Use it like any other mark: + +~~~python +chart = xy.chart( + xy.mark("hilo", t="day", low="low", high="high", data=frame, name="Range"), + xy.y_axis(label="price"), +) +~~~ + +Fields you named in `columns` behave exactly like a built-in mark's `x` and `y`: +a string names a column in `data=`, anything else is used as values directly, +and they reach `calc` as arrays. Every other keyword — `mid_size` above — arrives +in `ctx.options` untouched. + +## What a plugin can and cannot do + +| Can | Cannot | +| --- | --- | +| Compute new columns from its inputs | Reach the `Figure`, the trace list, or the column store | +| Emit any number of built-in marks | Emit another plugin's mark (composition is one level deep) | +| Read the caller's `style`, `name`, and options | Ship its own GLSL or WGSL | +| Draw on a named axis via `xy.mark(..., y_axis="y2")` | Add a new GPU primitive | + +The last two are the real boundary, and it is deliberate rather than temporary +scaffolding. A plugin that composes built-in marks cannot draw anything the +engine could not already draw, which is exactly why it inherits everything the +engine already guarantees. A plugin carrying its own shader would inherit none +of it and would have to re-implement decimation, picking, accessibility, and +three export paths correctly on its own. See +[§24 of the design dossier](https://github.com/reflex-dev/xy/blob/main/spec/design-dossier.md) +for the full argument. + +## Registry rules + +`register_mark` refuses two things outright, both because the alternative is a +bug someone debugs at runtime: + +- **Shadowing a built-in.** `xy.register_mark(MarkPlugin(name="scatter", ...))` + raises. A plugin cannot change what `xy.scatter` means. +- **Silently replacing another plugin.** Two libraries registering + `"candlestick"` is a conflict their user needs to see, not a race that import + order settles. Pass `replace=True` when that is genuinely what you want. + +`xy.registered_marks()` lists what is contributed from outside; +`xy.unregister_mark(name)` removes one, which is mostly useful in tests. diff --git a/docs/app/xy_docs/config.py b/docs/app/xy_docs/config.py index 36006525..4b58cfa4 100644 --- a/docs/app/xy_docs/config.py +++ b/docs/app/xy_docs/config.py @@ -132,6 +132,7 @@ "network", ( ("XY Architecture", "/advanced/"), + ("Custom Marks", "/advanced/custom-marks/"), ( "Runtime and Deployment", "/advanced/runtime-and-deployment/", diff --git a/python/xy/__init__.py b/python/xy/__init__.py index b6f81c3d..de2b35cf 100644 --- a/python/xy/__init__.py +++ b/python/xy/__init__.py @@ -41,6 +41,8 @@ "Interaction": ".components", "Legend": ".components", "Mark": ".components", + "MarkContext": ".plugins", + "MarkPlugin": ".plugins", "Modebar": ".components", "Selection": "._figure", "Spring": ".components", @@ -73,6 +75,7 @@ "hexbin": ".components", "hexbin_chart": ".components", "heatmap": ".components", + "mark": ".components", "heatmap_chart": ".components", "hline": ".components", "hist": ".components", @@ -81,6 +84,9 @@ "interaction_config": ".components", "label": ".components", "legend": ".components", + "register_mark": ".plugins", + "registered_marks": ".plugins", + "unregister_mark": ".plugins", "line": ".components", "line_chart": ".components", "marker": ".components", @@ -129,6 +135,8 @@ "Interaction", "Legend", "Mark", + "MarkContext", + "MarkPlugin", "Modebar", "Selection", "Spring", @@ -172,8 +180,11 @@ "legend", "line", "line_chart", + "mark", "marker", "modebar", + "register_mark", + "registered_marks", "scatter", "scatter_chart", "segments", @@ -192,6 +203,7 @@ "tooltip", "triangle_mesh", "triangle_mesh_chart", + "unregister_mark", "violin", "violin_chart", "vline", diff --git a/python/xy/components.py b/python/xy/components.py index 21b82c63..2e2749f1 100644 --- a/python/xy/components.py +++ b/python/xy/components.py @@ -43,7 +43,7 @@ import numpy as np -from . import _validate, channels, export, styles +from . import _validate, channels, export, plugins, styles from ._figure import Figure, Selection from ._typing import ArrayLike, ColorLike, Scalar, TableLike from .dom import CHART_DOM_SLOTS, validate_dom_slots @@ -113,6 +113,7 @@ "legend", "line", "line_chart", + "mark", "marker", "modebar", "scatter", @@ -3260,7 +3261,7 @@ def figure(self) -> Figure: colorbar_candidates: list[dict[str, Any]] = [] for m in marks: data = m.data if m.data is not None else self.data - applier = _MARK_APPLIERS.get(m.kind) + applier = _MARK_APPLIERS.get(m.kind) or _plugin_applier(m.kind) if applier is None: raise TypeError(f"no applier registered for mark kind {m.kind!r}") x_axis_id, y_axis_id = _mark_axis_ids(m, axes) @@ -5199,6 +5200,85 @@ def _apply_callout_annotation(fig: Figure, annotation: Annotation) -> None: } +def _plugin_column(values: Any) -> Any: + """A plugin's declared column, as an array when it can be one.""" + if values is None or isinstance(values, np.ndarray): + return values + try: + return np.asarray(values) + except (TypeError, ValueError): # pragma: no cover - exotic column objects + return values + + +def _plugin_applier(kind: str) -> Optional[Callable[[Figure, Mark, Any], None]]: + """An applier for a registered mark plugin, or None if `kind` is unknown. + + Resolved per compile rather than folded into `_MARK_APPLIERS` so a plugin + can never shadow a built-in, and so `registered_marks()` stays the single + answer to what is contributed from outside. + """ + resolved = plugins.get_mark_plugin(kind) + if resolved is None: + return None + plugin = resolved + + def apply(fig: Figure, m: Mark, data: Any) -> None: + # Declared columns arrive as arrays, never as the raw list a caller + # happened to pass: a calc function is arithmetic over columns (§24), + # and making every plugin author write np.asarray first would be a + # papercut that shows up as a TypeError in their code, not ours. + columns = { + column: _plugin_column(_resolve(data, m.props.get(column), context=f"{kind}.{column}")) + for column in plugin.columns + } + if plugin.calc is not None: + calculated = plugin.calc(columns) + if not isinstance(calculated, Mapping): + raise TypeError( + f"mark plugin {kind!r} calc must return a mapping of columns, " + f"got {type(calculated).__name__}" + ) + columns = dict(calculated) + # Axis ids are chart plumbing, already applied by the compile loop. + options = { + k: v + for k, v in m.props.items() + if k not in plugin.columns and k not in {"x_axis", "y_axis"} + } + built = plugin.build( + plugins.MarkContext( + columns=columns, + options=options, + name=m.name, + style=dict(m.style or {}), + class_name=m.class_name, + ) + ) + if isinstance(built, (Mark, str, bytes)) or not isinstance(built, Sequence): + raise TypeError( + f"mark plugin {kind!r} build must return a sequence of marks, " + f"got {type(built).__name__}" + ) + for child in built: + if not isinstance(child, Mark): + raise TypeError( + f"mark plugin {kind!r} build returned {type(child).__name__}, " + "expected marks built by xy's mark constructors" + ) + child_applier = _MARK_APPLIERS.get(child.kind) + if child_applier is None: + # One level only: a plugin composes built-in primitives. Letting + # plugins compose each other turns a registry into a dependency + # graph with cycles, for a case nothing has asked for yet. + raise TypeError( + f"mark plugin {kind!r} build returned mark kind {child.kind!r}, " + f"which is not a built-in; plugins compose built-in marks only" + ) + child_applier(fig, child, child.data if child.data is not None else data) + + return apply + + _ANNOTATION_APPLIERS: dict[str, Callable[[Figure, Annotation], None]] = { "arrow": _apply_arrow_annotation, "band": _apply_band_annotation, @@ -5209,6 +5289,57 @@ def _apply_callout_annotation(fig: Figure, annotation: Annotation) -> None: } +def mark( + kind: str, + *, + data: TableLike = None, + name: Optional[str] = None, + style: Optional[dict[str, StyleValue]] = None, + class_name: Optional[str] = None, + key: Any = None, + animation: "Animation | bool | None" = None, + x_axis: str = "x", + y_axis: str = "y", + **fields: Any, +) -> Mark: + """A mark contributed by a registered plugin (`xy.register_mark`). + + `kind` names the plugin. Fields it declared in `MarkPlugin.columns` accept a + column name resolved from ``data`` or values directly, exactly like a + built-in mark's ``x``/``y``; every other keyword reaches the plugin as an + option untouched. + + Args: + kind: Registered plugin name. + data: Table used to resolve column-name inputs. + name: Series label used by legends and tooltips. + style: Mark style overrides, passed to the plugin verbatim. + class_name: Adapter-only trace metadata. + key: Stable row identities, or a column name resolved from ``data``. + animation: Per-mark animation override; ``False`` disables animation. + x_axis: Identifier of the x axis used by this mark. + y_axis: Identifier of the y axis used by this mark. + **fields: The plugin's declared columns and options. + """ + if plugins.get_mark_plugin(kind) is None: + known = ", ".join(plugins.registered_marks()) or "none registered" + raise ValueError(f"unknown mark plugin {kind!r}; registered plugins: {known}") + return Mark( + kind=kind, + data=data, + name=_optional_string(name, "mark name"), + class_name=_optional_string(class_name, "mark class_name"), + style=styles.normalize_css_style(style, "mark style"), + key=key, + animation=animation, + props={ + **fields, + "x_axis": _axis_id(x_axis, "mark x_axis"), + "y_axis": _axis_id(y_axis, "mark y_axis"), + }, + ) + + def chart(*children: Component, **props: Any) -> Chart: """A neutral single-panel chart for overlays and mixed mark composition.""" return Chart("chart", children, **props) diff --git a/python/xy/plugins.py b/python/xy/plugins.py new file mode 100644 index 00000000..954d5967 --- /dev/null +++ b/python/xy/plugins.py @@ -0,0 +1,147 @@ +"""Third-party mark plugins — the v0 of the dossier's §24 extensibility story. + +§24 describes a registered mark plugin as three things: a calc function over +columns, *either* a composition of built-in GPU primitives *or* a WGSL/GLSL +snippet pair, and hover/a11y descriptors. This module ships the first and the +composition half of the second, and deliberately not the shader half. + +The reason is not schedule. A plugin that composes built-in marks cannot draw +anything the engine could not already draw, which means it inherits — for free, +and without a way to get them wrong — LOD and decimation (§28), picking and +hover, the a11y summary (§20), the wire protocol's f32 discipline (§29), and +every export path including the two that have no browser. A plugin carrying its +own shader inherits none of that and has to re-earn all of it. Composition is +where the breadth-without-forking argument actually holds; shaders are a second +system, and they can wait until something real needs one. + + import numpy as np + import xy + + def _calc(columns): + low, high = columns["low"], columns["high"] + return {**columns, "mid": (low + high) / 2.0} + + def _build(ctx): + return [ + xy.segments( + x0=ctx.columns["t"], x1=ctx.columns["t"], + y0=ctx.columns["low"], y1=ctx.columns["high"], + style=ctx.style, + ), + xy.scatter(x=ctx.columns["t"], y=ctx.columns["mid"], size=4), + ] + + xy.register_mark( + xy.MarkPlugin(name="hilo", columns=("t", "low", "high"), calc=_calc, build=_build) + ) + + chart = xy.chart(xy.mark("hilo", t=ts, low=lows, high=highs, data=frame)) + +A plugin's `build` returns built-in `Mark` objects and nothing else; it never +sees the `Figure`, the trace list, or the column store. That is the whole +containment argument, and `tests/test_mark_plugins.py` holds it: the composed +marks go through the same appliers, the same axis assignment, and the same +post-processing as marks written by hand, because they *are* marks written by +hand — just written by someone else's function. +""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: # pragma: no cover - import cycle at runtime + from .components import Mark + + +@dataclass(frozen=True) +class MarkContext: + """Everything a plugin's `build` is allowed to see. + + Deliberately not a `Figure`: a plugin composes marks, it does not drive the + engine. `columns` holds the plugin's declared columns after `calc` has run, + with any string values already resolved against the chart's `data=`. + """ + + columns: Mapping[str, Any] + options: Mapping[str, Any] + name: str | None = None + style: Mapping[str, Any] = field(default_factory=dict) + class_name: str | None = None + + +@dataclass(frozen=True) +class MarkPlugin: + """A mark kind contributed from outside the core. + + `columns` names the fields `xy.mark(...)` will resolve against `data=`; + everything else a caller passes lands in `MarkContext.options` untouched. + `calc` runs once over the resolved columns and returns the columns `build` + sees — this is §24's "calc function over columns → columns", running in + Python today rather than in the worker. + """ + + name: str + build: Callable[[MarkContext], "Sequence[Mark]"] + columns: tuple[str, ...] = () + calc: Callable[[Mapping[str, Any]], Mapping[str, Any]] | None = None + doc: str = "" + + +_REGISTRY: dict[str, MarkPlugin] = {} + + +def register_mark(plugin: MarkPlugin, *, replace: bool = False) -> MarkPlugin: + """Register a mark plugin under its name. + + Refuses to shadow a built-in kind, and refuses to silently replace another + plugin: two libraries registering `"candlestick"` is a conflict their user + needs to know about, not a race the import order settles. + """ + from .components import _MARK_APPLIERS + + if not isinstance(plugin, MarkPlugin): + raise TypeError(f"register_mark expects a MarkPlugin, got {type(plugin).__name__}") + if not plugin.name or not plugin.name.isidentifier(): + raise ValueError(f"mark plugin name must be an identifier, got {plugin.name!r}") + if plugin.name in _MARK_APPLIERS: + raise ValueError(f"{plugin.name!r} is a built-in mark kind and cannot be replaced") + if plugin.name in _REGISTRY and not replace: + raise ValueError( + f"mark plugin {plugin.name!r} is already registered; " + "pass replace=True if that is intended" + ) + if not callable(plugin.build): + raise TypeError(f"mark plugin {plugin.name!r} build must be callable") + if plugin.calc is not None and not callable(plugin.calc): + raise TypeError(f"mark plugin {plugin.name!r} calc must be callable or None") + duplicates = sorted({c for c in plugin.columns if plugin.columns.count(c) > 1}) + if duplicates: + raise ValueError(f"mark plugin {plugin.name!r} repeats column(s) {duplicates}") + _REGISTRY[plugin.name] = plugin + return plugin + + +def unregister_mark(name: str) -> None: + """Remove a registered plugin. Unknown names are a no-op.""" + _REGISTRY.pop(name, None) + + +def registered_marks() -> tuple[str, ...]: + """Names of every registered mark plugin, sorted.""" + return tuple(sorted(_REGISTRY)) + + +def get_mark_plugin(name: str) -> MarkPlugin | None: + return _REGISTRY.get(name) + + +__all__ = [ + "MarkContext", + "MarkPlugin", + "get_mark_plugin", + "register_mark", + "registered_marks", + "unregister_mark", +] diff --git a/spec/api/chart-kind-contract.md b/spec/api/chart-kind-contract.md index a662c694..b9452ebe 100644 --- a/spec/api/chart-kind-contract.md +++ b/spec/api/chart-kind-contract.md @@ -199,6 +199,24 @@ this contract): `pointPick` (participates in the point-geometry GPU pick pass), colors, §36). The registry and `markOf()` are exported (`xy.MARK_KINDS`) — it is the public extension surface. +## Contributing a kind from outside the repo + +The checklist above is for kinds that join the core: six touch points across +Python, the client, and the docs. A kind that only needs to *compose* existing +primitives does not have to pay it. `xy.register_mark` (`python/xy/plugins.py`, +dossier §24) takes a `calc` over declared columns plus a `build` that returns +built-in `Mark` objects, and `components._plugin_applier` runs the result +through the same appliers, axis assignment, and post-processing as a hand-built +mark. `_MARK_APPLIERS` is consulted first, so a plugin can never shadow a +built-in. + +The dividing line is whether the kind needs a **new primitive**. A candlestick, +a dumbbell, a ribbon, a high-low band — all compositions, all plugin territory. +A kind that needs geometry no shader draws yet is a core kind and takes the +checklist. Composition is one level deep on purpose: plugins compose built-ins, +not each other, which keeps the registry a lookup rather than a dependency +graph. + ## Extension points not yet generalized (do it when the case lands) These are still shaped for the marks that exist. Generalize them when a real new diff --git a/spec/design-dossier.md b/spec/design-dossier.md index 313aadf4..2e0e225b 100644 --- a/spec/design-dossier.md +++ b/spec/design-dossier.md @@ -652,7 +652,7 @@ missing entirely.* | 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) | | 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 | Custom-mark API sketch (§24) | +| 15 | No extensibility story (Plotly has custom traces) | Minor | **Shipped v0**: composition mark plugins, `xy.register_mark` (§24). Custom shaders still deferred. | ## 16. Numeric precision & deep zoom @@ -909,6 +909,19 @@ partial-bundle pain). Neither exists today. (c) hover/a11y descriptors so §17/§20 work uncalled-for. Plotly's moat is breadth; a plugin API is how breadth arrives without the core team writing all 40 traces. + **Shipped (v0):** `xy.register_mark` / `xy.MarkPlugin` / `xy.mark` in + `python/xy/plugins.py`. It ships (a) and the composition half of (b); the + shader half is deliberately deferred, and (c) turns out to need nothing — + a plugin that composes built-in marks inherits hover, picking, the a11y + summary, LOD, and every export path *by construction*, because its output is + ordinary traces. `build` returns `Mark` objects and cannot reach the `Figure`, + the trace list, or the column store, so a plugin cannot draw anything the + engine could not already draw. Composition is one level deep: plugins compose + built-ins, not each other. That is what makes the containment argument hold, + and it is the reason the shader half should stay deferred until something + real needs it — a plugin carrying its own shader inherits none of the above + and has to re-earn all of it. + ## 25. Milestone amendments (audit-driven) - **Phase 0** additionally proves: offset-encoding precision on ms-timestamp data diff --git a/tests/test_mark_plugins.py b/tests/test_mark_plugins.py new file mode 100644 index 00000000..53f74a10 --- /dev/null +++ b/tests/test_mark_plugins.py @@ -0,0 +1,199 @@ +"""A mark plugin composes built-in marks and gets the engine for free (§24). + +The containment argument is the whole design: a plugin returns `Mark` objects +and never touches the `Figure`, the trace list, or the column store, so its +traces cannot differ from hand-written ones in decimation, picking, hover, a11y, +or any export path. These tests hold that argument rather than merely checking +that the registry accepts a callable. +""" + +from __future__ import annotations + +import re + +import numpy as np +import pytest + +import xy + + +def _hilo_plugin(name: str = "hilo") -> xy.MarkPlugin: + def calc(columns): + return {**columns, "mid": (columns["low"] + columns["high"]) / 2.0} + + def build(ctx): + return [ + xy.segments( + x0=ctx.columns["t"], + x1=ctx.columns["t"], + y0=ctx.columns["low"], + y1=ctx.columns["high"], + name=ctx.name, + style=ctx.style, + ), + xy.scatter( + x=ctx.columns["t"], + y=ctx.columns["mid"], + size=ctx.options.get("mid_size", 6), + ), + ] + + return xy.MarkPlugin(name=name, columns=("t", "low", "high"), calc=calc, build=build) + + +@pytest.fixture +def hilo(): + plugin = xy.register_mark(_hilo_plugin()) + try: + yield plugin + finally: + xy.unregister_mark(plugin.name) + + +def test_a_plugin_mark_compiles_to_ordinary_traces(hilo) -> None: + chart = xy.chart( + xy.mark("hilo", t=[0.0, 1.0, 2.0], low=[1.0, 2.0, 1.5], high=[3.0, 4.0, 3.5], name="band") + ) + fig = chart.figure() + + # One plugin mark, two primitives, and nothing about them marks them as + # second-class: they are the same kinds the built-in constructors produce. + assert [t.kind for t in fig.traces] == ["segments", "scatter"] + spec, _ = fig.build_payload() + assert {t["kind"] for t in spec["traces"]} == {"segments", "scatter"} + + +def test_calc_runs_before_build_and_its_columns_are_what_build_sees(hilo) -> None: + fig = xy.chart(xy.mark("hilo", t=[0.0, 1.0], low=[1.0, 3.0], high=[3.0, 5.0])).figure() + scatter = next(t for t in fig.traces if t.kind == "scatter") + assert list(scatter.y.values) == [2.0, 4.0] + + +def test_declared_columns_resolve_from_data_like_a_built_in_mark(hilo) -> None: + frame = {"t": [0.0, 1.0], "lo": [1.0, 2.0], "hi": [3.0, 4.0]} + fig = xy.chart(xy.mark("hilo", t="t", low="lo", high="hi", data=frame)).figure() + assert [t.kind for t in fig.traces] == ["segments", "scatter"] + + with pytest.raises(ValueError, match=r"hilo.low column 'nope' not found"): + xy.chart(xy.mark("hilo", t="t", low="nope", high="hi", data=frame)).figure() + + +def test_undeclared_keywords_reach_the_plugin_as_options(hilo) -> None: + fig = xy.chart( + xy.mark("hilo", t=[0.0, 1.0], low=[1.0, 2.0], high=[3.0, 4.0], mid_size=14) + ).figure() + scatter = next(t for t in fig.traces if t.kind == "scatter") + assert scatter.size_ch is not None and scatter.size_ch.constant == pytest.approx(14.0) + + +def test_plugin_traces_reach_every_renderer(hilo) -> None: + fig = xy.chart( + xy.mark( + "hilo", + t=[0.0, 1.0, 2.0], + low=[1.0, 2.0, 1.5], + high=[3.0, 4.0, 3.5], + style={"stroke": "#ff0000"}, + ) + ).figure() + + assert "#ff0000" in fig.to_svg() + from xy import _raster + + image = _raster.render_raster(*fig.build_payload(), scale=1) + assert np.any(image[:, :, 0] > image[:, :, 2]) + + +def test_a_plugin_mark_honors_named_axes(hilo) -> None: + fig = xy.chart( + xy.mark( + "hilo", + t=[0.0, 1.0], + low=[1.0, 2.0], + high=[3.0, 4.0], + y_axis="y2", + ), + xy.y_axis(id="y2"), + ).figure() + assert all(t.y_axis == "y2" for t in fig.traces) + + +def test_the_registry_refuses_to_shadow_or_silently_replace() -> None: + with pytest.raises(ValueError, match="built-in mark kind"): + xy.register_mark(xy.MarkPlugin(name="scatter", build=lambda ctx: [])) + + first = xy.register_mark(_hilo_plugin("twice")) + try: + with pytest.raises(ValueError, match="already registered"): + xy.register_mark(_hilo_plugin("twice")) + assert xy.register_mark(_hilo_plugin("twice"), replace=True) is not first + finally: + xy.unregister_mark("twice") + + with pytest.raises(ValueError, match="must be an identifier"): + xy.register_mark(xy.MarkPlugin(name="not a name", build=lambda ctx: [])) + + +def test_registered_marks_lists_only_contributed_kinds(hilo) -> None: + assert "hilo" in xy.registered_marks() + assert "scatter" not in xy.registered_marks() + xy.unregister_mark("hilo") + assert "hilo" not in xy.registered_marks() + xy.register_mark(_hilo_plugin()) + + +def test_an_unknown_plugin_name_fails_at_construction_not_at_compile() -> None: + with pytest.raises(ValueError, match="unknown mark plugin 'nope'"): + xy.mark("nope", x=[0.0]) + + +def test_build_must_return_built_in_marks() -> None: + def bad_type(ctx): + return "not marks" + + def nested(ctx): + return [xy.mark("inner", x=[0.0])] + + xy.register_mark(xy.MarkPlugin(name="badtype", build=bad_type)) + xy.register_mark(xy.MarkPlugin(name="inner", build=lambda ctx: [])) + xy.register_mark(xy.MarkPlugin(name="nested", build=nested)) + try: + with pytest.raises(TypeError, match="must return a sequence of marks"): + xy.chart(xy.mark("badtype")).figure() + # One level only: plugins compose built-ins, not each other. + with pytest.raises(TypeError, match=re.escape("plugins compose built-in marks only")): + xy.chart(xy.mark("nested")).figure() + finally: + for name in ("badtype", "inner", "nested"): + xy.unregister_mark(name) + + +def test_calc_must_return_a_mapping() -> None: + xy.register_mark( + xy.MarkPlugin( + name="badcalc", columns=("x",), calc=lambda cols: [1, 2], build=lambda ctx: [] + ) + ) + try: + with pytest.raises(TypeError, match="calc must return a mapping"): + xy.chart(xy.mark("badcalc", x=[0.0])).figure() + finally: + xy.unregister_mark("badcalc") + + +def test_a_plugin_cannot_reach_the_figure(hilo) -> None: + seen = {} + + def build(ctx): + seen["ctx"] = ctx + return [xy.line(x=ctx.columns["t"], y=ctx.columns["low"])] + + xy.register_mark(xy.MarkPlugin(name="probe", columns=("t", "low"), build=build)) + try: + xy.chart(xy.mark("probe", t=[0.0, 1.0], low=[1.0, 2.0])).figure() + finally: + xy.unregister_mark("probe") + + ctx = seen["ctx"] + assert set(vars(ctx)) == {"columns", "options", "name", "style", "class_name"} + assert not any(hasattr(ctx, attr) for attr in ("figure", "traces", "store", "fig"))