Skip to content

Commit 09bbd75

Browse files
Merge pull request #3293 from plotly/gh_3292
Fix: customdata is sometimes a tuple
2 parents 17b7c27 + a921ab5 commit 09bbd75

11 files changed

+91
-65
lines changed

Diff for: CHANGELOG.md

+2
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ This project adheres to [Semantic Versioning](http://semver.org/).
44

55
## UNRELEASED
66

7+
### Fixed
8+
- Fixed regression introduced in version 5.0.0 where pandas/numpy arrays with `dtype` of Object were being converted to `list` values when added to a Figure ([#3292](https://github.com/plotly/plotly.py/issues/3292), [#3293](https://github.com/plotly/plotly.py/pull/3293))
79

810
## [5.1.0] - 2021-06-28
911

Diff for: packages/python/plotly/_plotly_utils/basevalidators.py

+41-28
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ def to_scalar_or_list(v):
5353
return v
5454

5555

56-
def copy_to_readonly_numpy_array_or_list(v, kind=None, force_numeric=False):
56+
def copy_to_readonly_numpy_array(v, kind=None, force_numeric=False):
5757
"""
5858
Convert an array-like value into a read-only numpy array
5959
@@ -94,7 +94,6 @@ def copy_to_readonly_numpy_array_or_list(v, kind=None, force_numeric=False):
9494
"i": "int32",
9595
"f": "float64",
9696
"O": "object",
97-
"U": "U",
9897
}
9998

10099
# Handle pandas Series and Index objects
@@ -119,12 +118,18 @@ def copy_to_readonly_numpy_array_or_list(v, kind=None, force_numeric=False):
119118
if not isinstance(v, np.ndarray):
120119
# v has its own logic on how to convert itself into a numpy array
121120
if is_numpy_convertable(v):
122-
return copy_to_readonly_numpy_array_or_list(
121+
return copy_to_readonly_numpy_array(
123122
np.array(v), kind=kind, force_numeric=force_numeric
124123
)
125124
else:
126125
# v is not homogenous array
127-
return [to_scalar_or_list(e) for e in v]
126+
v_list = [to_scalar_or_list(e) for e in v]
127+
128+
# Lookup dtype for requested kind, if any
129+
dtype = kind_default_dtypes.get(first_kind, None)
130+
131+
# construct new array from list
132+
new_v = np.array(v_list, order="C", dtype=dtype)
128133
elif v.dtype.kind in numeric_kinds:
129134
# v is a homogenous numeric array
130135
if kind and v.dtype.kind not in kind:
@@ -135,12 +140,6 @@ def copy_to_readonly_numpy_array_or_list(v, kind=None, force_numeric=False):
135140
else:
136141
# Either no kind was requested or requested kind is satisfied
137142
new_v = np.ascontiguousarray(v.copy())
138-
elif v.dtype.kind == "O":
139-
if kind:
140-
dtype = kind_default_dtypes.get(first_kind, None)
141-
return np.array(v, dtype=dtype)
142-
else:
143-
return v.tolist()
144143
else:
145144
# v is a non-numeric homogenous array
146145
new_v = v.copy()
@@ -155,12 +154,12 @@ def copy_to_readonly_numpy_array_or_list(v, kind=None, force_numeric=False):
155154
if "U" not in kind:
156155
# Force non-numeric arrays to have object type
157156
# --------------------------------------------
158-
# Here we make sure that non-numeric arrays become lists
159-
# This works around cases like np.array([1, 2, '3']) where
157+
# Here we make sure that non-numeric arrays have the object
158+
# datatype. This works around cases like np.array([1, 2, '3']) where
160159
# numpy converts the integers to strings and returns array of dtype
161160
# '<U21'
162161
if new_v.dtype.kind not in ["u", "i", "f", "O", "M"]:
163-
return v.tolist()
162+
new_v = np.array(v, dtype="object")
164163

165164
# Set new array to be read-only
166165
# -----------------------------
@@ -399,7 +398,7 @@ def validate_coerce(self, v):
399398
# Pass None through
400399
pass
401400
elif is_homogeneous_array(v):
402-
v = copy_to_readonly_numpy_array_or_list(v)
401+
v = copy_to_readonly_numpy_array(v)
403402
elif is_simple_array(v):
404403
v = to_scalar_or_list(v)
405404
else:
@@ -610,7 +609,7 @@ def validate_coerce(self, v):
610609
self.raise_invalid_elements(invalid_els[:10])
611610

612611
if is_homogeneous_array(v):
613-
v = copy_to_readonly_numpy_array_or_list(v)
612+
v = copy_to_readonly_numpy_array(v)
614613
else:
615614
v = to_scalar_or_list(v)
616615
else:
@@ -766,7 +765,7 @@ def validate_coerce(self, v):
766765
elif self.array_ok and is_homogeneous_array(v):
767766
np = get_module("numpy")
768767
try:
769-
v_array = copy_to_readonly_numpy_array_or_list(v, force_numeric=True)
768+
v_array = copy_to_readonly_numpy_array(v, force_numeric=True)
770769
except (ValueError, TypeError, OverflowError):
771770
self.raise_invalid_val(v)
772771

@@ -893,7 +892,7 @@ def validate_coerce(self, v):
893892
pass
894893
elif self.array_ok and is_homogeneous_array(v):
895894
np = get_module("numpy")
896-
v_array = copy_to_readonly_numpy_array_or_list(
895+
v_array = copy_to_readonly_numpy_array(
897896
v, kind=("i", "u"), force_numeric=True
898897
)
899898

@@ -1054,7 +1053,25 @@ def validate_coerce(self, v):
10541053
if invalid_els:
10551054
self.raise_invalid_elements(invalid_els)
10561055

1057-
if is_simple_array(v) or is_homogeneous_array(v):
1056+
if is_homogeneous_array(v):
1057+
np = get_module("numpy")
1058+
1059+
# If not strict, let numpy cast elements to strings
1060+
v = copy_to_readonly_numpy_array(v, kind="U")
1061+
1062+
# Check no_blank
1063+
if self.no_blank:
1064+
invalid_els = v[v == ""][:10].tolist()
1065+
if invalid_els:
1066+
self.raise_invalid_elements(invalid_els)
1067+
1068+
# Check values
1069+
if self.values:
1070+
invalid_inds = np.logical_not(np.isin(v, self.values))
1071+
invalid_els = v[invalid_inds][:10].tolist()
1072+
if invalid_els:
1073+
self.raise_invalid_elements(invalid_els)
1074+
elif is_simple_array(v):
10581075
if not self.strict:
10591076
v = [StringValidator.to_str_or_unicode_or_none(e) for e in v]
10601077

@@ -1331,12 +1348,8 @@ def validate_coerce(self, v, should_raise=True):
13311348
# Pass None through
13321349
pass
13331350
elif self.array_ok and is_homogeneous_array(v):
1334-
v = copy_to_readonly_numpy_array_or_list(v)
1335-
if (
1336-
not isinstance(v, list)
1337-
and self.numbers_allowed()
1338-
and v.dtype.kind in ["u", "i", "f"]
1339-
):
1351+
v = copy_to_readonly_numpy_array(v)
1352+
if self.numbers_allowed() and v.dtype.kind in ["u", "i", "f"]:
13401353
# Numbers are allowed and we have an array of numbers.
13411354
# All good
13421355
pass
@@ -1350,9 +1363,9 @@ def validate_coerce(self, v, should_raise=True):
13501363

13511364
# ### Check that elements have valid colors types ###
13521365
elif self.numbers_allowed() or invalid_els:
1353-
v = copy_to_readonly_numpy_array_or_list(validated_v, kind="O")
1366+
v = copy_to_readonly_numpy_array(validated_v, kind="O")
13541367
else:
1355-
v = copy_to_readonly_numpy_array_or_list(validated_v, kind="U")
1368+
v = copy_to_readonly_numpy_array(validated_v, kind="U")
13561369
elif self.array_ok and is_simple_array(v):
13571370
validated_v = [self.validate_coerce(e, should_raise=False) for e in v]
13581371

@@ -1867,7 +1880,7 @@ def validate_coerce(self, v):
18671880
self.raise_invalid_elements(invalid_els)
18681881

18691882
if is_homogeneous_array(v):
1870-
v = copy_to_readonly_numpy_array_or_list(validated_v, kind="U")
1883+
v = copy_to_readonly_numpy_array(validated_v, kind="U")
18711884
else:
18721885
v = to_scalar_or_list(v)
18731886
else:
@@ -1915,7 +1928,7 @@ def validate_coerce(self, v):
19151928
# Pass None through
19161929
pass
19171930
elif self.array_ok and is_homogeneous_array(v):
1918-
v = copy_to_readonly_numpy_array_or_list(v, kind="O")
1931+
v = copy_to_readonly_numpy_array(v, kind="O")
19191932
elif self.array_ok and is_simple_array(v):
19201933
v = to_scalar_or_list(v)
19211934
return v

Diff for: packages/python/plotly/_plotly_utils/tests/validators/test_dataarray_validator.py

+2-16
Original file line numberDiff line numberDiff line change
@@ -32,29 +32,15 @@ def test_validator_acceptance_simple(val, validator):
3232

3333

3434
@pytest.mark.parametrize(
35-
"val", [np.array([2, 3, 4]), np.array([[1, 2, 3], [4, 5, 6]])],
35+
"val",
36+
[np.array([2, 3, 4]), pd.Series(["a", "b", "c"]), np.array([[1, 2, 3], [4, 5, 6]])],
3637
)
3738
def test_validator_acceptance_homogeneous(val, validator):
3839
coerce_val = validator.validate_coerce(val)
3940
assert isinstance(coerce_val, np.ndarray)
4041
assert np.array_equal(validator.present(coerce_val), val)
4142

4243

43-
# Accept object array as list
44-
@pytest.mark.parametrize(
45-
"val",
46-
[
47-
["A", "B", "C"],
48-
np.array(["A", "B", "C"], dtype="object"),
49-
pd.Series(["a", "b", "c"]),
50-
],
51-
)
52-
def test_validator_accept_object_array_as_list(val, validator):
53-
coerce_val = validator.validate_coerce(val)
54-
assert isinstance(coerce_val, list)
55-
assert coerce_val == list(val)
56-
57-
5844
# ### Rejection ###
5945
@pytest.mark.parametrize("val", ["Hello", 23, set(), {}])
6046
def test_rejection(val, validator):

Diff for: packages/python/plotly/_plotly_utils/tests/validators/test_enumerated_validator.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ def test_acceptance_aok(val, validator_aok_re):
135135
# Values should be accepted and returned unchanged
136136
coerce_val = validator_aok_re.validate_coerce(val)
137137
if isinstance(val, (np.ndarray, pd.Series)):
138-
assert coerce_val == list(np.array(val))
138+
assert np.array_equal(coerce_val, np.array(val, dtype=coerce_val.dtype))
139139
elif isinstance(val, (list, tuple)):
140140
assert validator_aok_re.present(coerce_val) == tuple(val)
141141
else:

Diff for: packages/python/plotly/_plotly_utils/tests/validators/test_pandas_series_input.py

+20-8
Original file line numberDiff line numberDiff line change
@@ -149,10 +149,13 @@ def test_color_validator_object(color_validator, color_object_pandas):
149149
res = color_validator.validate_coerce(color_object_pandas)
150150

151151
# Check type
152-
assert isinstance(res, list)
152+
assert isinstance(res, np.ndarray)
153+
154+
# Check dtype
155+
assert res.dtype == "object"
153156

154157
# Check values
155-
assert res == color_object_pandas.tolist()
158+
np.testing.assert_array_equal(res, color_object_pandas)
156159

157160

158161
def test_color_validator_categorical(color_validator, color_categorical_pandas):
@@ -161,10 +164,13 @@ def test_color_validator_categorical(color_validator, color_categorical_pandas):
161164

162165
# Check type
163166
assert color_categorical_pandas.dtype == "category"
164-
assert isinstance(res, list)
167+
assert isinstance(res, np.ndarray)
168+
169+
# Check dtype
170+
assert res.dtype == "object"
165171

166172
# Check values
167-
assert res == color_categorical_pandas.tolist()
173+
np.testing.assert_array_equal(res, np.array(color_categorical_pandas))
168174

169175

170176
def test_data_array_validator_dates_series(
@@ -174,10 +180,13 @@ def test_data_array_validator_dates_series(
174180
res = data_array_validator.validate_coerce(datetime_pandas)
175181

176182
# Check type
177-
assert isinstance(res, list)
183+
assert isinstance(res, np.ndarray)
184+
185+
# Check dtype
186+
assert res.dtype == "object"
178187

179188
# Check values
180-
assert res == dates_array.tolist()
189+
np.testing.assert_array_equal(res, dates_array)
181190

182191

183192
def test_data_array_validator_dates_dataframe(
@@ -188,7 +197,10 @@ def test_data_array_validator_dates_dataframe(
188197
res = data_array_validator.validate_coerce(df)
189198

190199
# Check type
191-
assert isinstance(res, list)
200+
assert isinstance(res, np.ndarray)
201+
202+
# Check dtype
203+
assert res.dtype == "object"
192204

193205
# Check values
194-
assert res == dates_array.reshape(len(dates_array), 1).tolist()
206+
np.testing.assert_array_equal(res, dates_array.reshape(len(dates_array), 1))

Diff for: packages/python/plotly/_plotly_utils/tests/validators/test_string_validator.py

+5-2
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,8 @@ def test_acceptance_aok_scalars(val, validator_aok):
138138
def test_acceptance_aok_list(val, validator_aok):
139139
coerce_val = validator_aok.validate_coerce(val)
140140
if isinstance(val, np.ndarray):
141-
assert coerce_val == val.tolist()
141+
assert isinstance(coerce_val, np.ndarray)
142+
assert np.array_equal(coerce_val, np.array(val, dtype=coerce_val.dtype))
142143
elif isinstance(val, list):
143144
assert validator_aok.present(val) == tuple(val)
144145
else:
@@ -177,7 +178,9 @@ def test_rejection_aok_values(val, validator_aok_values):
177178
)
178179
def test_acceptance_no_blanks_aok(val, validator_no_blanks_aok):
179180
coerce_val = validator_no_blanks_aok.validate_coerce(val)
180-
if isinstance(val, (list, np.ndarray)):
181+
if isinstance(val, np.ndarray):
182+
assert np.array_equal(coerce_val, np.array(val, dtype=coerce_val.dtype))
183+
elif isinstance(val, list):
181184
assert validator_no_blanks_aok.present(coerce_val) == tuple(val)
182185
else:
183186
assert coerce_val == val

Diff for: packages/python/plotly/_plotly_utils/tests/validators/test_xarray_input.py

+5-2
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,10 @@ def test_color_validator_object(color_validator, color_object_xarray):
126126
res = color_validator.validate_coerce(color_object_xarray)
127127

128128
# Check type
129-
assert isinstance(res, list)
129+
assert isinstance(res, np.ndarray)
130+
131+
# Check dtype
132+
assert res.dtype == "object"
130133

131134
# Check values
132-
assert res == list(color_object_xarray)
135+
np.testing.assert_array_equal(res, color_object_xarray)

Diff for: packages/python/plotly/plotly/tests/test_io/test_to_from_plotly_json.py

+7
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import pytest
22
import plotly.io.json as pio
33
import plotly.graph_objects as go
4+
import plotly.express as px
45
import numpy as np
56
import pandas as pd
67
import json
@@ -202,3 +203,9 @@ def to_str(v):
202203
expected = build_test_dict_string(array_str)
203204
assert result == expected
204205
check_roundtrip(result, engine=engine, pretty=pretty)
206+
207+
208+
def test_object_array(engine, pretty):
209+
fig = px.scatter(px.data.tips(), x="total_bill", y="tip", custom_data=["sex"])
210+
result = fig.to_plotly_json()
211+
check_roundtrip(result, engine=engine, pretty=pretty)

Diff for: packages/python/plotly/plotly/tests/test_optional/test_px/test_px.py

+4-4
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,8 @@ def test_custom_data_scatter():
3838
hover_data=["petal_length", "petal_width"],
3939
custom_data=["species_id", "species"],
4040
)
41-
assert [e[0] for e in fig.data[0].customdata] == iris.species_id.to_list()
42-
assert len(fig.data[0].customdata[0]) == 4
41+
assert np.all(fig.data[0].customdata[:, 0] == iris.species_id)
42+
assert fig.data[0].customdata.shape[1] == 4
4343
# Hover and custom data, with repeated arguments
4444
fig = px.scatter(
4545
iris,
@@ -48,8 +48,8 @@ def test_custom_data_scatter():
4848
hover_data=["petal_length", "petal_width", "species_id"],
4949
custom_data=["species_id", "species"],
5050
)
51-
assert [e[0] for e in fig.data[0].customdata] == iris.species_id.tolist()
52-
assert len(fig.data[0].customdata[0]) == 4
51+
assert np.all(fig.data[0].customdata[:, 0] == iris.species_id)
52+
assert fig.data[0].customdata.shape[1] == 4
5353
assert (
5454
fig.data[0].hovertemplate
5555
== "sepal_width=%{x}<br>sepal_length=%{y}<br>petal_length=%{customdata[2]}<br>petal_width=%{customdata[3]}<br>species_id=%{customdata[0]}<extra></extra>"

Diff for: packages/python/plotly/plotly/tests/test_optional/test_px/test_px_functions.py

+3-3
Original file line numberDiff line numberDiff line change
@@ -229,9 +229,9 @@ def test_sunburst_treemap_with_path_color():
229229
df["hover"] = [el.lower() for el in vendors]
230230
fig = px.sunburst(df, path=path, color="calls", hover_data=["hover"])
231231
custom = fig.data[0].customdata
232-
assert [el[0] for el in custom[:8]] == df["hover"].tolist()
233-
assert [el[0] for el in custom[8:]] == ["(?)"] * 7
234-
assert [el[1] for el in custom[:8]] == df["calls"].tolist()
232+
assert np.all(custom[:8, 0] == df["hover"])
233+
assert np.all(custom[8:, 0] == "(?)")
234+
assert np.all(custom[:8, 1] == df["calls"])
235235

236236
# Discrete color
237237
fig = px.sunburst(df, path=path, color="vendors")

Diff for: packages/python/plotly/plotly/tests/test_optional/test_px/test_px_input.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ def test_repeated_name():
126126
hover_data=["petal_length", "petal_width", "species_id"],
127127
custom_data=["species_id", "species"],
128128
)
129-
assert len(fig.data[0].customdata[0]) == 4
129+
assert fig.data[0].customdata.shape[1] == 4
130130

131131

132132
def test_arrayattrable_numpy():

0 commit comments

Comments
 (0)