From d9121c9f52ef3cf5c280a734c754e4c819ae1714 Mon Sep 17 00:00:00 2001 From: FBruzzesi Date: Tue, 3 Dec 2024 21:47:09 +0100 Subject: [PATCH 1/3] patch: deepcopy figure fix --- .../plotly/_plotly_utils/basevalidators.py | 58 +++++++++---------- 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/packages/python/plotly/_plotly_utils/basevalidators.py b/packages/python/plotly/_plotly_utils/basevalidators.py index 73fe92e3511..3e7bd9faddf 100644 --- a/packages/python/plotly/_plotly_utils/basevalidators.py +++ b/packages/python/plotly/_plotly_utils/basevalidators.py @@ -223,6 +223,17 @@ def type_str(v): return "'{module}.{name}'".format(module=v.__module__, name=v.__name__) +def is_typed_array_spec(v): + """ + Return whether a value is considered to be a typed array spec for plotly.js + """ + return isinstance(v, dict) and "bdata" in v and "dtype" in v + + +def is_none_or_typed_array_spec(v): + return v is None or is_typed_array_spec(v) + + # Validators # ---------- class BaseValidator(object): @@ -393,8 +404,7 @@ def description(self): def validate_coerce(self, v): - if v is None: - # Pass None through + if is_none_or_typed_array_spec(v): pass elif is_homogeneous_array(v): v = copy_to_readonly_numpy_array(v) @@ -591,8 +601,7 @@ def in_values(self, e): return False def validate_coerce(self, v): - if v is None: - # Pass None through + if is_none_or_typed_array_spec(v): pass elif self.array_ok and is_array(v): v_replaced = [self.perform_replacemenet(v_el) for v_el in v] @@ -636,8 +645,7 @@ def description(self): ) def validate_coerce(self, v): - if v is None: - # Pass None through + if is_none_or_typed_array_spec(v): pass elif not isinstance(v, bool): self.raise_invalid_val(v) @@ -661,8 +669,7 @@ def description(self): ) def validate_coerce(self, v): - if v is None: - # Pass None through + if is_none_or_typed_array_spec(v): pass elif isinstance(v, str): pass @@ -752,8 +759,7 @@ def description(self): return desc def validate_coerce(self, v): - if v is None: - # Pass None through + if is_none_or_typed_array_spec(v): pass elif self.array_ok and is_homogeneous_array(v): np = get_module("numpy") @@ -899,8 +905,7 @@ def description(self): return desc def validate_coerce(self, v): - if v is None: - # Pass None through + if is_none_or_typed_array_spec(v): pass elif v in self.extras: return v @@ -1063,8 +1068,7 @@ def description(self): return desc def validate_coerce(self, v): - if v is None: - # Pass None through + if is_none_or_typed_array_spec(v): pass elif self.array_ok and is_array(v): @@ -1365,8 +1369,7 @@ def description(self): return valid_color_description def validate_coerce(self, v, should_raise=True): - if v is None: - # Pass None through + if is_none_or_typed_array_spec(v): pass elif self.array_ok and is_homogeneous_array(v): v = copy_to_readonly_numpy_array(v) @@ -1510,8 +1513,7 @@ def description(self): def validate_coerce(self, v): - if v is None: - # Pass None through + if is_none_or_typed_array_spec(v): pass elif is_array(v): validated_v = [ @@ -1708,16 +1710,17 @@ def description(self): (e.g. 270 is converted to -90). """.format( plotly_name=self.plotly_name, - array_ok=", or a list, numpy array or other iterable thereof" - if self.array_ok - else "", + array_ok=( + ", or a list, numpy array or other iterable thereof" + if self.array_ok + else "" + ), ) return desc def validate_coerce(self, v): - if v is None: - # Pass None through + if is_none_or_typed_array_spec(v): pass elif self.array_ok and is_homogeneous_array(v): try: @@ -1902,8 +1905,7 @@ def vc_scalar(self, v): return None def validate_coerce(self, v): - if v is None: - # Pass None through + if is_none_or_typed_array_spec(v): pass elif self.array_ok and is_array(v): @@ -1961,8 +1963,7 @@ def description(self): return desc def validate_coerce(self, v): - if v is None: - # Pass None through + if is_none_or_typed_array_spec(v): pass elif self.array_ok and is_homogeneous_array(v): v = copy_to_readonly_numpy_array(v, kind="O") @@ -2170,8 +2171,7 @@ def validate_element_with_indexed_name(self, val, validator, inds): return val def validate_coerce(self, v): - if v is None: - # Pass None through + if is_none_or_typed_array_spec(v): return None elif not is_array(v): self.raise_invalid_val(v) From 6808a9776cc508d67844c0ee06472a2a53b23a53 Mon Sep 17 00:00:00 2001 From: FBruzzesi Date: Fri, 6 Dec 2024 09:49:19 +0100 Subject: [PATCH 2/3] test: add basic deepcopy figure test --- .../tests/validators/test_fig_deepcopy.py | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 packages/python/plotly/_plotly_utils/tests/validators/test_fig_deepcopy.py diff --git a/packages/python/plotly/_plotly_utils/tests/validators/test_fig_deepcopy.py b/packages/python/plotly/_plotly_utils/tests/validators/test_fig_deepcopy.py new file mode 100644 index 00000000000..3d9d7ca798d --- /dev/null +++ b/packages/python/plotly/_plotly_utils/tests/validators/test_fig_deepcopy.py @@ -0,0 +1,11 @@ +import copy +import plotly.express as px + + +def test_deepcopy(): + gapminder = px.data.gapminder() + fig = px.line(gapminder, x="year", y="gdpPercap", color="country") + + fig_copy = copy.deepcopy(fig) + + assert fig_copy is not None From 9c1d326c739fd4912835ae0783a6f6069414f397 Mon Sep 17 00:00:00 2001 From: FBruzzesi Date: Fri, 6 Dec 2024 21:58:53 +0100 Subject: [PATCH 3/3] add equality test, multiple frames and numpy arrays --- .../tests/validators/test_fig_deepcopy.py | 35 ++++++++++++++++--- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/packages/python/plotly/_plotly_utils/tests/validators/test_fig_deepcopy.py b/packages/python/plotly/_plotly_utils/tests/validators/test_fig_deepcopy.py index 3d9d7ca798d..2ad869310c2 100644 --- a/packages/python/plotly/_plotly_utils/tests/validators/test_fig_deepcopy.py +++ b/packages/python/plotly/_plotly_utils/tests/validators/test_fig_deepcopy.py @@ -1,11 +1,38 @@ import copy +import pytest import plotly.express as px +""" +This test is in the validators folder since copy.deepcopy ends up calling +BaseFigure(*args) which hits `validate_coerce`. -def test_deepcopy(): - gapminder = px.data.gapminder() +When inputs are dataframes and arrays, then the copied figure is called with +base64 encoded arrays. +""" + + +@pytest.mark.parametrize("return_type", ["pandas", "polars", "pyarrow"]) +@pytest.mark.filterwarnings( + r"ignore:\*scattermapbox\* is deprecated! Use \*scattermap\* instead" +) +def test_deepcopy_dataframe(return_type): + gapminder = px.data.gapminder(return_type=return_type) fig = px.line(gapminder, x="year", y="gdpPercap", color="country") + fig_copied = copy.deepcopy(fig) + + assert fig_copied.to_dict() == fig.to_dict() + + +@pytest.mark.filterwarnings( + r"ignore:\*scattermapbox\* is deprecated! Use \*scattermap\* instead" +) +def test_deepcopy_array(): + gapminder = px.data.gapminder() + x = gapminder["year"].to_numpy() + y = gapminder["gdpPercap"].to_numpy() + color = gapminder["country"].to_numpy() - fig_copy = copy.deepcopy(fig) + fig = px.line(x=x, y=y, color=color) + fig_copied = copy.deepcopy(fig) - assert fig_copy is not None + assert fig_copied.to_dict() == fig.to_dict()