diff --git a/src/py/tests/_plotly_json_compat_cases.py b/src/py/tests/_plotly_json_compat_cases.py new file mode 100644 index 00000000..a9130581 --- /dev/null +++ b/src/py/tests/_plotly_json_compat_cases.py @@ -0,0 +1,967 @@ +"""Shared Plotly JSON-compatibility cases for Kaleido serialization tests. + +This module defines the value/location matrix used by Kaleido regression tests +that compare Kaleido's pre-render serialization behavior with Plotly's JSON +compatibility contract. It intentionally contains no Kaleido imports and does +not render figures; each case only constructs a Plotly figure, locates the raw +value in ``fig.to_dict()``, and checks how ``PlotlyJSONEncoder`` cleans that +value into decoded JSON. + +Case summary +============ + +Supported scalar values +----------------------- +The supported scalar matrix covers values that are historically accepted by +Plotly's JSON compatibility layer and are common in pandas, NumPy, datetime, and +scientific-computing workflows: + +* ``decimal.Decimal``. +* ``pandas.Timestamp`` with and without timezone information. +* pandas missing and duration sentinels: ``pandas.NaT``, ``pandas.NA``, and + ``pandas.Timedelta``. +* Python non-finite floats: ``NaN`` and positive infinity, both expected to + encode as JSON ``null``. +* Python ``datetime.datetime``, ``datetime.date``, and ``datetime.time``. +* NumPy scalar values: ``numpy.datetime64``, signed and unsigned integer + scalars, floating scalars, and boolean scalars. + +Supported container values +-------------------------- +The supported container matrix covers pandas and NumPy containers that commonly +appear in Plotly figures created from data frames and scientific arrays: + +* datetime containers: ``Series[datetime64]``, ``DatetimeIndex``, and + ``ndarray[datetime64]``. +* object containers containing non-native scalar values, such as object arrays + of ``pandas.Timestamp`` and ``Series`` of ``decimal.Decimal``. +* pandas nullable extension dtypes containing ``pandas.NA``: + ``boolean``, ``string``, and ``Int64``. +* ``pandas.Categorical`` values containing both category labels and missing + entries. + +Figure locations +---------------- +The location matrix places these values into several places where real Plotly +figures can carry data or metadata: + +* 2D trace data: ``scatter.x`` and ``scatter.y``. +* 3D trace data: ``scatter3d.x``, ``scatter3d.y``, and ``scatter3d.z``. +* Matrix trace data: ``heatmap.z`` and ``surface.z``. +* Per-point payload fields that preserve arbitrary payload values, especially + ``customdata``. Text fields are intentionally omitted because Plotly graph + objects often coerce them to strings before JSON serialization. +* Trace and layout metadata: ``trace.meta`` and ``layout.meta``. +* 2D annotations and 3D scene annotations, including scene-annotation ``z``. +* 2D shapes, with separate cases for ``x0``, ``x1``, ``y0``, and ``y1``. +* 2D and 3D axis ranges, plus representative 2D ``tickvals``. + +Unsupported boundary cases +-------------------------- +The module also records common scientific-Python values that are deliberately +outside the current Plotly JSON compatibility contract: ``numpy.timedelta64``, +``datetime.timedelta``, ``pandas.Period``, ``pandas.Interval``, Python +``complex``, and ``numpy.complex128``. Tests should mark these as expected +failures unless a PR intentionally defines conversion semantics for them. + +Design notes +============ + +The raw figure-spec predicates are intentionally semantic rather than exact-type +checks for containers. Plotly versions normalize some inputs differently: for +example, datetime ``Series`` may become object arrays of Python datetimes, +nullable integer ``Series`` may become float arrays containing ``NaN``, and +metadata fields may preserve pandas containers directly. The tests therefore +assert that the intended information survived figure construction and that the +Plotly encoder produced the expected JSON-compatible representation, without +requiring one Plotly-version-specific intermediate shape. +""" + +from __future__ import annotations + +import datetime as dt +import decimal +import json +import math +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from collections.abc import Callable + +import numpy as np +import pandas as pd +import plotly.graph_objects as go +from plotly.utils import PlotlyJSONEncoder + +JsonPath = tuple[str | int, ...] +_MISSING = object() +_EXPECTED_INT = 42 + + +@dataclass(frozen=True) +class ValueCase: + """A value or container placed into a Plotly figure spec. + + Args: + name: Human-readable pytest id for the value under test. + make: Factory returning a fresh value for each test invocation. + raw_predicate: Predicate proving that the raw ``fig.to_dict()`` value is + the intended non-JSON-native value or a Plotly-normalized equivalent. + encoded_predicate: Predicate proving that Plotly's JSON encoder cleaned + the value into an expected JSON-compatible representation. + """ + + name: str + make: Callable[[], Any] + raw_predicate: Callable[[Any], bool] + encoded_predicate: Callable[[Any], bool] + + +@dataclass(frozen=True) +class FigureLocationCase: + """A specific location in a Plotly figure spec. + + Args: + name: Human-readable pytest id for the location. + build: Factory that places the provided value into the target location. + path: Path to the target value inside ``fig.to_dict()``. + """ + + name: str + build: Callable[[Any], go.Figure] + path: JsonPath + + +@dataclass(frozen=True) +class SerializationCase: + """A value/location pair used by parametrized regression tests. + + Args: + value: Value or container case under test. + location: Plotly figure location receiving the value. + """ + + value: ValueCase + location: FigureLocationCase + + @property + def id(self) -> str: + """Return a stable pytest id for this value/location pair. + + Returns: + Identifier containing both the special value and its figure path. + """ + return f"{self.value.name} in {self.location.name}" + + +@dataclass(frozen=True) +class UnsupportedCase: + """A common scientific-Python value without a Plotly JSON contract. + + Args: + name: Human-readable pytest id for the unsupported value. + make: Factory returning a fresh value for each test invocation. + """ + + name: str + make: Callable[[], Any] + + +# --------------------------------------------------------------------------- +# Path, predicate, and serialization helpers +# --------------------------------------------------------------------------- + + +def get_path(obj: Any, path: JsonPath) -> Any: + """Return a nested value from a dict/list/tuple structure. + + Args: + obj: Nested object to inspect. + path: Sequence of dictionary keys and sequence indexes. + + Returns: + The nested value, or ``_MISSING`` if the path does not exist. + """ + current = obj + for part in path: + if isinstance(current, dict): + if part not in current: + return _MISSING + current = current[part] + elif isinstance(current, (list, tuple)) and isinstance(part, int): + if part >= len(current) or part < -len(current): + return _MISSING + current = current[part] + else: + return _MISSING + return current + + +def plotly_cleaned_spec(fig: go.Figure) -> dict[str, Any]: + """Return the Plotly-encoder-cleaned JSON-compatible figure spec. + + Args: + fig: Figure whose raw spec may contain non-JSON-native values. + + Returns: + Decoded JSON-compatible spec produced by ``PlotlyJSONEncoder``. + """ + return json.loads(json.dumps(fig.to_dict(), cls=PlotlyJSONEncoder)) + + +def assert_plotly_encoder_accepts_case(case: SerializationCase) -> None: + """Assert that Plotly's JSON encoder accepts a value/location case. + + Args: + case: Value/location pair under test. + """ + fig = case.location.build(case.value.make()) + raw_spec = fig.to_dict() + raw_value = get_path(raw_spec, case.location.path) + + assert raw_value is not _MISSING + assert case.value.raw_predicate(raw_value), repr(raw_value) + + cleaned_spec = plotly_cleaned_spec(fig) + encoded_value = get_path(cleaned_spec, case.location.path) + + assert encoded_value is not _MISSING + assert case.value.encoded_predicate(encoded_value), repr(encoded_value) + + +def _flat_values(value: Any) -> list[Any]: + """Return a best-effort flat list of scalar values from a container. + + Args: + value: Scalar or container produced by Plotly figure construction. + + Returns: + Flat list of items for predicate checks. + """ + if isinstance(value, pd.Series): + return list(value.array) + if isinstance(value, pd.Categorical): + return list(value) + if isinstance(value, pd.Index): + return list(value) + if isinstance(value, np.ndarray): + return list(value.ravel()) + if isinstance(value, (list, tuple)): + items: list[Any] = [] + for item in value: + if isinstance( + item, + (list, tuple, np.ndarray, pd.Series, pd.Index, pd.Categorical), + ): + items.extend(_flat_values(item)) + else: + items.append(item) + return items + return [value] + + +def _is_nan(value: Any) -> bool: + """Return whether a value behaves like floating NaN. + + Args: + value: Value to inspect. + + Returns: + True if ``math.isnan`` accepts the value and reports NaN. + """ + try: + return math.isnan(value) + except TypeError: + return False + + +def _is_non_finite_float(value: Any) -> bool: + """Return whether a value is a non-finite float. + + Args: + value: Value to inspect. + + Returns: + True for NaN, positive infinity, and negative infinity. + """ + return isinstance(value, float) and not math.isfinite(value) + + +def _is_iso_string_with(prefix: str) -> Callable[[Any], bool]: + """Build a predicate that checks an encoded ISO-like string prefix. + + Args: + prefix: Prefix expected in the encoded JSON value. + + Returns: + Predicate accepting the decoded JSON value. + """ + return lambda value: isinstance(value, str) and value.startswith(prefix) + + +def _is_number_close(expected: float) -> Callable[[Any], bool]: + """Build a predicate that checks a decoded JSON number approximately. + + Args: + expected: Expected numeric value. + + Returns: + Predicate accepting the decoded JSON value. + """ + return lambda value: isinstance(value, (int, float)) and math.isclose( + value, + expected, + ) + + +def _is_json_null(value: Any) -> bool: + """Return whether a decoded JSON value is null. + + Args: + value: Decoded JSON value. + + Returns: + True if the decoded value is ``None``. + """ + return value is None + + +def _is_datetime_container(value: Any) -> bool: + """Return whether a value is a datetime-like container. + + Args: + value: Value to inspect. + + Returns: + True for Plotly 5 and Plotly 6 datetime-container representations. + """ + if isinstance(value, pd.Series): + return pd.api.types.is_datetime64_any_dtype(value.dtype) + if isinstance(value, pd.DatetimeIndex): + return True + if isinstance(value, np.ndarray): + if np.issubdtype(value.dtype, np.datetime64): + return True + if value.dtype == object: + return any(isinstance(item, dt.datetime) for item in value.ravel()) + return False + + +def _container_has_type(*types_: type[Any]) -> Callable[[Any], bool]: + """Build a predicate for containers containing requested item types. + + Args: + *types_: Types expected to occur inside the container. + + Returns: + Predicate accepting the raw figure-spec value. + """ + + def predicate(value: Any) -> bool: + values = _flat_values(value) + return all(any(isinstance(item, type_) for item in values) for type_ in types_) + + return predicate + + +def _is_nullable_int_container(value: Any) -> bool: + """Return whether a value is a nullable-integer container or equivalent. + + Args: + value: Value to inspect. + + Returns: + True for pandas nullable integer containers and Plotly-normalized + numeric-array equivalents, including float arrays containing NaN. + """ + if isinstance(value, pd.Series) and str(value.dtype) == "Int64": + return True + if isinstance(value, dict) and {"dtype", "bdata"}.issubset(value): + return True + if isinstance(value, np.ndarray): + return value.size > 0 and any(_is_nan(item) for item in value.ravel()) + values = _flat_values(value) + return bool(values) and any(item is pd.NA or _is_nan(item) for item in values) + + +def _is_categorical_or_equivalent(value: Any) -> bool: + """Return whether a value is a categorical container or equivalent. + + Args: + value: Value to inspect. + + Returns: + True for pandas ``Categorical`` values and Plotly-normalized category + sequences preserving at least one string and one missing value. + """ + if isinstance(value, pd.Categorical): + return True + values = _flat_values(value) + return any(isinstance(item, str) for item in values) and any( + item is None or _is_nan(item) for item in values + ) + + +def _is_decoded_list_with_prefix(prefix: str) -> Callable[[Any], bool]: + """Build a predicate for decoded JSON lists of ISO-like strings. + + Args: + prefix: Prefix expected in at least one decoded string value. + + Returns: + Predicate accepting the decoded JSON value. + """ + return lambda value: isinstance(value, list) and any( + isinstance(item, str) and item.startswith(prefix) for item in value + ) + + +def _is_decoded_sequence_containing_null(value: Any) -> bool: + """Return whether a decoded JSON sequence contains null. + + Args: + value: Decoded JSON value. + + Returns: + True if the value is a list and contains ``None``. + """ + return isinstance(value, list) and any(item is None for item in value) + + +def _is_decoded_list_of_numbers(value: Any) -> bool: + """Return whether a decoded JSON value is a list containing numbers. + + Args: + value: Decoded JSON value. + + Returns: + True if the value is a non-empty list of JSON numeric values. + """ + return isinstance(value, list) and bool(value) and all( + isinstance(item, (int, float)) for item in value + ) + + +def _is_decoded_nullable_int(value: Any) -> bool: + """Return whether a decoded value represents nullable integer data. + + Args: + value: Decoded JSON value. + + Returns: + True for list-with-null encodings and recent typed-array encodings. + """ + return _is_decoded_sequence_containing_null(value) or ( + isinstance(value, dict) and {"dtype", "bdata"}.issubset(value) + ) + + +# --------------------------------------------------------------------------- +# Value cases +# --------------------------------------------------------------------------- + + +DECIMAL = ValueCase( + "decimal.Decimal", + lambda: decimal.Decimal("12.34"), + lambda value: isinstance(value, decimal.Decimal), + _is_number_close(12.34), +) +TIMESTAMP = ValueCase( + "pandas.Timestamp", + lambda: pd.Timestamp("2026-01-02 03:04:05"), + lambda value: isinstance(value, pd.Timestamp), + _is_iso_string_with("2026-01-02T03:04:05"), +) +TIMESTAMP_TZ = ValueCase( + "pandas.Timestamp.tz_aware", + lambda: pd.Timestamp("2026-01-02 03:04:05", tz="UTC"), + lambda value: isinstance(value, pd.Timestamp), + _is_iso_string_with("2026-01-02T03:04:05+00:00"), +) +PANDAS_NAT = ValueCase( + "pandas.NaT", + lambda: pd.NaT, + lambda value: value is pd.NaT, + _is_json_null, +) +PANDAS_NA = ValueCase( + "pandas.NA", + lambda: pd.NA, + lambda value: value is pd.NA, + _is_json_null, +) +PANDAS_TIMEDELTA = ValueCase( + "pandas.Timedelta", + lambda: pd.Timedelta("2 days 03:04:05"), + lambda value: isinstance(value, pd.Timedelta), + _is_iso_string_with("P2DT3H4M5S"), +) +NUMPY_DATETIME64 = ValueCase( + "numpy.datetime64", + lambda: np.datetime64("2026-01-02T03:04:05"), + lambda value: isinstance(value, np.datetime64), + _is_iso_string_with("2026-01-02T03:04:05"), +) +NUMPY_INT64 = ValueCase( + "numpy.int64", + lambda: np.int64(_EXPECTED_INT), + lambda value: isinstance(value, np.integer), + lambda value: value == _EXPECTED_INT, +) +NUMPY_UINT64 = ValueCase( + "numpy.uint64", + lambda: np.uint64(_EXPECTED_INT), + lambda value: isinstance(value, np.unsignedinteger), + lambda value: value == _EXPECTED_INT, +) +NUMPY_FLOAT32 = ValueCase( + "numpy.float32", + lambda: np.float32(12.5), + lambda value: isinstance(value, np.floating), + _is_number_close(12.5), +) +NUMPY_BOOL = ValueCase( + "numpy.bool_", + lambda: np.bool_(1), + lambda value: isinstance(value, np.bool_), + lambda value: value is True, +) +FLOAT_NAN = ValueCase("float.nan", lambda: float("nan"), _is_nan, _is_json_null) +FLOAT_INF = ValueCase( + "float.inf", + lambda: float("inf"), + _is_non_finite_float, + _is_json_null, +) +DATETIME = ValueCase( + "datetime.datetime", + lambda: dt.datetime(2026, 1, 2, 3, 4, 5), # noqa: DTZ001 + lambda value: isinstance(value, dt.datetime), + _is_iso_string_with("2026-01-02T03:04:05"), +) +DATE = ValueCase( + "datetime.date", + lambda: dt.date(2026, 1, 2), + lambda value: isinstance(value, dt.date) and not isinstance(value, dt.datetime), + _is_iso_string_with("2026-01-02"), +) +TIME = ValueCase( + "datetime.time", + lambda: dt.time(3, 4, 5), + lambda value: isinstance(value, dt.time), + _is_iso_string_with("03:04:05"), +) + + +# --------------------------------------------------------------------------- +# Figure locations +# --------------------------------------------------------------------------- + + +SCATTER_X = FigureLocationCase( + "scatter.x[0]", + lambda value: go.Figure(go.Scatter(x=[value, 2], y=[1, 2])), + ("data", 0, "x", 0), +) +SCATTER_Y = FigureLocationCase( + "scatter.y[0]", + lambda value: go.Figure(go.Scatter(x=[1, 2], y=[value, 2])), + ("data", 0, "y", 0), +) +SCATTER3D_X = FigureLocationCase( + "scatter3d.x[0]", + lambda value: go.Figure(go.Scatter3d(x=[value, 2], y=[1, 2], z=[1, 2])), + ("data", 0, "x", 0), +) +SCATTER3D_Y = FigureLocationCase( + "scatter3d.y[0]", + lambda value: go.Figure(go.Scatter3d(x=[1, 2], y=[value, 2], z=[1, 2])), + ("data", 0, "y", 0), +) +SCATTER3D_Z = FigureLocationCase( + "scatter3d.z[0]", + lambda value: go.Figure(go.Scatter3d(x=[1, 2], y=[1, 2], z=[value, 2])), + ("data", 0, "z", 0), +) +HEATMAP_Z = FigureLocationCase( + "heatmap.z[0][0]", + lambda value: go.Figure(go.Heatmap(z=[[value, 2], [3, 4]])), + ("data", 0, "z", 0, 0), +) +SURFACE_Z = FigureLocationCase( + "surface.z[0][0]", + lambda value: go.Figure(go.Surface(z=[[value, 2], [3, 4]])), + ("data", 0, "z", 0, 0), +) +CUSTOMDATA = FigureLocationCase( + "trace.customdata[0][0]", + lambda value: go.Figure(go.Scatter(x=[1], y=[1], customdata=[[value]])), + ("data", 0, "customdata", 0, 0), +) +TRACE_META = FigureLocationCase( + "trace.meta.special", + lambda value: go.Figure(go.Scatter(x=[1], y=[1], meta={"special": value})), + ("data", 0, "meta", "special"), +) +LAYOUT_META = FigureLocationCase( + "layout.meta.special", + lambda value: go.Figure(go.Scatter(x=[1], y=[1])).update_layout( + meta={"special": value}, + ), + ("layout", "meta", "special"), +) +ANNOTATION_X = FigureLocationCase( + "layout.annotation.x", + lambda value: go.Figure(go.Scatter(x=[1], y=[1])).add_annotation( + x=value, + y=1, + text="annotation", + ), + ("layout", "annotations", 0, "x"), +) +ANNOTATION_Y = FigureLocationCase( + "layout.annotation.y", + lambda value: go.Figure(go.Scatter(x=[1], y=[1])).add_annotation( + x=1, + y=value, + text="annotation", + ), + ("layout", "annotations", 0, "y"), +) +SCENE_ANNOTATION_X = FigureLocationCase( + "layout.scene.annotation.x", + lambda value: go.Figure(go.Scatter3d(x=[1], y=[1], z=[1])).update_layout( + scene={"annotations": [{"x": value, "y": 1, "z": 1, "text": "annotation"}]}, + ), + ("layout", "scene", "annotations", 0, "x"), +) +SCENE_ANNOTATION_Y = FigureLocationCase( + "layout.scene.annotation.y", + lambda value: go.Figure(go.Scatter3d(x=[1], y=[1], z=[1])).update_layout( + scene={"annotations": [{"x": 1, "y": value, "z": 1, "text": "annotation"}]}, + ), + ("layout", "scene", "annotations", 0, "y"), +) +SCENE_ANNOTATION_Z = FigureLocationCase( + "layout.scene.annotation.z", + lambda value: go.Figure(go.Scatter3d(x=[1], y=[1], z=[1])).update_layout( + scene={"annotations": [{"x": 1, "y": 1, "z": value, "text": "annotation"}]}, + ), + ("layout", "scene", "annotations", 0, "z"), +) +SHAPE_X0 = FigureLocationCase( + "layout.shape.x0", + lambda value: go.Figure(go.Scatter(x=[1, 2], y=[1, 2])).add_shape( + type="line", + x0=value, + x1=2, + y0=0, + y1=1, + ), + ("layout", "shapes", 0, "x0"), +) +SHAPE_X1 = FigureLocationCase( + "layout.shape.x1", + lambda value: go.Figure(go.Scatter(x=[1, 2], y=[1, 2])).add_shape( + type="line", + x0=1, + x1=value, + y0=0, + y1=1, + ), + ("layout", "shapes", 0, "x1"), +) +SHAPE_Y0 = FigureLocationCase( + "layout.shape.y0", + lambda value: go.Figure(go.Scatter(x=[1, 2], y=[1, 2])).add_shape( + type="line", + x0=1, + x1=2, + y0=value, + y1=1, + ), + ("layout", "shapes", 0, "y0"), +) +SHAPE_Y1 = FigureLocationCase( + "layout.shape.y1", + lambda value: go.Figure(go.Scatter(x=[1, 2], y=[1, 2])).add_shape( + type="line", + x0=1, + x1=2, + y0=0, + y1=value, + ), + ("layout", "shapes", 0, "y1"), +) +XAXIS_RANGE = FigureLocationCase( + "layout.xaxis.range[0]", + lambda value: go.Figure(go.Scatter(x=[1, 2], y=[1, 2])).update_layout( + xaxis_range=[value, value], + ), + ("layout", "xaxis", "range", 0), +) +YAXIS_RANGE = FigureLocationCase( + "layout.yaxis.range[0]", + lambda value: go.Figure(go.Scatter(x=[1, 2], y=[1, 2])).update_layout( + yaxis_range=[value, value], + ), + ("layout", "yaxis", "range", 0), +) +XAXIS_TICKVAL = FigureLocationCase( + "layout.xaxis.tickvals[0]", + lambda value: go.Figure(go.Scatter(x=[1, 2], y=[1, 2])).update_layout( + xaxis_tickvals=[value], + ), + ("layout", "xaxis", "tickvals", 0), +) +YAXIS_TICKVAL = FigureLocationCase( + "layout.yaxis.tickvals[0]", + lambda value: go.Figure(go.Scatter(x=[1, 2], y=[1, 2])).update_layout( + yaxis_tickvals=[value], + ), + ("layout", "yaxis", "tickvals", 0), +) +SCENE_XAXIS_RANGE = FigureLocationCase( + "layout.scene.xaxis.range[0]", + lambda value: go.Figure( + go.Scatter3d(x=[1, 2], y=[1, 2], z=[1, 2]), + ).update_layout(scene={"xaxis": {"range": [value, value]}}), + ("layout", "scene", "xaxis", "range", 0), +) +SCENE_YAXIS_RANGE = FigureLocationCase( + "layout.scene.yaxis.range[0]", + lambda value: go.Figure( + go.Scatter3d(x=[1, 2], y=[1, 2], z=[1, 2]), + ).update_layout(scene={"yaxis": {"range": [value, value]}}), + ("layout", "scene", "yaxis", "range", 0), +) +SCENE_ZAXIS_RANGE = FigureLocationCase( + "layout.scene.zaxis.range[0]", + lambda value: go.Figure( + go.Scatter3d(x=[1, 2], y=[1, 2], z=[1, 2]), + ).update_layout(scene={"zaxis": {"range": [value, value]}}), + ("layout", "scene", "zaxis", "range", 0), +) + +DATA_AND_LAYOUT_LOCATIONS = [ + SCATTER_X, + SCATTER_Y, + SCATTER3D_X, + SCATTER3D_Y, + SCATTER3D_Z, + HEATMAP_Z, + SURFACE_Z, + CUSTOMDATA, + TRACE_META, + LAYOUT_META, + ANNOTATION_X, + ANNOTATION_Y, + SCENE_ANNOTATION_X, + SCENE_ANNOTATION_Y, + SCENE_ANNOTATION_Z, + SHAPE_X0, + SHAPE_X1, + SHAPE_Y0, + SHAPE_Y1, +] +METADATA_AND_ANNOTATION_LOCATIONS = [ + TRACE_META, + LAYOUT_META, + ANNOTATION_X, + ANNOTATION_Y, + SCENE_ANNOTATION_X, + SCENE_ANNOTATION_Y, + SCENE_ANNOTATION_Z, +] +RANGE_LOCATIONS = [ + XAXIS_RANGE, + YAXIS_RANGE, + XAXIS_TICKVAL, + YAXIS_TICKVAL, + SCENE_XAXIS_RANGE, + SCENE_YAXIS_RANGE, + SCENE_ZAXIS_RANGE, +] + + +def _make_cases( + values: list[ValueCase], + locations: list[FigureLocationCase], +) -> list[SerializationCase]: + """Build value/location cases for pytest parametrization. + + Args: + values: Values to combine with each provided location. + locations: Figure-spec locations to combine with each provided value. + + Returns: + List of value/location serialization cases. + """ + return [ + SerializationCase(value=value, location=location) + for value in values + for location in locations + ] + + +SUPPORTED_SCALAR_CASES = [ + *_make_cases( + [ + DECIMAL, + TIMESTAMP, + TIMESTAMP_TZ, + PANDAS_NAT, + PANDAS_NA, + PANDAS_TIMEDELTA, + FLOAT_NAN, + FLOAT_INF, + DATETIME, + DATE, + TIME, + ], + DATA_AND_LAYOUT_LOCATIONS, + ), + *_make_cases( + [NUMPY_DATETIME64, NUMPY_INT64, NUMPY_UINT64, NUMPY_FLOAT32, NUMPY_BOOL], + METADATA_AND_ANNOTATION_LOCATIONS, + ), + *_make_cases([DECIMAL, TIMESTAMP, TIMESTAMP_TZ, DATETIME, DATE], RANGE_LOCATIONS), +] + + +# --------------------------------------------------------------------------- +# Container cases +# --------------------------------------------------------------------------- + + +DATETIME_SERIES = ValueCase( + "Series[datetime64]", + lambda: pd.Series(pd.to_datetime(["2026-01-01", "2026-01-02"])), + _is_datetime_container, + _is_decoded_list_with_prefix("2026-01-01"), +) +DATETIME_INDEX = ValueCase( + "DatetimeIndex", + lambda: pd.DatetimeIndex(["2026-01-01", "2026-01-02"]), + _is_datetime_container, + _is_decoded_list_with_prefix("2026-01-01"), +) +NDARRAY_DATETIME64 = ValueCase( + "ndarray[datetime64]", + lambda: np.array(["2026-01-01", "2026-01-02"], dtype="datetime64[ns]"), + _is_datetime_container, + _is_decoded_list_with_prefix("2026-01-01"), +) +OBJECT_ARRAY_TIMESTAMP = ValueCase( + "ndarray[object_Timestamp]", + lambda: np.array( + [pd.Timestamp("2026-01-01"), pd.Timestamp("2026-01-02")], + dtype=object, + ), + _container_has_type(pd.Timestamp), + _is_decoded_list_with_prefix("2026-01-01"), +) +SERIES_DECIMAL = ValueCase( + "Series[object_Decimal]", + lambda: pd.Series([decimal.Decimal("1.1"), decimal.Decimal("2.2")]), + _container_has_type(decimal.Decimal), + _is_decoded_list_of_numbers, +) +SERIES_BOOLEAN_NA = ValueCase( + "Series[boolean_with_NA]", + lambda: pd.Series([True, pd.NA], dtype="boolean"), + _container_has_type(type(pd.NA)), + _is_decoded_sequence_containing_null, +) +SERIES_STRING_NA = ValueCase( + "Series[string_with_NA]", + lambda: pd.Series(["a", pd.NA], dtype="string"), + _container_has_type(type(pd.NA)), + _is_decoded_sequence_containing_null, +) +SERIES_INT64_NA = ValueCase( + "Series[Int64_with_NA]", + lambda: pd.Series([1, pd.NA], dtype="Int64"), + _is_nullable_int_container, + _is_decoded_nullable_int, +) +CATEGORICAL = ValueCase( + "pandas.Categorical", + lambda: pd.Categorical(["a", None, "b"]), + _is_categorical_or_equivalent, + _is_decoded_sequence_containing_null, +) + +CONTAINER_X = FigureLocationCase( + "scatter.x", + lambda value: go.Figure(go.Scatter(x=value, y=[1, 2, 3][: len(value)])), + ("data", 0, "x"), +) +CONTAINER_Y = FigureLocationCase( + "scatter.y", + lambda value: go.Figure(go.Scatter(x=list(range(1, len(value) + 1)), y=value)), + ("data", 0, "y"), +) +CONTAINER_Z3D = FigureLocationCase( + "scatter3d.z", + lambda value: go.Figure( + go.Scatter3d(x=list(range(1, len(value) + 1)), y=[1] * len(value), z=value), + ), + ("data", 0, "z"), +) +CONTAINER_CUSTOMDATA = FigureLocationCase( + "trace.customdata", + lambda value: go.Figure( + go.Scatter( + x=list(range(1, len(value) + 1)), + y=[1] * len(value), + customdata=value, + ), + ), + ("data", 0, "customdata"), +) +CONTAINER_TRACE_META = FigureLocationCase( + "trace.meta.special", + lambda value: go.Figure(go.Scatter(x=[1], y=[1], meta={"special": value})), + ("data", 0, "meta", "special"), +) +CONTAINER_LAYOUT_META = FigureLocationCase( + "layout.meta.special", + lambda value: go.Figure(go.Scatter(x=[1], y=[1])).update_layout( + meta={"special": value}, + ), + ("layout", "meta", "special"), +) + +SUPPORTED_CONTAINER_CASES = _make_cases( + [ + DATETIME_SERIES, + DATETIME_INDEX, + NDARRAY_DATETIME64, + OBJECT_ARRAY_TIMESTAMP, + SERIES_DECIMAL, + SERIES_BOOLEAN_NA, + SERIES_STRING_NA, + SERIES_INT64_NA, + CATEGORICAL, + ], + [ + CONTAINER_X, + CONTAINER_Y, + CONTAINER_Z3D, + CONTAINER_CUSTOMDATA, + CONTAINER_TRACE_META, + CONTAINER_LAYOUT_META, + ], +) + +UNSUPPORTED_CASES = [ + UnsupportedCase("numpy.timedelta64", lambda: np.timedelta64(2, "D")), + UnsupportedCase("datetime.timedelta", lambda: dt.timedelta(days=2, seconds=3)), + UnsupportedCase("pandas.Period", lambda: pd.Period("2026-01", freq="M")), + UnsupportedCase("pandas.Interval", lambda: pd.Interval(1, 2)), + UnsupportedCase("python.complex", lambda: complex(1, 2)), + UnsupportedCase("numpy.complex128", lambda: np.complex128(1 + 2j)), +] diff --git a/src/py/tests/test_plotly_json_compatible_serialization.py b/src/py/tests/test_plotly_json_compatible_serialization.py new file mode 100644 index 00000000..9f767c80 --- /dev/null +++ b/src/py/tests/test_plotly_json_compatible_serialization.py @@ -0,0 +1,381 @@ +"""Test Kaleido v1 pre-render serialization for Plotly-compatible values. + +Technical specification +======================= + +Purpose +------- +This module is intended for the current ``plotly/Kaleido`` Python test suite, +where Kaleido v1 serializes the Plotly figure spec inside the Python process +before sending it to the browser/JavaScript render boundary. It is designed to +catch regressions where Kaleido's pre-render serializer is narrower than +Plotly's own JSON serialization contract. + +The compatibility contract tested here is deliberately narrow and reproducible: +for every parametrized value/location pair, ``fig.to_dict()`` may contain a +non-JSON-native Python object, but ``json.dumps(fig.to_dict(), +cls=PlotlyJSONEncoder)`` must succeed and must decode to a JSON-compatible +representation at the same figure-spec path. If Plotly's encoder accepts that +raw figure spec, Kaleido's pre-render static-export path should not fail while +serializing the same spec. + +Why this module does not perform real image export +------------------------------------------------- +The test matrix is intentionally broad and covers many figure-spec locations: +2D trace ``x``/``y`` data, 3D trace ``x``/``y``/``z`` data, matrix trace +``z`` data, ``customdata``, trace and layout ``meta``, 2D annotations, +3D scene annotations, individual shape coordinates, axis ranges, and +representative axis tick values. Rendering all combinations through a real +browser would be slow and would also mix serialization failures with unrelated +Plotly.js rendering semantics. For example, some serialized annotation +coordinates can be valid JSON but not meaningful visual coordinates for the +constructed trace, which can fail later in SVG generation for reasons unrelated +to Python-side JSON serialization. + +Instead, these tests mock the JavaScript/devtools call made by +``_KaleidoTab._calc_fig``. The mocked tests still execute the Python-side +pre-render serialization code used by image export, including Kaleido's +``orjson.dumps(..., default=_orjson_default, option=OPT_SERIALIZE_NUMPY)`` call, +but they do not launch Chrome and do not require an actual SVG render. This is +aligned with Kaleido's existing testing guidance that large parametrized real +renders are burdensome and that mocked integration tests provide the most useful +coverage for substantial internal behavior. + +Case matrix +----------- +The shared matrix in ``_plotly_json_compat_cases.py`` covers scalar values and +containers commonly produced by pandas, NumPy, datetime, and Decimal workflows. +It includes currently supported Plotly JSON cases such as ``decimal.Decimal``, +``pandas.Timestamp``, ``pandas.NaT``, ``pandas.NA``, ``pandas.Timedelta``, NumPy +scalar values, non-finite floats, Python datetime/date/time values, pandas +``Series`` and ``DatetimeIndex`` containers, NumPy datetime/object arrays, +nullable pandas extension dtypes, and ``pandas.Categorical``. Text fields are +not part of the broad matrix because Plotly graph objects often coerce them to +strings before JSON serialization, so they do not reliably exercise Kaleido's +non-native-value serializer. + +The helper matrix also records common scientific-Python values that do not yet +have an established Plotly JSON compatibility contract, including +``numpy.timedelta64``, ``datetime.timedelta``, ``pandas.Period``, +``pandas.Interval``, and complex values. Those are kept in explicit xfail tests +so maintainers can see the boundary between restoring historical Plotly-compatible +behavior and defining new behavior. + +Assertions +---------- +Each supported case is checked at three scopes: + +1. Plotly baseline: the raw figure spec contains the value at the intended path, + allowing for Plotly's version-specific normalization of containers, and + ``PlotlyJSONEncoder`` converts it to an expected JSON-compatible value. +2. Cleaning proof: the Plotly-cleaned spec serializes with Kaleido's v1 orjson + configuration. This verifies that successful cleaning is enough to satisfy + Kaleido's lower-level serializer. +3. Mocked Kaleido integration: ``_KaleidoTab._calc_fig`` is called with the raw + ``fig.to_dict()`` spec while only the browser/JavaScript call is mocked. This + is the regression check: before the serialization fix, cases such as + ``decimal.Decimal`` and ``pandas.Timestamp`` fail before reaching the mocked + JavaScript boundary; after the fix, they should pass. + +What this module is not +----------------------- +This module is not a Plotly 5 / Kaleido 0.x compatibility test. Kaleido 0.x used +``kaleido.scopes.plotly.PlotlyScope`` and delegated serialization to +``plotly.io.to_json``; Kaleido v1 removed that public package layout and now has +a different pre-render path. A separate historical demonstration module can be +used to show the old behavior, but this file should target the current Kaleido +main branch. +""" + +from __future__ import annotations + +import asyncio +import json +from typing import Any + +import pytest +from _plotly_json_compat_cases import ( + _MISSING, + SUPPORTED_CONTAINER_CASES, + SUPPORTED_SCALAR_CASES, + UNSUPPORTED_CASES, + SerializationCase, + UnsupportedCase, + assert_plotly_encoder_accepts_case, + get_path, + plotly_cleaned_spec, +) +from plotly.utils import PlotlyJSONEncoder + +kaleido_tab = pytest.importorskip( + "kaleido._kaleido_tab._tab", + reason="Kaleido v1 _KaleidoTab serialization path is required.", +) + +_DUMMY_SVG = "" + + +class DummyProfileLog: + """Small profile-log replacement used by mocked ``_calc_fig`` tests.""" + + def __init__(self) -> None: + """Initialize an empty list of observed profiling ticks.""" + self.ticks: list[str] = [] + + def tick(self, message: str) -> None: + """Record a profile tick message. + + Args: + message: Profile event message emitted by Kaleido internals. + """ + self.ticks.append(message) + + +class DummyRenderProfile: + """Small render-profile replacement used by mocked ``_calc_fig`` tests.""" + + def __init__(self) -> None: + """Initialize fields mutated by Kaleido's private ``_calc_fig`` method.""" + self.profile_log = DummyProfileLog() + self.data_out_size = 0 + self.js_log: list[str] = [] + + +class DummyJsLogger: + """Small JavaScript logger replacement used by mocked ``_calc_fig`` tests.""" + + def __init__(self) -> None: + """Initialize an empty JavaScript log field.""" + self.log: list[str] = [] + + +class DummyKaleidoTab: + """Object carrying the attributes needed by ``_KaleidoTab._calc_fig``.""" + + def __init__(self) -> None: + """Initialize the minimal attributes read by the private method.""" + self.tab = object() + self._current_js_id = "mocked-js-id" + self.js_logger = DummyJsLogger() + + +async def _fake_exec_js_fn(*_args: Any, **_kwargs: Any) -> dict[str, Any]: + """Return a minimal successful Kaleido JavaScript response. + + Args: + *_args: Positional arguments accepted by the real devtools helper. + **_kwargs: Keyword arguments accepted by the real devtools helper. + + Returns: + Nested response dictionary consumed by ``check_kaleido_js_response``. + """ + value = json.dumps({"code": 0, "format": "svg", "result": _DUMMY_SVG}) + return {"result": {"result": {"value": value}}} + + +def kaleido_prerender_serialize(spec: dict[str, Any]) -> bytes: + """Serialize a spec the same way Kaleido v1 serializes before rendering. + + Args: + spec: Plotly figure spec passed to Kaleido's browser/render boundary. + + Returns: + UTF-8 JSON bytes produced by Kaleido's ``orjson`` configuration. + """ + return kaleido_tab.orjson.dumps( + spec, + default=kaleido_tab._orjson_default, # noqa: SLF001 + option=kaleido_tab.orjson.OPT_SERIALIZE_NUMPY, + ) + + +def _mock_browser_boundary(monkeypatch: pytest.MonkeyPatch) -> None: + """Replace the JavaScript/devtools boundary with a deterministic response. + + Args: + monkeypatch: Pytest fixture used to replace the browser call. + """ + monkeypatch.setattr( + kaleido_tab._dtools, # noqa: SLF001 + "exec_js_fn", + _fake_exec_js_fn, + ) + + +def _calc_fig_without_browser(case: SerializationCase) -> bytes: + """Run Kaleido's v1 pre-render path with the browser boundary mocked. + + Args: + case: Supported value/location pair under test. + + Returns: + Bytes returned by ``_KaleidoTab._calc_fig`` after the mocked boundary. + """ + return asyncio.run( + kaleido_tab._KaleidoTab._calc_fig( # noqa: SLF001 + DummyKaleidoTab(), + case.location.build(case.value.make()).to_dict(), + topojson=None, + render_prof=DummyRenderProfile(), + stepper=None, + ), + ) + + +@pytest.mark.parametrize("case", SUPPORTED_SCALAR_CASES, ids=lambda case: case.id) +class TestPlotlyCompatibleScalarValues: + """Regression tests for Plotly-serializable scalar values.""" + + def test_plotly_json_encoder_baseline_accepts_scalar_case( + self, + case: SerializationCase, + ) -> None: + """Verify Plotly's encoder accepts this scalar/location pair. + + Args: + case: Supported scalar value and figure-spec location under test. + """ + assert_plotly_encoder_accepts_case(case) + + def test_plotly_cleaned_scalar_case_serializes_with_kaleido_orjson( + self, + case: SerializationCase, + ) -> None: + """Verify Plotly-cleaned scalar specs are serializable by Kaleido. + + Args: + case: Supported scalar value and figure-spec location under test. + """ + fig = case.location.build(case.value.make()) + cleaned_spec = plotly_cleaned_spec(fig) + encoded = kaleido_prerender_serialize(cleaned_spec) + + assert isinstance(encoded, bytes) + assert json.loads(encoded) + + def test_kaleido_mocked_calc_fig_accepts_scalar_case( + self, + case: SerializationCase, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Verify Kaleido's pre-render path serializes this scalar case. + + Args: + case: Supported scalar value and figure-spec location under test. + monkeypatch: Pytest fixture used to replace the browser call. + """ + _mock_browser_boundary(monkeypatch) + + result = _calc_fig_without_browser(case) + + assert result == _DUMMY_SVG.encode() + + +@pytest.mark.parametrize("case", SUPPORTED_CONTAINER_CASES, ids=lambda case: case.id) +class TestPlotlyCompatibleContainerValues: + """Regression tests for pandas and NumPy containers in figure specs.""" + + def test_plotly_json_encoder_baseline_accepts_container_case( + self, + case: SerializationCase, + ) -> None: + """Verify Plotly's encoder accepts this container/location pair. + + Args: + case: Supported pandas/NumPy container and figure-spec location. + """ + assert_plotly_encoder_accepts_case(case) + + def test_plotly_cleaned_container_case_serializes_with_kaleido_orjson( + self, + case: SerializationCase, + ) -> None: + """Verify Plotly-cleaned container specs are serializable by Kaleido. + + Args: + case: Supported pandas/NumPy container and figure-spec location. + """ + fig = case.location.build(case.value.make()) + cleaned_spec = plotly_cleaned_spec(fig) + encoded = kaleido_prerender_serialize(cleaned_spec) + + assert isinstance(encoded, bytes) + assert json.loads(encoded) + + def test_kaleido_mocked_calc_fig_accepts_container_case( + self, + case: SerializationCase, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Verify Kaleido's pre-render path serializes this container case. + + Args: + case: Supported pandas/NumPy container and figure-spec location. + monkeypatch: Pytest fixture used to replace the browser call. + """ + _mock_browser_boundary(monkeypatch) + + result = _calc_fig_without_browser(case) + + assert result == _DUMMY_SVG.encode() + + +@pytest.mark.parametrize("unsupported", UNSUPPORTED_CASES, ids=lambda case: case.name) +class TestCurrentlyUnsupportedScientificPythonValues: + """Document common values outside Plotly's current JSON contract.""" + + def test_plotly_json_encoder_does_not_currently_support_value( + self, + unsupported: UnsupportedCase, + ) -> None: + """Verify unsupported values are not silently treated as supported. + + Args: + unsupported: Common but currently unsupported scientific-Python value. + """ + import plotly.graph_objects as go # noqa: PLC0415 + + fig = go.Figure( + go.Scatter(x=[1], y=[1], meta={"special": unsupported.make()}), + ) + raw_value = get_path(fig.to_dict(), ("data", 0, "meta", "special")) + + assert raw_value is not _MISSING + with pytest.raises(TypeError, match=r"not JSON serializable|JSON"): + json.dumps(fig.to_dict(), cls=PlotlyJSONEncoder) + + @pytest.mark.xfail( + reason=( + "No established Plotly JSON compatibility contract for this common " + "scientific-Python value." + ), + strict=True, + ) + def test_kaleido_mocked_calc_fig_for_currently_unsupported_value( + self, + unsupported: UnsupportedCase, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Document Kaleido behavior for currently unsupported value types. + + Args: + unsupported: Common but currently unsupported scientific-Python value. + monkeypatch: Pytest fixture used to replace the browser call. + """ + import plotly.graph_objects as go # noqa: PLC0415 + + _mock_browser_boundary(monkeypatch) + fig = go.Figure( + go.Scatter(x=[1], y=[1], meta={"special": unsupported.make()}), + ) + result = asyncio.run( + kaleido_tab._KaleidoTab._calc_fig( # noqa: SLF001 + DummyKaleidoTab(), + fig.to_dict(), + topojson=None, + render_prof=DummyRenderProfile(), + stepper=None, + ), + ) + + assert result == _DUMMY_SVG.encode()