Skip to content

v1.3 serialization regression #465

Description

@awiol

TL;DR

Static image export regressed in Kaleido 1.3.0 for Plotly figures containing scalar non-JSON-native objects that Plotly’s PlotlyJSONEncoder can serialize, such as pandas.Timestamp. The raw figure spec may legitimately contain such objects after fig.to_dict(). Plotly’s JSON encoder accepts them, but Kaleido 1.3.0 serializes the spec with orjson.dumps(...) and a narrow fallback that only handles .tolist(). This breaks fig.to_image() / fig.write_image() for figures that worked with Plotly 5 + Kaleido 0.2.1.

Related: appears that same root cause is responsible for #453

I have been using Plotly+Kaleido for image export quite some time. And after migrating to Plotly 6 recently (skipped earlier minor versions), I noticed that some plots are not getting exported into image format, causing this error:

.../lib/python3.13/site-packages/kaleido/_kaleido_tab/_tab.py", line 151, in _calc_fig
    spec_str = orjson.dumps(
               ~~~~~~~~~~~~^
        spec,
        ^^^^^
        default=_orjson_default,
        ^^^^^^^^^^^^^^^^^^^^^^^^
        option=orjson.OPT_SERIALIZE_NUMPY,
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    ).decode()
    ^
TypeError: Type is not JSON serializable: Timestamp

Despite json type error being as always not very informative, I figured the only candidate is pandas.Timestamp that are present in the dataframe I feed to Plotly express.
So I went onto a bit of a journey, with some help of LLM (disclaimer: I ran all the code it gave and reviewed what it said).

Here's a code that tries a few different Figures to reveal possible rootcause:

import json
import sys
from importlib.metadata import PackageNotFoundError, version
from typing import Any

import pandas as pd
import plotly.express as px
import plotly.graph_objects as go
from plotly.utils import PlotlyJSONEncoder


JsonPath = tuple[str | int, ...]


def package_version(package_name: str) -> str:
    """Return the installed package version, or '<not installed>'."""

    try:
        return version(package_name)
    except PackageNotFoundError:
        return "<not installed>"


def print_versions() -> None:
    """Print the interpreter and relevant package versions."""

    versions = {
        "python": sys.version.split()[0],
        "plotly": package_version("plotly"),
        "kaleido": package_version("kaleido"),
        "orjson": package_version("orjson"),
        "pandas": package_version("pandas"),
    }

    print("versions:", versions)


def timestamp_paths(obj: Any, path: JsonPath = ()) -> list[JsonPath]:
    """Return paths where pandas Timestamp objects occur inside a nested object."""

    if isinstance(obj, pd.Timestamp):
        return [path]

    if isinstance(obj, dict):
        paths: list[JsonPath] = []

        for key, value in obj.items():
            paths.extend(timestamp_paths(value, (*path, key)))

        return paths

    if isinstance(obj, list | tuple):
        paths = []

        for index, value in enumerate(obj):
            paths.extend(timestamp_paths(value, (*path, index)))

        return paths

    return []


def probe_orjson_if_available(spec: dict[str, Any]) -> str:
    """Probe Kaleido-1.3-style orjson serialization if orjson is installed."""

    try:
        import orjson
    except ImportError:
        return "SKIP: orjson is not installed"

    def default(obj: Any) -> Any:
        if hasattr(obj, "tolist"):
            return obj.tolist()

        raise TypeError(f"Type is not JSON serializable: {type(obj).__name__}")

    try:
        orjson.dumps(
            spec,
            default=default,
            option=orjson.OPT_SERIALIZE_NUMPY,
        )
    except Exception as exc:
        return f"FAIL: {type(exc).__name__}: {exc}"

    return "OK"


