diff --git a/CHANGELOG.md b/CHANGELOG.md index 1632f92203..c6584d49d5 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 [[#5856](https://github.com/plotly/plotly.py/pull/5856)], 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 1ffc63e5ad..f2a45b2454 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 77f228dfa2..7fb77f82b2 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 7635a6c544..76d0e048a8 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 83d8d152b8..77a5073899 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 7728e8ddc0..64ce836161 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):