Skip to content

Commit fe18c55

Browse files
committed
Add a msgspec JSON engine
1 parent 6e9eafb commit fe18c55

7 files changed

Lines changed: 396 additions & 38 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ This project adheres to [Semantic Versioning](http://semver.org/).
44

55
## Unreleased
66

7+
### Added
8+
- Add a `msgspec` JSON engine, used by the `auto` engine when `msgspec` is installed and `orjson` is not [[#5813](https://github.com/plotly/plotly.py/pull/5813)]
9+
710
### Updated
811
- Speed up `plotly.io.to_json` and `PlotlyJSONEncoder` for figures and for payloads that mix plotly objects with plain data: figures are no longer deep copied, and with orjson only the values orjson can't serialize go through the slow cleaning path [[#5813](https://github.com/plotly/plotly.py/pull/5813)]
912

‎doc/python/renderers.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,6 @@ No matter the approach chosen to display a figure, [the figure data structure](h
271271

272272
*New in v5.0*
273273

274-
The default JSON serialization mechanism can be slow for figures with many data points or with large `numpy` arrays or data frames. **If [the `orjson` package](https://github.com/ijl/orjson) is installed**, `plotly` will use that instead of the built-in `json` package, which can lead to **5-10x** speedups for large figures.
274+
The default JSON serialization mechanism can be slow for figures with many data points or with large `numpy` arrays or data frames. **If [the `orjson` package](https://github.com/ijl/orjson) is installed**, `plotly` will use that instead of the built-in `json` package, which can lead to **5-10x** speedups for large figures. If `orjson` isn't installed but [`msgspec`](https://github.com/jcrist/msgspec) is (Dash installs it), `plotly` uses `msgspec`, which gives most of the same speedup.
275275

276276
Once a figure is serialized to JSON, it must be rendered by a browser, either immediately in the user's browser, at some later point if the figure is exported to HTML, or immediately in Kaleido's internal headless browser for static image export. Rendering time is generally proportional to the total number of data points in the figure, the number of traces and the number of subplots. In situations where rendering performance is slow, we recommend considering [the use of `plotly` WebGL traces](/python/webgl-vs-svg/) to exploit GPU-accelerated rendering in the browser, or [using the Datashader library to do Python-side rendering](/python/datashader/) before using `px.imshow()` to render the figure.

‎plotly/basedatatypes.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3458,6 +3458,7 @@ def to_json(self, *args, **kwargs):
34583458
The JSON encoding engine to use. One of:
34593459
- "json" for an encoder based on the built-in Python json module
34603460
- "orjson" for a fast encoder the requires the orjson package
3461+
- "msgspec" for a fast encoder that requires the msgspec package
34613462
If not specified, the default encoder is set to the current value of
34623463
plotly.io.json.config.default_encoder.
34633464
@@ -3518,6 +3519,7 @@ def write_json(self, *args, **kwargs):
35183519
The JSON encoding engine to use. One of:
35193520
- "json" for an encoder based on the built-in Python json module
35203521
- "orjson" for a fast encoder the requires the orjson package
3522+
- "msgspec" for a fast encoder that requires the msgspec package
35213523
If not specified, the default encoder is set to the current value of
35223524
plotly.io.json.config.default_encoder.
35233525
@@ -5620,6 +5622,7 @@ def to_json(self, *args, **kwargs):
56205622
The JSON encoding engine to use. One of:
56215623
- "json" for an encoder based on the built-in Python json module
56225624
- "orjson" for a fast encoder the requires the orjson package
5625+
- "msgspec" for a fast encoder that requires the msgspec package
56235626
If not specified, the default encoder is set to the current value of
56245627
plotly.io.json.config.default_encoder.
56255628

‎plotly/io/_json.py‎

Lines changed: 102 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111

1212
class JsonConfig(object):
13-
_valid_engines = ("json", "orjson", "auto")
13+
_valid_engines = ("json", "orjson", "msgspec", "auto")
1414

1515
def __init__(self):
1616
self._default_engine = "auto"
@@ -30,6 +30,8 @@ def default_engine(self, val):
3030

3131
if val == "orjson":
3232
self.validate_orjson()
33+
elif val == "msgspec":
34+
self.validate_msgspec()
3335

3436
self._default_engine = val
3537

@@ -39,6 +41,12 @@ def validate_orjson(cls):
3941
if orjson is None:
4042
raise ValueError("The orjson engine requires the orjson package")
4143

44+
@classmethod
45+
def validate_msgspec(cls):
46+
msgspec = get_module("msgspec")
47+
if msgspec is None:
48+
raise ValueError("The msgspec engine requires the msgspec package")
49+
4250

4351
config = JsonConfig()
4452

@@ -66,6 +74,20 @@ def coerce_to_strict(const):
6674
)
6775

6876

77+
def _resolve_engine(engine):
78+
if engine is None:
79+
engine = config.default_engine
80+
if engine == "auto":
81+
if get_module("orjson", should_load=True) is not None:
82+
return "orjson"
83+
if get_module("msgspec", should_load=True) is not None:
84+
return "msgspec"
85+
return "json"
86+
if engine not in ("orjson", "msgspec", "json"):
87+
raise ValueError("Invalid json engine: %s" % engine)
88+
return engine
89+
90+
6991
def _safe(json_str, _swap):
7092
out = json_str
7193
for unsafe_char, safe_char in _swap:
@@ -91,7 +113,9 @@ def to_json_plotly(plotly_object, pretty=False, engine=None):
91113
The JSON encoding engine to use. One of:
92114
- "json" for an engine based on the built-in Python json module
93115
- "orjson" for a faster engine that requires the orjson package
94-
- "auto" for the "orjson" engine if available, otherwise "json"
116+
- "msgspec" for a faster engine that requires the msgspec package
117+
- "auto" for the "orjson" engine if available, then "msgspec",
118+
otherwise "json"
95119
If not specified, the default engine is set to the current value of
96120
plotly.io.json.config.default_engine.
97121
@@ -104,19 +128,7 @@ def to_json_plotly(plotly_object, pretty=False, engine=None):
104128
--------
105129
to_json : Convert a plotly Figure to JSON with validation
106130
"""
107-
orjson = get_module("orjson", should_load=True)
108-
109-
# Determine json engine
110-
if engine is None:
111-
engine = config.default_engine
112-
113-
if engine == "auto":
114-
if orjson is not None:
115-
engine = "orjson"
116-
else:
117-
engine = "json"
118-
elif engine not in ["orjson", "json"]:
119-
raise ValueError("Invalid json engine: %s" % engine)
131+
engine = _resolve_engine(engine)
120132

121133
modules = {
122134
"sage_all": get_module("sage.all", should_load=False),
@@ -140,8 +152,60 @@ def to_json_plotly(plotly_object, pretty=False, engine=None):
140152
return _safe(
141153
json.dumps(plotly_object, cls=PlotlyJSONEncoder, **opts), _swap_json
142154
)
155+
elif engine == "msgspec":
156+
JsonConfig.validate_msgspec()
157+
msgspec = get_module("msgspec", should_load=True)
158+
pd = modules["pd"]
159+
160+
def enc_hook(value):
161+
# msgspec takes only exact types: numpy scalars subclass these
162+
if isinstance(value, float):
163+
return float(value)
164+
if isinstance(value, int):
165+
return int(value)
166+
if isinstance(value, str):
167+
return str.__str__(value)
168+
if isinstance(value, (dict, list, tuple)):
169+
# only asked for when they sit where JSON can't hold them
170+
# (a tuple dict key), converting would loop forever
171+
raise TypeError(
172+
f"Type is not JSON serializable: {type(value).__name__}"
173+
)
174+
if pd is not None:
175+
if value is pd.NaT:
176+
return None
177+
# isoformat keeps nanoseconds, as in the json engine
178+
if isinstance(value, pd.Timestamp):
179+
return value.isoformat()
180+
if isinstance(value, (pd.Series, pd.Index)) and value.dtype.kind == "M":
181+
return value.tolist()
182+
to_json_dict = getattr(value, "_to_json_dict", None)
183+
if to_json_dict is not None:
184+
return to_json_dict()
185+
cleaned = clean_to_json_compatible(
186+
value,
187+
numpy_allowed=False,
188+
datetime_allowed=False,
189+
modules=modules,
190+
)
191+
if cleaned is value:
192+
raise TypeError(
193+
f"Type is not JSON serializable: {type(value).__name__}"
194+
)
195+
return cleaned
196+
197+
encoder = msgspec.json.Encoder(enc_hook=enc_hook, decimal_format="number")
198+
try:
199+
out = encoder.encode(plotly_object)
200+
except (TypeError, ValueError, OverflowError, msgspec.EncodeError):
201+
# Errors and values msgspec rejects come out as with the json engine
202+
return to_json_plotly(plotly_object, pretty=pretty, engine="json")
203+
if pretty:
204+
out = msgspec.json.format(out, indent=2)
205+
return _safe(out.decode("utf8"), _swap_orjson)
143206
elif engine == "orjson":
144207
JsonConfig.validate_orjson()
208+
orjson = get_module("orjson", should_load=True)
145209
opts = orjson.OPT_NON_STR_KEYS | orjson.OPT_SERIALIZE_NUMPY
146210

147211
if pretty:
@@ -213,7 +277,9 @@ def to_json(fig, validate=True, pretty=False, remove_uids=True, engine=None):
213277
The JSON encoding engine to use. One of:
214278
- "json" for an engine based on the built-in Python json module
215279
- "orjson" for a faster engine that requires the orjson package
216-
- "auto" for the "orjson" engine if available, otherwise "json"
280+
- "msgspec" for a faster engine that requires the msgspec package
281+
- "auto" for the "orjson" engine if available, then "msgspec",
282+
otherwise "json"
217283
If not specified, the default engine is set to the current value of
218284
plotly.io.json.config.default_engine.
219285
@@ -266,7 +332,9 @@ def write_json(fig, file, validate=True, pretty=False, remove_uids=True, engine=
266332
The JSON encoding engine to use. One of:
267333
- "json" for an engine based on the built-in Python json module
268334
- "orjson" for a faster engine that requires the orjson package
269-
- "auto" for the "orjson" engine if available, otherwise "json"
335+
- "msgspec" for a faster engine that requires the msgspec package
336+
- "auto" for the "orjson" engine if available, then "msgspec",
337+
otherwise "json"
270338
If not specified, the default engine is set to the current value of
271339
plotly.io.json.config.default_engine.
272340
Returns
@@ -329,7 +397,10 @@ def from_json_plotly(value, engine=None):
329397
- if "json", parse JSON using built in json module
330398
- if "orjson", parse using the faster orjson module, requires the orjson
331399
package
332-
- if "auto" use orjson module if available, otherwise use the json module
400+
- if "msgspec", parse using the faster msgspec module, requires the
401+
msgspec package
402+
- if "auto" use orjson module if available, then msgspec, otherwise
403+
use the json module
333404
334405
If not specified, the default engine is set to the current value of
335406
plotly.io.json.config.default_engine.
@@ -342,8 +413,6 @@ def from_json_plotly(value, engine=None):
342413
--------
343414
from_json_plotly : Parse JSON with plotly conventions into a dict
344415
"""
345-
orjson = get_module("orjson", should_load=True)
346-
347416
# Validate value
348417
# --------------
349418
if not isinstance(value, (str, bytes)):
@@ -353,22 +422,15 @@ def from_json_plotly(value, engine=None):
353422
Received value: {value}""".format(typ=type(value), value=value)
354423
)
355424

356-
# Determine json engine
357-
if engine is None:
358-
engine = config.default_engine
359-
360-
if engine == "auto":
361-
if orjson is not None:
362-
engine = "orjson"
363-
else:
364-
engine = "json"
365-
elif engine not in ["orjson", "json"]:
366-
raise ValueError("Invalid json engine: %s" % engine)
425+
engine = _resolve_engine(engine)
367426

368427
if engine == "orjson":
369428
JsonConfig.validate_orjson()
370429
# orjson handles bytes input natively
371-
value_dict = orjson.loads(value)
430+
value_dict = get_module("orjson", should_load=True).loads(value)
431+
elif engine == "msgspec":
432+
JsonConfig.validate_msgspec()
433+
value_dict = get_module("msgspec", should_load=True).json.decode(value)
372434
else:
373435
# decode bytes to str for built-in json module
374436
if isinstance(value, bytes):
@@ -400,7 +462,10 @@ def from_json(value, output_type="Figure", skip_invalid=False, engine=None):
400462
- if "json", parse JSON using built in json module
401463
- if "orjson", parse using the faster orjson module, requires the orjson
402464
package
403-
- if "auto" use orjson module if available, otherwise use the json module
465+
- if "msgspec", parse using the faster msgspec module, requires the
466+
msgspec package
467+
- if "auto" use orjson module if available, then msgspec, otherwise
468+
use the json module
404469
405470
If not specified, the default engine is set to the current value of
406471
plotly.io.json.config.default_engine.
@@ -456,7 +521,10 @@ def read_json(file, output_type="Figure", skip_invalid=False, engine=None):
456521
- if "json", parse JSON using built in json module
457522
- if "orjson", parse using the faster orjson module, requires the orjson
458523
package
459-
- if "auto" use orjson module if available, otherwise use the json module
524+
- if "msgspec", parse using the faster msgspec module, requires the
525+
msgspec package
526+
- if "auto" use orjson module if available, then msgspec, otherwise
527+
use the json module
460528
461529
If not specified, the default engine is set to the current value of
462530
plotly.io.json.config.default_engine.

‎pyproject.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ dev_optional = [
7272
"fiona<=1.9.6;python_version<='3.8'",
7373
"geopandas",
7474
"jupyterlab",
75+
"msgspec",
7576
"orjson",
7677
"pandas",
7778
"pdfrw",

‎tests/test_io/test_to_from_plotly_json.py‎

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from _plotly_utils.optional_imports import get_module
1414

1515
orjson = get_module("orjson")
16+
msgspec = get_module("msgspec")
1617

1718
eastern = timezone("US/Eastern")
1819

@@ -67,10 +68,11 @@ def check_roundtrip(value, engine, pretty):
6768

6869

6970
# Fixtures
71+
engines = ["json", "auto"]
7072
if orjson is not None:
71-
engines = ["json", "orjson", "auto"]
72-
else:
73-
engines = ["json", "auto"]
73+
engines.append("orjson")
74+
if msgspec is not None:
75+
engines.append("msgspec")
7476

7577

7678
@pytest.fixture(scope="module", params=engines)
@@ -322,3 +324,41 @@ def to_plotly_json(self):
322324
)
323325
assert result["fig"]["layout"]["title"] == {"text": "from to_dict"}
324326
assert result["trace"]["name"] == "from to_plotly_json"
327+
328+
329+
def test_datetime_series_with_nat(engine):
330+
value = {"v": pd.Series([pd.Timestamp("2024-01-01"), pd.NaT])}
331+
result = pio.to_json_plotly(value, engine=engine)
332+
assert json.loads(result) == {"v": ["2024-01-01T00:00:00", None]}
333+
334+
335+
@pytest.mark.parametrize("value", [object(), 1j, {(1, 2): 1}, [{(1,): 2}]])
336+
def test_unserializable_raises_type_error(engine, value):
337+
with pytest.raises(TypeError):
338+
pio.to_json_plotly({"v": value}, engine=engine)
339+
340+
341+
@pytest.mark.skipif(msgspec is None, reason="requires msgspec")
342+
def test_auto_engine_uses_msgspec_without_orjson(monkeypatch):
343+
def without_orjson(name, **kwargs):
344+
return None if name == "orjson" else get_module(name, **kwargs)
345+
346+
monkeypatch.setattr("plotly.io._json.get_module", without_orjson)
347+
utc = datetime.datetime(2024, 1, 1, tzinfo=datetime.timezone.utc)
348+
# msgspec writes UTC as Z, the json engine as +00:00
349+
assert (
350+
pio.to_json_plotly({"t": utc}, engine="auto") == '{"t":"2024-01-01T00:00:00Z"}'
351+
)
352+
assert pio.from_json_plotly('{"a":[1]}', engine="auto") == {"a": [1]}
353+
354+
355+
@pytest.mark.skipif(msgspec is None, reason="requires msgspec")
356+
def test_msgspec_default_engine():
357+
original = pio.config.default_engine
358+
try:
359+
pio.config.default_engine = "msgspec"
360+
assert pio.to_json_plotly(go.Figure(go.Bar(y=[1]))) == pio.to_json_plotly(
361+
go.Figure(go.Bar(y=[1])), engine="json"
362+
)
363+
finally:
364+
pio.config.default_engine = original

0 commit comments

Comments
 (0)