Skip to content

Commit 8aa4aa8

Browse files
committed
Accept None elements in color, number and angle arrays
to_json writes NaN as null and from_json loads it back as None, which ColorValidator, NumberValidator and AngleValidator rejected inside lists, so figures with NaN in these arrays could not be loaded again. Keep None elements in arrays; scalars are unchanged.
1 parent 84498cc commit 8aa4aa8

6 files changed

Lines changed: 111 additions & 7 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
2424
- Fix `mpl_to_plotly` conversion of matplotlib contour lines and line collections: close contour rings ending with `Z` codes, support dash styles, convert date x-axes, separate disjoint subpaths with `None` separators, hide line collection traces from the legend by default, and group consecutive same-style lines into single traces [[#5770](https://github.com/plotly/plotly.py/pull/5770)], with thanks to @robertoffmoura for the contribution!
2525
- Fix `plotly.io.write_images` ignoring `layout.width`/`layout.height` (and the template's) and exporting every figure at the default 700x500 size; it now uses the same width/height priority as `write_image` and `to_image` [[#5784](https://github.com/plotly/plotly.py/pull/5784)], with thanks to @breken-ai for the contribution!
2626
- Fix color utilities mutating the list of colors passed to them [[#5836](https://github.com/plotly/plotly.py/pull/5836)], with thanks to @Enesuygurs for the contribution!
27+
- Fix `plotly.io.from_json` failing on figures whose color, number or angle arrays contained NaN (written to JSON as `null`), such as `marker.color=[0, np.nan]`; `None` elements in these arrays are now accepted and kept [[#4280](https://github.com/plotly/plotly.py/issues/4280)], with thanks to @Thanatos9404 for the contribution!
2728

2829

2930
## [7.1.0] - 2026-09-15

‎_plotly_utils/basevalidators.py‎

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -770,15 +770,21 @@ def validate_coerce(self, v):
770770

771771
v = v_array # Always numeric numpy array
772772
elif self.array_ok and is_simple_array(v):
773+
# None elements are kept: null in figure JSON (e.g. a NaN
774+
# written by to_json) loads back as None
775+
numeric_els = [e for e in v if e is not None]
776+
773777
# Check numeric
774-
invalid_els = [e for e in v if not isinstance(e, numbers.Number)]
778+
invalid_els = [e for e in numeric_els if not isinstance(e, numbers.Number)]
775779

776780
if invalid_els:
777781
self.raise_invalid_elements(invalid_els[:10])
778782

779783
# Check min/max
780784
if self.has_min_max:
781-
invalid_els = [e for e in v if not (self.min_val <= e <= self.max_val)]
785+
invalid_els = [
786+
e for e in numeric_els if not (self.min_val <= e <= self.max_val)
787+
]
782788

783789
if invalid_els:
784790
self.raise_invalid_elements(invalid_els[:10])
@@ -1395,7 +1401,8 @@ def find_invalid_els(self, orig, validated, invalid_els=None):
13951401
"""
13961402
Helper method to find invalid elements in orig array.
13971403
Elements are invalid if their corresponding element in
1398-
the validated array is None.
1404+
the validated array is None. None elements in orig are
1405+
valid, since null in figure JSON loads back as None.
13991406
14001407
This method handles deeply nested list structures
14011408
"""
@@ -1406,7 +1413,7 @@ def find_invalid_els(self, orig, validated, invalid_els=None):
14061413
if is_array(orig_el):
14071414
self.find_invalid_els(orig_el, validated_el, invalid_els)
14081415
else:
1409-
if validated_el is None:
1416+
if validated_el is None and orig_el is not None:
14101417
invalid_els.append(orig_el)
14111418

14121419
return invalid_els
@@ -1712,13 +1719,18 @@ def validate_coerce(self, v):
17121719
# Normalize v onto the interval [-180, 180)
17131720
v = (v + 180) % 360 - 180
17141721
elif self.array_ok and is_simple_array(v):
1715-
# Check numeric
1716-
invalid_els = [e for e in v if not isinstance(e, numbers.Number)]
1722+
# Check numeric, keeping None elements (null in figure JSON)
1723+
invalid_els = [
1724+
e for e in v if e is not None and not isinstance(e, numbers.Number)
1725+
]
17171726

17181727
if invalid_els:
17191728
self.raise_invalid_elements(invalid_els[:10])
17201729

1721-
v = [(x + 180) % 360 - 180 for x in to_scalar_or_list(v)]
1730+
v = [
1731+
None if x is None else (x + 180) % 360 - 180
1732+
for x in to_scalar_or_list(v)
1733+
]
17221734
elif not isinstance(v, numbers.Number):
17231735
self.raise_invalid_val(v)
17241736
else:

‎tests/test_io/test_to_from_json.py‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,26 @@ def test_from_json(fig1):
8282
assert pio.to_json(fig1_loaded) == pio.to_json(fig1.to_dict())
8383

8484

85+
def test_from_json_nan_in_arrays():
86+
# to_json writes NaN as null, which from_json loads back as None
87+
fig = go.Figure(
88+
data=[
89+
go.Scatter(
90+
x=[0, 1],
91+
y=[0, 1],
92+
marker={"color": [0, np.nan], "angle": [45, np.nan]},
93+
),
94+
go.Bar(x=[0, 1], y=[1, 2], offset=[0.1, np.nan]),
95+
]
96+
)
97+
fig_loaded = pio.from_json(fig.to_json())
98+
99+
assert fig_loaded.data[0].marker.color == (0, None)
100+
assert fig_loaded.data[0].marker.angle == (45, None)
101+
assert fig_loaded.data[1].offset == (0.1, None)
102+
assert json.loads(fig_loaded.to_json())["data"] == json.loads(fig.to_json())["data"]
103+
104+
85105
@pytest.mark.parametrize(
86106
"fig_type_spec,fig_type",
87107
[

‎tests/test_plotly_utils/validators/test_angle_validator.py‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,10 +65,25 @@ def test_aok_coercion(val, expected, validator_aok):
6565
)
6666

6767

68+
# None elements (null in figure JSON) are kept
69+
@pytest.mark.parametrize(
70+
"val,expected", [([190, None], [-170, None]), ((None,), [None])]
71+
)
72+
def test_aok_none_elements(val, expected, validator_aok):
73+
assert validator_aok.validate_coerce(val) == expected
74+
75+
6876
# Test rejection
6977
@pytest.mark.parametrize("val", [["hello"], [()], [[]], [set()], ["34"]])
7078
def test_aok_rejection(val, validator_aok):
7179
with pytest.raises(ValueError) as validation_failure:
7280
validator_aok.validate_coerce(val)
7381

7482
assert "Invalid element(s)" in str(validation_failure.value)
83+
84+
85+
def test_aok_rejection_skips_none(validator_aok):
86+
with pytest.raises(ValueError) as validation_failure:
87+
validator_aok.validate_coerce([None, "34", None])
88+
89+
assert "Invalid elements include: ['34']" in str(validation_failure.value)

‎tests/test_plotly_utils/validators/test_color_validator.py‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,25 @@ def test_rejection_aok_2D(val, validator_aok):
219219
assert "Invalid element(s)" in str(validation_failure.value)
220220

221221

222+
# None elements (null in figure JSON) are kept
223+
@pytest.mark.parametrize(
224+
"val",
225+
[
226+
["red", None],
227+
[None],
228+
[["red", None], [None, "blue"]],
229+
np.array(["red", None], dtype="object"),
230+
],
231+
)
232+
def test_acceptance_aok_none_elements(val, validator_aok):
233+
coerce_val = validator_aok.validate_coerce(val)
234+
235+
if isinstance(val, np.ndarray):
236+
assert coerce_val.tolist() == val.tolist()
237+
else:
238+
assert coerce_val == val
239+
240+
222241
# Array ok, numbers ok
223242

224243

@@ -242,6 +261,23 @@ def test_acceptance_aok_colorscale(val, validator_aok_colorscale):
242261
assert coerce_val == val
243262

244263

264+
@pytest.mark.parametrize(
265+
"val",
266+
[
267+
[0, None],
268+
["red", None, 0.5],
269+
np.array([0, None], dtype="object"),
270+
],
271+
)
272+
def test_acceptance_aok_colorscale_none_elements(val, validator_aok_colorscale):
273+
coerce_val = validator_aok_colorscale.validate_coerce(val)
274+
275+
if isinstance(val, np.ndarray):
276+
assert coerce_val.tolist() == val.tolist()
277+
else:
278+
assert coerce_val == val
279+
280+
245281
# Rejection
246282
@pytest.mark.parametrize(
247283
"val",

‎tests/test_plotly_utils/validators/test_number_validator.py‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,11 @@ def validator_aok():
3535
return NumberValidator("prop", "parent", min=-1, max=1.5, array_ok=True)
3636

3737

38+
@pytest.fixture
39+
def validator_aok_no_min_max():
40+
return NumberValidator("prop", "parent", array_ok=True)
41+
42+
3843
# Array not ok
3944

4045

@@ -134,6 +139,13 @@ def test_coercion_aok_list(val, expected, validator_aok):
134139
assert validator_aok.present(v) == tuple(val)
135140

136141

142+
# None elements (null in figure JSON) are kept, with or without min/max
143+
@pytest.mark.parametrize("val", [[1, None], [None], (None, -0.5, None)])
144+
def test_acceptance_aok_none_elements(val, validator_aok, validator_aok_no_min_max):
145+
assert validator_aok.validate_coerce(val) == list(val)
146+
assert validator_aok_no_min_max.validate_coerce(val) == list(val)
147+
148+
137149
# Rejection
138150
@pytest.mark.parametrize("val", [["a", 4]])
139151
def test_rejection_aok(val, validator_aok):
@@ -143,6 +155,13 @@ def test_rejection_aok(val, validator_aok):
143155
assert "Invalid element(s)" in str(validation_failure.value)
144156

145157

158+
def test_rejection_aok_skips_none(validator_aok):
159+
with pytest.raises(ValueError) as validation_failure:
160+
validator_aok.validate_coerce([None, "a", None])
161+
162+
assert "Invalid elements include: ['a']" in str(validation_failure.value)
163+
164+
146165
# Rejection by element
147166
@pytest.mark.parametrize(
148167
"val",
@@ -152,6 +171,7 @@ def test_rejection_aok(val, validator_aok):
152171
[-0.1234, 0.41, np_nan()],
153172
[0, np_inf()],
154173
[0, -np_inf()],
174+
[None, 2],
155175
],
156176
)
157177
def test_rejection_aok_min_max(val, validator_aok):

0 commit comments

Comments
 (0)