From 8aa4aa85523989e47cf1c18ac91df499d97a3e40 Mon Sep 17 00:00:00 2001 From: Yashvardhan Thanvi Date: Sat, 31 Oct 2026 09:20:08 +0530 Subject: [PATCH 1/2] 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. --- CHANGELOG.md | 1 + _plotly_utils/basevalidators.py | 26 ++++++++++---- tests/test_io/test_to_from_json.py | 20 +++++++++++ .../validators/test_angle_validator.py | 15 ++++++++ .../validators/test_color_validator.py | 36 +++++++++++++++++++ .../validators/test_number_validator.py | 20 +++++++++++ 6 files changed, 111 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1632f922032..c56231f836e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). - 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! - 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! - 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! +- 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! ## [7.1.0] - 2026-09-15 diff --git a/_plotly_utils/basevalidators.py b/_plotly_utils/basevalidators.py index 1ffc63e5add..f2a45b2454e 100644 --- a/_plotly_utils/basevalidators.py +++ b/_plotly_utils/basevalidators.py @@ -770,15 +770,21 @@ def validate_coerce(self, v): v = v_array # Always numeric numpy array elif self.array_ok and is_simple_array(v): + # None elements are kept: null in figure JSON (e.g. a NaN + # written by to_json) loads back as None + numeric_els = [e for e in v if e is not None] + # Check numeric - invalid_els = [e for e in v if not isinstance(e, numbers.Number)] + invalid_els = [e for e in numeric_els if not isinstance(e, numbers.Number)] if invalid_els: self.raise_invalid_elements(invalid_els[:10]) # Check min/max if self.has_min_max: - invalid_els = [e for e in v if not (self.min_val <= e <= self.max_val)] + invalid_els = [ + e for e in numeric_els if not (self.min_val <= e <= self.max_val) + ] if invalid_els: self.raise_invalid_elements(invalid_els[:10]) @@ -1395,7 +1401,8 @@ def find_invalid_els(self, orig, validated, invalid_els=None): """ Helper method to find invalid elements in orig array. Elements are invalid if their corresponding element in - the validated array is None. + the validated array is None. None elements in orig are + valid, since null in figure JSON loads back as None. This method handles deeply nested list structures """ @@ -1406,7 +1413,7 @@ def find_invalid_els(self, orig, validated, invalid_els=None): if is_array(orig_el): self.find_invalid_els(orig_el, validated_el, invalid_els) else: - if validated_el is None: + if validated_el is None and orig_el is not None: invalid_els.append(orig_el) return invalid_els @@ -1712,13 +1719,18 @@ def validate_coerce(self, v): # Normalize v onto the interval [-180, 180) v = (v + 180) % 360 - 180 elif self.array_ok and is_simple_array(v): - # Check numeric - invalid_els = [e for e in v if not isinstance(e, numbers.Number)] + # Check numeric, keeping None elements (null in figure JSON) + invalid_els = [ + e for e in v if e is not None and not isinstance(e, numbers.Number) + ] if invalid_els: self.raise_invalid_elements(invalid_els[:10]) - v = [(x + 180) % 360 - 180 for x in to_scalar_or_list(v)] + v = [ + None if x is None else (x + 180) % 360 - 180 + for x in to_scalar_or_list(v) + ] elif not isinstance(v, numbers.Number): self.raise_invalid_val(v) else: diff --git a/tests/test_io/test_to_from_json.py b/tests/test_io/test_to_from_json.py index 77f228dfa24..7fb77f82b2d 100644 --- a/tests/test_io/test_to_from_json.py +++ b/tests/test_io/test_to_from_json.py @@ -82,6 +82,26 @@ def test_from_json(fig1): assert pio.to_json(fig1_loaded) == pio.to_json(fig1.to_dict()) +def test_from_json_nan_in_arrays(): + # to_json writes NaN as null, which from_json loads back as None + fig = go.Figure( + data=[ + go.Scatter( + x=[0, 1], + y=[0, 1], + marker={"color": [0, np.nan], "angle": [45, np.nan]}, + ), + go.Bar(x=[0, 1], y=[1, 2], offset=[0.1, np.nan]), + ] + ) + fig_loaded = pio.from_json(fig.to_json()) + + assert fig_loaded.data[0].marker.color == (0, None) + assert fig_loaded.data[0].marker.angle == (45, None) + assert fig_loaded.data[1].offset == (0.1, None) + assert json.loads(fig_loaded.to_json())["data"] == json.loads(fig.to_json())["data"] + + @pytest.mark.parametrize( "fig_type_spec,fig_type", [ diff --git a/tests/test_plotly_utils/validators/test_angle_validator.py b/tests/test_plotly_utils/validators/test_angle_validator.py index 7635a6c5440..76d0e048a88 100644 --- a/tests/test_plotly_utils/validators/test_angle_validator.py +++ b/tests/test_plotly_utils/validators/test_angle_validator.py @@ -65,6 +65,14 @@ def test_aok_coercion(val, expected, validator_aok): ) +# None elements (null in figure JSON) are kept +@pytest.mark.parametrize( + "val,expected", [([190, None], [-170, None]), ((None,), [None])] +) +def test_aok_none_elements(val, expected, validator_aok): + assert validator_aok.validate_coerce(val) == expected + + # Test rejection @pytest.mark.parametrize("val", [["hello"], [()], [[]], [set()], ["34"]]) def test_aok_rejection(val, validator_aok): @@ -72,3 +80,10 @@ def test_aok_rejection(val, validator_aok): validator_aok.validate_coerce(val) assert "Invalid element(s)" in str(validation_failure.value) + + +def test_aok_rejection_skips_none(validator_aok): + with pytest.raises(ValueError) as validation_failure: + validator_aok.validate_coerce([None, "34", None]) + + assert "Invalid elements include: ['34']" in str(validation_failure.value) diff --git a/tests/test_plotly_utils/validators/test_color_validator.py b/tests/test_plotly_utils/validators/test_color_validator.py index 83d8d152b85..77a50738998 100644 --- a/tests/test_plotly_utils/validators/test_color_validator.py +++ b/tests/test_plotly_utils/validators/test_color_validator.py @@ -219,6 +219,25 @@ def test_rejection_aok_2D(val, validator_aok): assert "Invalid element(s)" in str(validation_failure.value) +# None elements (null in figure JSON) are kept +@pytest.mark.parametrize( + "val", + [ + ["red", None], + [None], + [["red", None], [None, "blue"]], + np.array(["red", None], dtype="object"), + ], +) +def test_acceptance_aok_none_elements(val, validator_aok): + coerce_val = validator_aok.validate_coerce(val) + + if isinstance(val, np.ndarray): + assert coerce_val.tolist() == val.tolist() + else: + assert coerce_val == val + + # Array ok, numbers ok @@ -242,6 +261,23 @@ def test_acceptance_aok_colorscale(val, validator_aok_colorscale): assert coerce_val == val +@pytest.mark.parametrize( + "val", + [ + [0, None], + ["red", None, 0.5], + np.array([0, None], dtype="object"), + ], +) +def test_acceptance_aok_colorscale_none_elements(val, validator_aok_colorscale): + coerce_val = validator_aok_colorscale.validate_coerce(val) + + if isinstance(val, np.ndarray): + assert coerce_val.tolist() == val.tolist() + else: + assert coerce_val == val + + # Rejection @pytest.mark.parametrize( "val", diff --git a/tests/test_plotly_utils/validators/test_number_validator.py b/tests/test_plotly_utils/validators/test_number_validator.py index 7728e8ddc0b..64ce836161b 100644 --- a/tests/test_plotly_utils/validators/test_number_validator.py +++ b/tests/test_plotly_utils/validators/test_number_validator.py @@ -35,6 +35,11 @@ def validator_aok(): return NumberValidator("prop", "parent", min=-1, max=1.5, array_ok=True) +@pytest.fixture +def validator_aok_no_min_max(): + return NumberValidator("prop", "parent", array_ok=True) + + # Array not ok @@ -134,6 +139,13 @@ def test_coercion_aok_list(val, expected, validator_aok): assert validator_aok.present(v) == tuple(val) +# None elements (null in figure JSON) are kept, with or without min/max +@pytest.mark.parametrize("val", [[1, None], [None], (None, -0.5, None)]) +def test_acceptance_aok_none_elements(val, validator_aok, validator_aok_no_min_max): + assert validator_aok.validate_coerce(val) == list(val) + assert validator_aok_no_min_max.validate_coerce(val) == list(val) + + # Rejection @pytest.mark.parametrize("val", [["a", 4]]) def test_rejection_aok(val, validator_aok): @@ -143,6 +155,13 @@ def test_rejection_aok(val, validator_aok): assert "Invalid element(s)" in str(validation_failure.value) +def test_rejection_aok_skips_none(validator_aok): + with pytest.raises(ValueError) as validation_failure: + validator_aok.validate_coerce([None, "a", None]) + + assert "Invalid elements include: ['a']" in str(validation_failure.value) + + # Rejection by element @pytest.mark.parametrize( "val", @@ -152,6 +171,7 @@ def test_rejection_aok(val, validator_aok): [-0.1234, 0.41, np_nan()], [0, np_inf()], [0, -np_inf()], + [None, 2], ], ) def test_rejection_aok_min_max(val, validator_aok): From 75d96bbe99952ff727080a42cf321686bd4970b8 Mon Sep 17 00:00:00 2001 From: Yashvardhan Thanvi Date: Sat, 31 Oct 2026 09:20:42 +0530 Subject: [PATCH 2/2] Link the changelog entry to the PR --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c56231f836e..c6584d49d55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,7 +24,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). - 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! - 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! - 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! -- 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! +- 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 [[#5856](https://github.com/plotly/plotly.py/pull/5856)], with thanks to @Thanatos9404 for the contribution! ## [7.1.0] - 2026-09-15