Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions src/py/kaleido/_json.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
from __future__ import annotations

import json
from decimal import Decimal
from typing import Any, Union

try:
import orjson
except ImportError: # pragma: no cover - exercised when orjson is unavailable
orjson = None


def default(obj: Any) -> Any:
"""Fallback for types the active JSON backend can't handle natively."""
if isinstance(obj, Decimal):
return float(obj)
if hasattr(obj, "isoformat"): # datetime-like, e.g. pandas Timestamp (#458)
return obj.isoformat()
if hasattr(obj, "tolist"):
return obj.tolist()
raise TypeError(f"Type is not JSON serializable: {type(obj).__name__}")


def dumps(obj: Any) -> str:
if orjson is not None:
return orjson.dumps(
obj,
default=default,
option=orjson.OPT_SERIALIZE_NUMPY,
).decode()
return json.dumps(obj, default=default, separators=(",", ":"))


def loads(value: Union[str, bytes, bytearray]) -> Any:
if orjson is not None:
return orjson.loads(value)
return json.loads(value)
21 changes: 3 additions & 18 deletions src/py/kaleido/_kaleido_tab/_tab.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
from __future__ import annotations

import base64
from decimal import Decimal
from typing import TYPE_CHECKING

import logistro
import orjson

from kaleido._json import default as _orjson_default
from kaleido._json import dumps as _json_dumps
from . import _devtools_utils as _dtools
from . import _js_logger
from ._errors import _raise_error
Expand All @@ -26,17 +26,6 @@
_logger = logistro.getLogger(__name__)


def _orjson_default(obj):
"""Fallback for types orjson can't handle natively (e.g. NumPy string arrays)."""
if isinstance(obj, Decimal):
return float(obj)
if hasattr(obj, "isoformat"): # datetime-like, e.g. pandas Timestamp (#458)
return obj.isoformat()
if hasattr(obj, "tolist"):
return obj.tolist()
raise TypeError(f"Type is not JSON serializable: {type(obj).__name__}")


def _subscribe_new(tab: choreo.Tab, event: str) -> asyncio.Future:
"""Create subscription to tab clearing old ones first: helper function."""
new_future = tab.subscribe_once(event)
Expand Down Expand Up @@ -153,11 +142,7 @@ async def _calc_fig(
stepper,
) -> bytes:
render_prof.profile_log.tick("serializing spec")
spec_str = orjson.dumps(
spec,
default=_orjson_default,
option=orjson.OPT_SERIALIZE_NUMPY,
).decode()
spec_str = _json_dumps(spec)
render_prof.profile_log.tick("spec serialized")

render_prof.profile_log.tick("sending javascript")
Expand Down
4 changes: 2 additions & 2 deletions src/py/kaleido/mocker/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
from typing import TYPE_CHECKING, TypedDict

import logistro
import orjson

from kaleido._json import loads as _json_loads
from ._args import args

if TYPE_CHECKING:
Expand Down Expand Up @@ -45,7 +45,7 @@ def load_figures_from_paths(paths: list[Path]) -> Generator[FigureDict, None]:
raise RuntimeError(f"Path {path} is not a file.")
_logger.info(f"Found file: {path!s}")
with path.open(encoding="utf-8") as file:
figure = orjson.loads(file.read())
figure = _json_loads(file.read())
for f, w, h, s in itertools.product( # all combos
args.format,
args.width,
Expand Down
1 change: 0 additions & 1 deletion src/py/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ maintainers = [
dependencies = [
"choreographer>=1.3.0",
"logistro>=1.0.8",
"orjson>=3.10.15",
"packaging",
]

Expand Down
6 changes: 3 additions & 3 deletions src/py/tests/test_kaleido_tab.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
from decimal import Decimal

import orjson
import plotly.graph_objects as go

from kaleido._kaleido_tab._tab import _orjson_default
from kaleido._json import dumps as _json_dumps
from kaleido._utils import fig_tools


def test_orjson_default_handles_decimal():
fig = go.Figure(data=[go.Bar(y=[Decimal("10.5"), Decimal(20), Decimal("-3.25")])])
spec = fig_tools.coerce_for_js(fig, None, {"format": "json"})

encoded = orjson.dumps(spec, default=_orjson_default)
encoded = _json_dumps(spec)

assert b'"y":[10.5,20.0,-3.25]' in encoded
assert '"y":[10.5,20.0,-3.25]' in encoded
27 changes: 21 additions & 6 deletions src/py/tests/test_orjson_encoder.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@
from decimal import Decimal

import numpy as np
import orjson
import pandas as pd

import kaleido._json as kaleido_json
from kaleido._kaleido_tab._tab import _orjson_default
from kaleido._json import dumps as _json_dumps


def test_orjson_default_handles_datetime_like():
Expand All @@ -23,15 +24,29 @@ def test_orjson_default_handles_datetime_like():
assert _orjson_default(tz_ts) == tz_ts.isoformat()
assert _orjson_default(tz_ts).endswith("+00:00")

# A figure spec carrying a Timestamp now round-trips through orjson.
# A figure spec carrying a Timestamp now round-trips through the JSON backend.
spec = {"x": [ts]}
dumped = orjson.dumps(
spec, default=_orjson_default, option=orjson.OPT_SERIALIZE_NUMPY
)
assert ts.isoformat().encode() in dumped
dumped = _json_dumps(spec)
assert ts.isoformat() in dumped

# Existing fallbacks are unaffected.
decimal_value = Decimal("1.5")
assert _orjson_default(decimal_value) == float(decimal_value)
array_values = [1, 2, 3]
assert _orjson_default(np.array(array_values)) == array_values


def test_json_backend_falls_back_without_orjson(monkeypatch):
monkeypatch.setattr(kaleido_json, "orjson", None)

dumped = _json_dumps(
{
"date": datetime.date(2026, 1, 2),
"decimal": Decimal("1.5"),
"array": np.array([1, 2, 3]),
}
)

assert '"date":"2026-01-02"' in dumped
assert '"decimal":1.5' in dumped
assert '"array":[1,2,3]' in dumped
3 changes: 0 additions & 3 deletions src/py/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.