Skip to content

Commit f04868d

Browse files
committed
Match the json engine on Decimal, NaT and circular values with msgspec
1 parent fe18c55 commit f04868d

2 files changed

Lines changed: 55 additions & 2 deletions

File tree

‎plotly/io/_json.py‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,7 @@ def to_json_plotly(plotly_object, pretty=False, engine=None):
155155
elif engine == "msgspec":
156156
JsonConfig.validate_msgspec()
157157
msgspec = get_module("msgspec", should_load=True)
158+
np = modules["np"]
158159
pd = modules["pd"]
159160

160161
def enc_hook(value):
@@ -171,6 +172,8 @@ def enc_hook(value):
171172
raise TypeError(
172173
f"Type is not JSON serializable: {type(value).__name__}"
173174
)
175+
if np is not None and isinstance(value, np.datetime64) and np.isnat(value):
176+
return None
174177
if pd is not None:
175178
if value is pd.NaT:
176179
return None
@@ -197,7 +200,16 @@ def enc_hook(value):
197200
encoder = msgspec.json.Encoder(enc_hook=enc_hook, decimal_format="number")
198201
try:
199202
out = encoder.encode(plotly_object)
200-
except (TypeError, ValueError, OverflowError, msgspec.EncodeError):
203+
# msgspec writes Decimal as is, so a NaN or Infinity value or a
204+
# Decimal dict key gives invalid JSON. Checking the syntax is cheap.
205+
msgspec.json.decode(out, type=msgspec.Raw)
206+
except (
207+
TypeError,
208+
ValueError,
209+
OverflowError,
210+
RecursionError,
211+
msgspec.MsgspecError,
212+
):
201213
# Errors and values msgspec rejects come out as with the json engine
202214
return to_json_plotly(plotly_object, pretty=pretty, engine="json")
203215
if pretty:
@@ -430,7 +442,12 @@ def from_json_plotly(value, engine=None):
430442
value_dict = get_module("orjson", should_load=True).loads(value)
431443
elif engine == "msgspec":
432444
JsonConfig.validate_msgspec()
433-
value_dict = get_module("msgspec", should_load=True).json.decode(value)
445+
msgspec = get_module("msgspec", should_load=True)
446+
try:
447+
value_dict = msgspec.json.decode(value)
448+
except (ValueError, msgspec.MsgspecError):
449+
# NaN and Infinity literals, numbers out of float range
450+
value_dict = from_json_plotly(value, engine="json")
434451
else:
435452
# decode bytes to str for built-in json module
436453
if isinstance(value, bytes):

‎tests/test_io/test_to_from_plotly_json.py‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import pandas as pd
77
import json
88
import datetime
9+
import decimal
910
import re
1011
import sys
1112
import warnings
@@ -362,3 +363,38 @@ def test_msgspec_default_engine():
362363
)
363364
finally:
364365
pio.config.default_engine = original
366+
367+
368+
@pytest.mark.parametrize(
369+
"value",
370+
[
371+
decimal.Decimal("NaN"),
372+
decimal.Decimal("-Infinity"),
373+
],
374+
)
375+
def test_non_finite_values_are_null(engine, pretty, value):
376+
result = pio.to_json_plotly({"v": [value]}, engine=engine, pretty=pretty)
377+
assert json.loads(result) == {"v": [None]}
378+
379+
380+
@pytest.mark.skipif(msgspec is None, reason="requires msgspec")
381+
def test_msgspec_datetime64_nat_is_null():
382+
value = {"v": np.datetime64("NaT")}
383+
assert pio.to_json_plotly(value, engine="msgspec") == '{"v":null}'
384+
385+
386+
@pytest.mark.skipif(msgspec is None, reason="requires msgspec")
387+
def test_msgspec_errors_match_json():
388+
circular = []
389+
circular.append(circular)
390+
with pytest.raises(ValueError, match="Circular reference"):
391+
pio.to_json_plotly(circular, engine="msgspec")
392+
with pytest.raises(TypeError):
393+
pio.to_json_plotly({decimal.Decimal("1.5"): 1}, engine="msgspec")
394+
395+
396+
@pytest.mark.skipif(msgspec is None, reason="requires msgspec")
397+
def test_msgspec_reads_what_json_reads():
398+
value = '{"a":NaN,"b":1e400}'
399+
result = pio.from_json_plotly(value, engine="msgspec")
400+
assert str(result) == str(pio.from_json_plotly(value, engine="json"))

0 commit comments

Comments
 (0)