def probe_figure(name: str, fig: go.Figure) -> None:
    """Print serialization and image-export diagnostics for one figure."""

    spec = fig.to_dict()

    print(f"\n{name}")
    print("-" * len(name))

    paths = timestamp_paths(spec)
    print("pd.Timestamp paths:", paths or "<none>")

    try:
        json.dumps(spec, cls=PlotlyJSONEncoder)
    except Exception as exc:
        print(f"PlotlyJSONEncoder: FAIL: {type(exc).__name__}: {exc}")
    else:
        print("PlotlyJSONEncoder: OK")

    print(f"Kaleido-1.3-style orjson: {probe_orjson_if_available(spec)}")

    try:
        fig.to_image(format="svg")
    except Exception as exc:
        print(f"fig.to_image(svg): FAIL: {type(exc).__name__}: {exc}")
    else:
        print("fig.to_image(svg): OK")


def main() -> None:
    """Run Plotly/Kaleido Timestamp serialization probes."""

    print_versions()

    probe_figure(
        "scalar Timestamp in trace list",
        go.Figure(
            go.Scatter(
                x=[pd.Timestamp("2024-01-01")],
                y=[1],
            )
        ),
    )

    frame = pd.DataFrame(
        {
            "time": pd.to_datetime(["2024-01-01", "2024-01-02"]),
            "value": [1, 2],
        }
    )

    probe_figure(
        "datetime64 DataFrame column via Plotly Express",
        px.line(frame, x="time", y="value"),
    )

    fig = px.line(frame, x="time", y="value")
    fig.add_vline(x=pd.Timestamp("2024-01-02"))

    probe_figure(
        "scalar Timestamp in layout shape",
        fig,
    )


if __name__ == "__main__":
    main()

I have two environments, one still on Plotly 5, and a newer one with Plotly 6 and orjson, below is output of the script from running in them:

versions: {'python': '3.13.2', 'plotly': '5.24.1', 'kaleido': '0.2.1', 'orjson': '<not installed>', 'pandas': '2.2.3'}

scalar Timestamp in trace list
------------------------------
pd.Timestamp paths: [('data', 0, 'x', 0)]
PlotlyJSONEncoder: OK
Kaleido-1.3-style orjson: SKIP: orjson is not installed
fig.to_image(svg): OK

datetime64 DataFrame column via Plotly Express
----------------------------------------------
pd.Timestamp paths: <none>
PlotlyJSONEncoder: OK
Kaleido-1.3-style orjson: SKIP: orjson is not installed
fig.to_image(svg): OK

scalar Timestamp in layout shape
--------------------------------
pd.Timestamp paths: [('layout', 'shapes', 0, 'x0'), ('layout', 'shapes', 0, 'x1')]
PlotlyJSONEncoder: OK
Kaleido-1.3-style orjson: SKIP: orjson is not installed
fig.to_image(svg): OK

and

versions: {'python': '3.13.13', 'plotly': '6.8.0', 'kaleido': '1.3.0', 'orjson': '3.11.9', 'pandas': '2.3.3'}

scalar Timestamp in trace list
------------------------------
pd.Timestamp paths: [('data', 0, 'x', 0)]
PlotlyJSONEncoder: OK
Kaleido-1.3-style orjson: FAIL: TypeError: Type is not JSON serializable: Timestamp
fig.to_image(svg): FAIL: TypeError: Type is not JSON serializable: Timestamp

datetime64 DataFrame column via Plotly Express
----------------------------------------------
pd.Timestamp paths: <none>
PlotlyJSONEncoder: OK
Kaleido-1.3-style orjson: OK
fig.to_image(svg): OK

scalar Timestamp in layout shape
--------------------------------
pd.Timestamp paths: [('layout', 'shapes', 0, 'x0'), ('layout', 'shapes', 0, 'x1')]
PlotlyJSONEncoder: OK
Kaleido-1.3-style orjson: FAIL: TypeError: Type is not JSON serializable: Timestamp
fig.to_image(svg): FAIL: TypeError: Type is not JSON serializable: Timestamp

Despite this being result of interplay between Plotly, kaleido and orjson, it seems most reasonable to create issue here.

Yes, I am aware that a the end of day, encountering this can be avoided on user side by tending to DataFrame or cleaning Figure before export.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions