Skip to content

Commit 3601322

Browse files
fix PX timezone treatment
1 parent 91e269f commit 3601322

File tree

6 files changed

+68
-50
lines changed

6 files changed

+68
-50
lines changed

Diff for: CHANGELOG.md

+7-1
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,15 @@ This project adheres to [Semantic Versioning](http://semver.org/).
1616
`binary_backend`, `binary_format` and `binary_compression_level` control
1717
how to generate the b64 string ([#2691](https://github.com/plotly/plotly.py/pull/2691)
1818
- `px.imshow` has a new `constrast_rescaling` argument in order to choose how
19-
to set data values corresponding to the bounds of the color range
19+
to set data values corresponding to the bounds of the color range
2020
([#2691](https://github.com/plotly/plotly.py/pull/2691)
2121

22+
### Fixed
23+
24+
- Plotly Express no longer converts datetime columns of input dataframes to UTC ([#2749](https://github.com/plotly/plotly.py/pull/2749))
25+
- Plotly Express has more complete support for datetimes as additional `hover_data` ([#2749](https://github.com/plotly/plotly.py/pull/2749))
26+
27+
2228
## [4.9.0] - 2020-07-16
2329

2430
### Added

Diff for: packages/python/plotly/plotly/express/_core.py

+29-34
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,6 @@ def make_trace_kwargs(args, trace_spec, trace_data, mapping_labels, sizeref):
222222
trace_patch = trace_spec.trace_patch.copy() or {}
223223
fit_results = None
224224
hover_header = ""
225-
custom_data_len = 0
226225
for attr_name in trace_spec.attrs:
227226
attr_value = args[attr_name]
228227
attr_label = get_decorated_label(args, attr_value, attr_name)
@@ -243,7 +242,7 @@ def make_trace_kwargs(args, trace_spec, trace_data, mapping_labels, sizeref):
243242
)
244243
]
245244
trace_patch["dimensions"] = [
246-
dict(label=get_label(args, name), values=column.values)
245+
dict(label=get_label(args, name), values=column)
247246
for (name, column) in dims
248247
]
249248
if trace_spec.constructor == go.Splom:
@@ -287,10 +286,8 @@ def make_trace_kwargs(args, trace_spec, trace_data, mapping_labels, sizeref):
287286
y = sorted_trace_data[args["y"]].values
288287
x = sorted_trace_data[args["x"]].values
289288

290-
x_is_date = False
291289
if x.dtype.type == np.datetime64:
292290
x = x.astype(int) / 10 ** 9 # convert to unix epoch seconds
293-
x_is_date = True
294291
elif x.dtype.type == np.object_:
295292
try:
296293
x = x.astype(np.float64)
@@ -308,21 +305,22 @@ def make_trace_kwargs(args, trace_spec, trace_data, mapping_labels, sizeref):
308305
"Could not convert value of 'y' into a numeric type."
309306
)
310307

308+
# preserve original values of "x" in case they're dates
309+
trace_patch["x"] = sorted_trace_data[args["x"]][
310+
np.logical_not(np.logical_or(np.isnan(y), np.isnan(x)))
311+
]
312+
311313
if attr_value == "lowess":
312314
# missing ='drop' is the default value for lowess but not for OLS (None)
313315
# we force it here in case statsmodels change their defaults
314316
trendline = sm.nonparametric.lowess(y, x, missing="drop")
315-
trace_patch["x"] = trendline[:, 0]
316317
trace_patch["y"] = trendline[:, 1]
317318
hover_header = "<b>LOWESS trendline</b><br><br>"
318319
elif attr_value == "ols":
319320
fit_results = sm.OLS(
320321
y, sm.add_constant(x), missing="drop"
321322
).fit()
322323
trace_patch["y"] = fit_results.predict()
323-
trace_patch["x"] = x[
324-
np.logical_not(np.logical_or(np.isnan(y), np.isnan(x)))
325-
]
326324
hover_header = "<b>OLS trendline</b><br>"
327325
if len(fit_results.params) == 2:
328326
hover_header += "%s = %g * %s + %g<br>" % (
@@ -339,8 +337,6 @@ def make_trace_kwargs(args, trace_spec, trace_data, mapping_labels, sizeref):
339337
hover_header += (
340338
"R<sup>2</sup>=%f<br><br>" % fit_results.rsquared
341339
)
342-
if x_is_date:
343-
trace_patch["x"] = pd.to_datetime(trace_patch["x"] * 10 ** 9)
344340
mapping_labels[get_label(args, args["x"])] = "%{x}"
345341
mapping_labels[get_label(args, args["y"])] = "%{y} <b>(trend)</b>"
346342
elif attr_name.startswith("error"):
@@ -350,8 +346,9 @@ def make_trace_kwargs(args, trace_spec, trace_data, mapping_labels, sizeref):
350346
trace_patch[error_xy] = {}
351347
trace_patch[error_xy][arr] = trace_data[attr_value]
352348
elif attr_name == "custom_data":
353-
trace_patch["customdata"] = trace_data[attr_value].values
354-
custom_data_len = len(attr_value) # number of custom data columns
349+
# here we store a data frame in customdata, and it's serialized
350+
# as a list of row lists, which is what we want
351+
trace_patch["customdata"] = trace_data[attr_value]
355352
elif attr_name == "hover_name":
356353
if trace_spec.constructor not in [
357354
go.Histogram,
@@ -368,29 +365,23 @@ def make_trace_kwargs(args, trace_spec, trace_data, mapping_labels, sizeref):
368365
go.Histogram2dContour,
369366
]:
370367
hover_is_dict = isinstance(attr_value, dict)
368+
customdata_cols = args.get("custom_data") or []
371369
for col in attr_value:
372370
if hover_is_dict and not attr_value[col]:
373371
continue
374372
try:
375373
position = args["custom_data"].index(col)
376374
except (ValueError, AttributeError, KeyError):
377-
position = custom_data_len
378-
custom_data_len += 1
379-
if "customdata" in trace_patch:
380-
trace_patch["customdata"] = np.hstack(
381-
(
382-
trace_patch["customdata"],
383-
trace_data[col].values[:, None],
384-
)
385-
)
386-
else:
387-
trace_patch["customdata"] = trace_data[col].values[
388-
:, None
389-
]
375+
position = len(customdata_cols)
376+
customdata_cols.append(col)
390377
attr_label_col = get_decorated_label(args, col, None)
391378
mapping_labels[attr_label_col] = "%%{customdata[%d]}" % (
392379
position
393380
)
381+
382+
# here we store a data frame in customdata, and it's serialized
383+
# as a list of row lists, which is what we want
384+
trace_patch["customdata"] = trace_data[customdata_cols]
394385
elif attr_name == "color":
395386
if trace_spec.constructor in [go.Choropleth, go.Choroplethmapbox]:
396387
trace_patch["z"] = trace_data[attr_value]
@@ -1029,6 +1020,16 @@ def _escape_col_name(df_input, col_name, extra):
10291020
return col_name
10301021

10311022

1023+
def to_unindexed_series(x):
1024+
"""
1025+
assuming x is list-like or even an existing pd.Series, return a new pd.Series with
1026+
no index, without extracting the data from an existing Series via numpy, which
1027+
seems to mangle datetime columns. Stripping the index from existing pd.Series is
1028+
required to get things to match up right in the new DataFrame we're building
1029+
"""
1030+
return pd.Series(x).reset_index(drop=True)
1031+
1032+
10321033
def process_args_into_dataframe(args, wide_mode, var_name, value_name):
10331034
"""
10341035
After this function runs, the `all_attrables` keys of `args` all contain only
@@ -1140,10 +1141,7 @@ def process_args_into_dataframe(args, wide_mode, var_name, value_name):
11401141
length,
11411142
)
11421143
)
1143-
if hasattr(real_argument, "values"):
1144-
df_output[col_name] = real_argument.values
1145-
else:
1146-
df_output[col_name] = np.array(real_argument)
1144+
df_output[col_name] = to_unindexed_series(real_argument)
11471145
elif not df_provided:
11481146
raise ValueError(
11491147
"String or int arguments are only possible when a "
@@ -1178,7 +1176,7 @@ def process_args_into_dataframe(args, wide_mode, var_name, value_name):
11781176
)
11791177
else:
11801178
col_name = str(argument)
1181-
df_output[col_name] = df_input[argument].values
1179+
df_output[col_name] = to_unindexed_series(df_input[argument])
11821180
# ----------------- argument is likely a column / array / list.... -------
11831181
else:
11841182
if df_provided and hasattr(argument, "name"):
@@ -1207,10 +1205,7 @@ def process_args_into_dataframe(args, wide_mode, var_name, value_name):
12071205
"length of previously-processed arguments %s is %d"
12081206
% (field, len(argument), str(list(df_output.columns)), length)
12091207
)
1210-
if hasattr(argument, "values"):
1211-
df_output[str(col_name)] = argument.values
1212-
else:
1213-
df_output[str(col_name)] = np.array(argument)
1208+
df_output[str(col_name)] = to_unindexed_series(argument)
12141209

12151210
# Finally, update argument with column name now that column exists
12161211
assert col_name is not None, (

Diff for: packages/python/plotly/plotly/tests/test_core/test_px/test_px_hover.py

+7
Original file line numberDiff line numberDiff line change
@@ -163,3 +163,10 @@ def test_sunburst_hoverdict_color():
163163
hover_data={"pop": ":,"},
164164
)
165165
assert "color" in fig.data[0].hovertemplate
166+
167+
168+
def test_date_in_hover():
169+
df = pd.DataFrame({"date": ["2015-04-04 19:31:30+1:00"], "value": [3]})
170+
df["date"] = pd.to_datetime(df["date"])
171+
fig = px.scatter(df, x="value", y="value", hover_data=["date"])
172+
assert str(fig.data[0].customdata[0][0]) == str(df["date"][0])

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

+8
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,14 @@ def test_build_df_with_index():
233233
assert_frame_equal(tips.reset_index()[out["data_frame"].columns], out["data_frame"])
234234

235235

236+
def test_timezones():
237+
df = pd.DataFrame({"date": ["2015-04-04 19:31:30+1:00"], "value": [3]})
238+
df["date"] = pd.to_datetime(df["date"])
239+
args = dict(data_frame=df, x="date", y="value")
240+
out = build_dataframe(args, go.Scatter)
241+
assert str(out["data_frame"]["date"][0]) == str(df["date"][0])
242+
243+
236244
def test_non_matching_index():
237245
df = pd.DataFrame(dict(y=[1, 2, 3]), index=["a", "b", "c"])
238246

Diff for: packages/python/plotly/plotly/tests/test_core/test_px/test_trendline.py

+2
Original file line numberDiff line numberDiff line change
@@ -102,9 +102,11 @@ def test_trendline_on_timeseries(mode):
102102
)
103103

104104
df["date"] = pd.to_datetime(df["date"])
105+
df["date"] = df["date"].dt.tz_localize("CET") # force a timezone
105106
fig = px.scatter(df, x="date", y="GOOG", trendline=mode)
106107
assert len(fig.data) == 2
107108
assert len(fig.data[0].x) == len(fig.data[1].x)
108109
assert type(fig.data[0].x[0]) == datetime
109110
assert type(fig.data[1].x[0]) == datetime
110111
assert np.all(fig.data[0].x == fig.data[1].x)
112+
assert str(fig.data[0].x[0]) == str(fig.data[1].x[0])

Diff for: packages/python/plotly/tox.ini

+15-15
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,13 @@
2424
; PASSING ADDITONAL ARGUMENTS TO TEST COMMANDS
2525
; The {posargs} is tox-specific and passes in any command line args after `--`.
2626
; For example, given the testing command in *this* file:
27-
; pytest {posargs} -x plotly/tests/test_core
27+
; pytest {posargs} plotly/tests/test_core
2828
;
2929
; The following command:
3030
; tox -- -k 'not nodev'
3131
;
3232
; Tells tox to call:
33-
; pytest -k 'not nodev' -x plotly/tests/test_core
33+
; pytest -k 'not nodev' plotly/tests/test_core
3434
;
3535

3636
[tox]
@@ -81,25 +81,25 @@ deps=
8181
basepython={env:PLOTLY_TOX_PYTHON_27:}
8282
commands=
8383
python --version
84-
pytest {posargs} -x plotly/tests/test_core
84+
pytest {posargs} plotly/tests/test_core
8585

8686
[testenv:py35-core]
8787
basepython={env:PLOTLY_TOX_PYTHON_35:}
8888
commands=
8989
python --version
90-
pytest {posargs} -x plotly/tests/test_core
90+
pytest {posargs} plotly/tests/test_core
9191

9292
[testenv:py36-core]
9393
basepython={env:PLOTLY_TOX_PYTHON_36:}
9494
commands=
9595
python --version
96-
pytest {posargs} -x plotly/tests/test_core
96+
pytest {posargs} plotly/tests/test_core
9797

9898
[testenv:py37-core]
9999
basepython={env:PLOTLY_TOX_PYTHON_37:}
100100
commands=
101101
python --version
102-
pytest {posargs} -x plotly/tests/test_core
102+
pytest {posargs} plotly/tests/test_core
103103
pytest {posargs} -x test_init/test_dependencies_not_imported.py
104104
pytest {posargs} -x test_init/test_lazy_imports.py
105105

@@ -111,41 +111,41 @@ commands=
111111
;; Do some coverage reporting. No need to do this for all environments.
112112
; mkdir -p {envbindir}/../../coverage-reports/{envname}
113113
; coverage erase
114-
; coverage run --include="*/plotly/*" --omit="*/tests*" {envbindir}/nosetests {posargs} -x plotly/tests
114+
; coverage run --include="*/plotly/*" --omit="*/tests*" {envbindir}/nosetests {posargs} plotly/tests
115115
; coverage html -d "{envbindir}/../../coverage-reports/{envname}" --title={envname}
116116

117117
[testenv:py27-optional]
118118
basepython={env:PLOTLY_TOX_PYTHON_27:}
119119
commands=
120120
python --version
121-
pytest {posargs} -x plotly/tests/test_core
122-
pytest {posargs} -x plotly/tests/test_optional
121+
pytest {posargs} plotly/tests/test_core
122+
pytest {posargs} plotly/tests/test_optional
123123
pytest _plotly_utils/tests/
124124
pytest plotly/tests/test_io
125125

126126
[testenv:py35-optional]
127127
basepython={env:PLOTLY_TOX_PYTHON_35:}
128128
commands=
129129
python --version
130-
pytest {posargs} -x plotly/tests/test_core
131-
pytest {posargs} -x plotly/tests/test_optional
130+
pytest {posargs} plotly/tests/test_core
131+
pytest {posargs} plotly/tests/test_optional
132132
pytest _plotly_utils/tests/
133133
pytest plotly/tests/test_io
134134

135135
[testenv:py36-optional]
136136
basepython={env:PLOTLY_TOX_PYTHON_36:}
137137
commands=
138138
python --version
139-
pytest {posargs} -x plotly/tests/test_core
140-
pytest {posargs} -x plotly/tests/test_optional
139+
pytest {posargs} plotly/tests/test_core
140+
pytest {posargs} plotly/tests/test_optional
141141
pytest _plotly_utils/tests/
142142
pytest plotly/tests/test_io
143143

144144
[testenv:py37-optional]
145145
basepython={env:PLOTLY_TOX_PYTHON_37:}
146146
commands=
147147
python --version
148-
pytest {posargs} -x plotly/tests/test_core
149-
pytest {posargs} -x plotly/tests/test_optional
148+
pytest {posargs} plotly/tests/test_core
149+
pytest {posargs} plotly/tests/test_optional
150150
pytest _plotly_utils/tests/
151151
pytest plotly/tests/test_io

0 commit comments

Comments
 (0)