Skip to content

Commit af3f76f

Browse files
Make orjson an optional dependency (closes #423)
Move orjson from required dependencies to a 'orjson' optional extra, with a stdlib json fallback in _kaleido_tab/_tab.py and mocker/_utils.py. Users who want the fast path: pip install kaleido[orjson] Default install no longer pulls orjson, which fixes installation on Python 3.14t and in environments that prefer minimal dependencies.
1 parent b7a00c4 commit af3f76f

3 files changed

Lines changed: 76 additions & 33 deletions

File tree

src/py/kaleido/_kaleido_tab/_tab.py

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
from __future__ import annotations
22

33
import base64
4-
from typing import TYPE_CHECKING
4+
import json as _stdlib_json
5+
from typing import TYPE_CHECKING, Any
56

67
import logistro
7-
import orjson
8+
9+
try:
10+
import orjson
11+
except ImportError: # pragma: no cover - exercised only when orjson is absent
12+
orjson = None # type: ignore[assignment]
813

914
from . import _devtools_utils as _dtools
1015
from . import _js_logger
@@ -32,6 +37,37 @@ def _orjson_default(obj):
3237
raise TypeError(f"Type is not JSON serializable: {type(obj).__name__}")
3338

3439

40+
class _StdlibJSONEncoder(_stdlib_json.JSONEncoder):
41+
"""
42+
Encoder used when ``orjson`` is unavailable; mirrors ``_orjson_default``.
43+
44+
Reproduces the ``orjson.OPT_SERIALIZE_NUMPY`` behavior via the standard
45+
``.tolist()`` round-trip so callers see the same output regardless of
46+
whether ``orjson`` is installed.
47+
"""
48+
49+
def default(self, o: Any) -> Any:
50+
if hasattr(o, "tolist"):
51+
return o.tolist()
52+
return super().default(o)
53+
54+
55+
def _serialize_spec(spec: Any) -> str:
56+
"""
57+
Serialize a figure spec to a JSON string.
58+
59+
Uses :mod:`orjson` when available (fast path with native NumPy support);
60+
falls back to the standard-library :mod:`json` module otherwise.
61+
"""
62+
if orjson is not None:
63+
return orjson.dumps(
64+
spec,
65+
default=_orjson_default,
66+
option=orjson.OPT_SERIALIZE_NUMPY,
67+
).decode()
68+
return _stdlib_json.dumps(spec, cls=_StdlibJSONEncoder)
69+
70+
3571
def _subscribe_new(tab: choreo.Tab, event: str) -> asyncio.Future:
3672
"""Create subscription to tab clearing old ones first: helper function."""
3773
new_future = tab.subscribe_once(event)
@@ -148,11 +184,7 @@ async def _calc_fig(
148184
stepper,
149185
) -> bytes:
150186
render_prof.profile_log.tick("serializing spec")
151-
spec_str = orjson.dumps(
152-
spec,
153-
default=_orjson_default,
154-
option=orjson.OPT_SERIALIZE_NUMPY,
155-
).decode()
187+
spec_str = _serialize_spec(spec)
156188
render_prof.profile_log.tick("spec serialized")
157189

158190
render_prof.profile_log.tick("sending javascript")

src/py/kaleido/mocker/_utils.py

Lines changed: 34 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
11
from __future__ import annotations
22

33
import itertools
4+
import json as _stdlib_json
45
from pathlib import Path
56
from typing import TYPE_CHECKING, TypedDict
67

78
import logistro
8-
import orjson
9+
10+
try:
11+
import orjson
12+
except ImportError: # pragma: no cover - exercised only when orjson is absent
13+
orjson = None # type: ignore[assignment]
914

1015
from ._args import args
1116

@@ -44,30 +49,34 @@ def load_figures_from_paths(paths: list[Path]) -> Generator[FigureDict, None]:
4449
if not path.is_file():
4550
raise RuntimeError(f"Path {path} is not a file.")
4651
_logger.info(f"Found file: {path!s}")
47-
with path.open(encoding="utf-8") as file:
48-
figure = orjson.loads(file.read())
49-
for f, w, h, s in itertools.product( # all combos
50-
args.format,
51-
args.width,
52-
args.height,
53-
args.scale,
54-
):
55-
name = (
56-
f"{path.stem}.{f!s}"
57-
if not args.parameterize
58-
else f"{path.stem!s}-{w!s}x{h!s}@{s!s}.{f!s}"
59-
)
60-
opts: LayoutOpts = {
61-
"scale": s,
62-
"width": w,
63-
"height": h,
64-
}
65-
_logger.info(f"Yielding spec: {name!s}")
66-
yield {
67-
"fig": figure,
68-
"path": str(Path(args.output) / name),
69-
"opts": opts,
70-
}
52+
if orjson is not None:
53+
with path.open("rb") as file:
54+
figure = orjson.loads(file.read())
55+
else:
56+
with path.open(encoding="utf-8") as file:
57+
figure = _stdlib_json.load(file)
58+
for f, w, h, s in itertools.product( # all combos
59+
args.format,
60+
args.width,
61+
args.height,
62+
args.scale,
63+
):
64+
name = (
65+
f"{path.stem}.{f!s}"
66+
if not args.parameterize
67+
else f"{path.stem!s}-{w!s}x{h!s}@{s!s}.{f!s}"
68+
)
69+
opts: LayoutOpts = {
70+
"scale": s,
71+
"width": w,
72+
"height": h,
73+
}
74+
_logger.info(f"Yielding spec: {name!s}")
75+
yield {
76+
"fig": figure,
77+
"path": str(Path(args.output) / name),
78+
"opts": opts,
79+
}
7180

7281
class FigureDict(TypedDict):
7382
"""The type a fig_dicts returns for `write_fig_from_object`."""

src/py/pyproject.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,12 @@ maintainers = [
2828
dependencies = [
2929
"choreographer>=1.3.0",
3030
"logistro>=1.0.8",
31-
"orjson>=3.10.15",
3231
"packaging",
3332
]
3433

34+
[project.optional-dependencies]
35+
orjson = ["orjson>=3.10.15"]
36+
3537
[project.urls]
3638
Homepage = "https://github.com/plotly/kaleido"
3739
Repository = "https://github.com/plotly/kaleido"

0 commit comments

Comments
 (0)