diff --git a/src/py/kaleido/_json.py b/src/py/kaleido/_json.py new file mode 100644 index 00000000..4f62b8b2 --- /dev/null +++ b/src/py/kaleido/_json.py @@ -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) diff --git a/src/py/kaleido/_kaleido_tab/_tab.py b/src/py/kaleido/_kaleido_tab/_tab.py index 800676ea..bff01323 100644 --- a/src/py/kaleido/_kaleido_tab/_tab.py +++ b/src/py/kaleido/_kaleido_tab/_tab.py @@ -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 @@ -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) @@ -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") diff --git a/src/py/kaleido/mocker/_utils.py b/src/py/kaleido/mocker/_utils.py index d18d9e60..19ad2f97 100644 --- a/src/py/kaleido/mocker/_utils.py +++ b/src/py/kaleido/mocker/_utils.py @@ -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: @@ -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, diff --git a/src/py/pyproject.toml b/src/py/pyproject.toml index dbc76169..45a4a13d 100644 --- a/src/py/pyproject.toml +++ b/src/py/pyproject.toml @@ -28,7 +28,6 @@ maintainers = [ dependencies = [ "choreographer>=1.3.0", "logistro>=1.0.8", - "orjson>=3.10.15", "packaging", ] diff --git a/src/py/tests/test_kaleido_tab.py b/src/py/tests/test_kaleido_tab.py index ee8e10f0..0e1a4242 100644 --- a/src/py/tests/test_kaleido_tab.py +++ b/src/py/tests/test_kaleido_tab.py @@ -1,9 +1,9 @@ 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 @@ -11,6 +11,6 @@ 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 diff --git a/src/py/tests/test_orjson_encoder.py b/src/py/tests/test_orjson_encoder.py index eb4b8133..1d431d78 100644 --- a/src/py/tests/test_orjson_encoder.py +++ b/src/py/tests/test_orjson_encoder.py @@ -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(): @@ -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 diff --git a/src/py/uv.lock b/src/py/uv.lock index 1d816a14..5a5cb586 100644 --- a/src/py/uv.lock +++ b/src/py/uv.lock @@ -481,8 +481,6 @@ source = { editable = "." } dependencies = [ { name = "choreographer" }, { name = "logistro" }, - { name = "orjson", version = "3.10.15", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "orjson", version = "3.11.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, { name = "packaging" }, ] @@ -527,7 +525,6 @@ pickles = [ requires-dist = [ { name = "choreographer", specifier = ">=1.3.0" }, { name = "logistro", specifier = ">=1.0.8" }, - { name = "orjson", specifier = ">=3.10.15" }, { name = "packaging" }, ]