Skip to content

Commit 5ae855d

Browse files
authored
#31793 Add support for subtracting datetime from Timestamp (#37329)
* BUG: cannot subtract Timestamp with different timezones (#31793) * TST: add index type coverage to timedelta subtraction tests (#31793) * TST: 31739 - add coverage of subtracting datetimes w/differing timezones
1 parent c3d3357 commit 5ae855d

File tree

6 files changed

+131
-37
lines changed

6 files changed

+131
-37
lines changed

doc/source/whatsnew/v1.4.0.rst

+1
Original file line numberDiff line numberDiff line change
@@ -715,6 +715,7 @@ Timezones
715715
^^^^^^^^^
716716
- Bug in :func:`to_datetime` with ``infer_datetime_format=True`` failing to parse zero UTC offset (``Z``) correctly (:issue:`41047`)
717717
- Bug in :meth:`Series.dt.tz_convert` resetting index in a :class:`Series` with :class:`CategoricalIndex` (:issue:`43080`)
718+
- Bug in ``Timestamp`` and ``DatetimeIndex`` incorrectly raising a ``TypeError`` when subtracting two timezone-aware objects with mismatched timezones (:issue:`31793`)
718719
-
719720

720721
Numeric

pandas/_libs/tslibs/timestamps.pyx

+4-4
Original file line numberDiff line numberDiff line change
@@ -342,10 +342,10 @@ cdef class _Timestamp(ABCTimestamp):
342342
else:
343343
self = type(other)(self)
344344

345-
# validate tz's
346-
if not tz_compare(self.tzinfo, other.tzinfo):
347-
raise TypeError("Timestamp subtraction must have the "
348-
"same timezones or no timezones")
345+
if (self.tzinfo is None) ^ (other.tzinfo is None):
346+
raise TypeError(
347+
"Cannot subtract tz-naive and tz-aware datetime-like objects."
348+
)
349349

350350
# scalar Timestamp/datetime - Timestamp/datetime -> yields a
351351
# Timedelta

pandas/core/arrays/datetimes.py

+10-11
Original file line numberDiff line numberDiff line change
@@ -728,12 +728,11 @@ def _sub_datetime_arraylike(self, other):
728728
assert is_datetime64_dtype(other)
729729
other = type(self)(other)
730730

731-
if not self._has_same_tz(other):
732-
# require tz compat
733-
raise TypeError(
734-
f"{type(self).__name__} subtraction must have the same "
735-
"timezones or no timezones"
736-
)
731+
try:
732+
self._assert_tzawareness_compat(other)
733+
except TypeError as error:
734+
new_message = str(error).replace("compare", "subtract")
735+
raise type(error)(new_message) from error
737736

738737
self_i8 = self.asi8
739738
other_i8 = other.asi8
@@ -779,11 +778,11 @@ def _sub_datetimelike_scalar(self, other):
779778
if other is NaT: # type: ignore[comparison-overlap]
780779
return self - NaT
781780

782-
if not self._has_same_tz(other):
783-
# require tz compat
784-
raise TypeError(
785-
"Timestamp subtraction must have the same timezones or no timezones"
786-
)
781+
try:
782+
self._assert_tzawareness_compat(other)
783+
except TypeError as error:
784+
new_message = str(error).replace("compare", "subtract")
785+
raise type(error)(new_message) from error
787786

788787
i8 = self.asi8
789788
result = checked_add_with_arr(i8, -other.value, arr_mask=self._isnan)

pandas/tests/arithmetic/test_datetime64.py

+57-6
Original file line numberDiff line numberDiff line change
@@ -837,6 +837,61 @@ def test_dt64arr_sub_timedeltalike_scalar(
837837
rng -= two_hours
838838
tm.assert_equal(rng, expected)
839839

840+
def test_dt64_array_sub_dt_with_different_timezone(self, box_with_array):
841+
t1 = date_range("20130101", periods=3).tz_localize("US/Eastern")
842+
t1 = tm.box_expected(t1, box_with_array)
843+
t2 = Timestamp("20130101").tz_localize("CET")
844+
tnaive = Timestamp(20130101)
845+
846+
result = t1 - t2
847+
expected = TimedeltaIndex(
848+
["0 days 06:00:00", "1 days 06:00:00", "2 days 06:00:00"]
849+
)
850+
expected = tm.box_expected(expected, box_with_array)
851+
tm.assert_equal(result, expected)
852+
853+
result = t2 - t1
854+
expected = TimedeltaIndex(
855+
["-1 days +18:00:00", "-2 days +18:00:00", "-3 days +18:00:00"]
856+
)
857+
expected = tm.box_expected(expected, box_with_array)
858+
tm.assert_equal(result, expected)
859+
860+
msg = "Cannot subtract tz-naive and tz-aware datetime-like objects"
861+
with pytest.raises(TypeError, match=msg):
862+
t1 - tnaive
863+
864+
with pytest.raises(TypeError, match=msg):
865+
tnaive - t1
866+
867+
def test_dt64_array_sub_dt64_array_with_different_timezone(self, box_with_array):
868+
t1 = date_range("20130101", periods=3).tz_localize("US/Eastern")
869+
t1 = tm.box_expected(t1, box_with_array)
870+
t2 = date_range("20130101", periods=3).tz_localize("CET")
871+
t2 = tm.box_expected(t2, box_with_array)
872+
tnaive = date_range("20130101", periods=3)
873+
874+
result = t1 - t2
875+
expected = TimedeltaIndex(
876+
["0 days 06:00:00", "0 days 06:00:00", "0 days 06:00:00"]
877+
)
878+
expected = tm.box_expected(expected, box_with_array)
879+
tm.assert_equal(result, expected)
880+
881+
result = t2 - t1
882+
expected = TimedeltaIndex(
883+
["-1 days +18:00:00", "-1 days +18:00:00", "-1 days +18:00:00"]
884+
)
885+
expected = tm.box_expected(expected, box_with_array)
886+
tm.assert_equal(result, expected)
887+
888+
msg = "Cannot subtract tz-naive and tz-aware datetime-like objects"
889+
with pytest.raises(TypeError, match=msg):
890+
t1 - tnaive
891+
892+
with pytest.raises(TypeError, match=msg):
893+
tnaive - t1
894+
840895
def test_dt64arr_add_sub_td64_nat(self, box_with_array, tz_naive_fixture):
841896
# GH#23320 special handling for timedelta64("NaT")
842897
tz = tz_naive_fixture
@@ -979,7 +1034,7 @@ def test_dt64arr_aware_sub_dt64ndarray_raises(
9791034
dt64vals = dti.values
9801035

9811036
dtarr = tm.box_expected(dti, box_with_array)
982-
msg = "subtraction must have the same timezones or"
1037+
msg = "Cannot subtract tz-naive and tz-aware datetime"
9831038
with pytest.raises(TypeError, match=msg):
9841039
dtarr - dt64vals
9851040
with pytest.raises(TypeError, match=msg):
@@ -2099,24 +2154,20 @@ def test_sub_dti_dti(self):
20992154

21002155
dti = date_range("20130101", periods=3)
21012156
dti_tz = date_range("20130101", periods=3).tz_localize("US/Eastern")
2102-
dti_tz2 = date_range("20130101", periods=3).tz_localize("UTC")
21032157
expected = TimedeltaIndex([0, 0, 0])
21042158

21052159
result = dti - dti
21062160
tm.assert_index_equal(result, expected)
21072161

21082162
result = dti_tz - dti_tz
21092163
tm.assert_index_equal(result, expected)
2110-
msg = "DatetimeArray subtraction must have the same timezones or"
2164+
msg = "Cannot subtract tz-naive and tz-aware datetime-like objects"
21112165
with pytest.raises(TypeError, match=msg):
21122166
dti_tz - dti
21132167

21142168
with pytest.raises(TypeError, match=msg):
21152169
dti - dti_tz
21162170

2117-
with pytest.raises(TypeError, match=msg):
2118-
dti_tz - dti_tz2
2119-
21202171
# isub
21212172
dti -= dti
21222173
tm.assert_index_equal(dti, expected)

pandas/tests/arithmetic/test_timedelta64.py

+16-15
Original file line numberDiff line numberDiff line change
@@ -357,13 +357,15 @@ def test_subtraction_ops(self):
357357
expected = DatetimeIndex(["20121231", NaT, "20121230"], name="foo")
358358
tm.assert_index_equal(result, expected)
359359

360-
def test_subtraction_ops_with_tz(self):
360+
def test_subtraction_ops_with_tz(self, box_with_array):
361361

362362
# check that dt/dti subtraction ops with tz are validated
363363
dti = pd.date_range("20130101", periods=3)
364+
dti = tm.box_expected(dti, box_with_array)
364365
ts = Timestamp("20130101")
365366
dt = ts.to_pydatetime()
366367
dti_tz = pd.date_range("20130101", periods=3).tz_localize("US/Eastern")
368+
dti_tz = tm.box_expected(dti_tz, box_with_array)
367369
ts_tz = Timestamp("20130101").tz_localize("US/Eastern")
368370
ts_tz2 = Timestamp("20130101").tz_localize("CET")
369371
dt_tz = ts_tz.to_pydatetime()
@@ -387,59 +389,58 @@ def _check(result, expected):
387389
_check(result, expected)
388390

389391
# tz mismatches
390-
msg = "Timestamp subtraction must have the same timezones or no timezones"
392+
msg = "Cannot subtract tz-naive and tz-aware datetime-like objects."
391393
with pytest.raises(TypeError, match=msg):
392394
dt_tz - ts
393395
msg = "can't subtract offset-naive and offset-aware datetimes"
394396
with pytest.raises(TypeError, match=msg):
395397
dt_tz - dt
396-
msg = "Timestamp subtraction must have the same timezones or no timezones"
397-
with pytest.raises(TypeError, match=msg):
398-
dt_tz - ts_tz2
399398
msg = "can't subtract offset-naive and offset-aware datetimes"
400399
with pytest.raises(TypeError, match=msg):
401400
dt - dt_tz
402-
msg = "Timestamp subtraction must have the same timezones or no timezones"
401+
msg = "Cannot subtract tz-naive and tz-aware datetime-like objects."
403402
with pytest.raises(TypeError, match=msg):
404403
ts - dt_tz
405404
with pytest.raises(TypeError, match=msg):
406405
ts_tz2 - ts
407406
with pytest.raises(TypeError, match=msg):
408407
ts_tz2 - dt
409-
with pytest.raises(TypeError, match=msg):
410-
ts_tz - ts_tz2
411408

409+
msg = "Cannot subtract tz-naive and tz-aware"
412410
# with dti
413411
with pytest.raises(TypeError, match=msg):
414412
dti - ts_tz
415413
with pytest.raises(TypeError, match=msg):
416414
dti_tz - ts
417-
with pytest.raises(TypeError, match=msg):
418-
dti_tz - ts_tz2
419415

420416
result = dti_tz - dt_tz
421417
expected = TimedeltaIndex(["0 days", "1 days", "2 days"])
422-
tm.assert_index_equal(result, expected)
418+
expected = tm.box_expected(expected, box_with_array)
419+
tm.assert_equal(result, expected)
423420

424421
result = dt_tz - dti_tz
425422
expected = TimedeltaIndex(["0 days", "-1 days", "-2 days"])
426-
tm.assert_index_equal(result, expected)
423+
expected = tm.box_expected(expected, box_with_array)
424+
tm.assert_equal(result, expected)
427425

428426
result = dti_tz - ts_tz
429427
expected = TimedeltaIndex(["0 days", "1 days", "2 days"])
430-
tm.assert_index_equal(result, expected)
428+
expected = tm.box_expected(expected, box_with_array)
429+
tm.assert_equal(result, expected)
431430

432431
result = ts_tz - dti_tz
433432
expected = TimedeltaIndex(["0 days", "-1 days", "-2 days"])
434-
tm.assert_index_equal(result, expected)
433+
expected = tm.box_expected(expected, box_with_array)
434+
tm.assert_equal(result, expected)
435435

436436
result = td - td
437437
expected = Timedelta("0 days")
438438
_check(result, expected)
439439

440440
result = dti_tz - td
441441
expected = DatetimeIndex(["20121231", "20130101", "20130102"], tz="US/Eastern")
442-
tm.assert_index_equal(result, expected)
442+
expected = tm.box_expected(expected, box_with_array)
443+
tm.assert_equal(result, expected)
443444

444445
def test_dti_tdi_numeric_ops(self):
445446
# These are normally union/diff set-like ops

pandas/tests/scalar/timestamp/test_arithmetic.py

+43-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from datetime import (
22
datetime,
33
timedelta,
4+
timezone,
45
)
56

67
import numpy as np
@@ -99,7 +100,7 @@ def test_rsub_dtscalars(self, tz_naive_fixture):
99100
if tz_naive_fixture is None:
100101
assert other.to_datetime64() - ts == td
101102
else:
102-
msg = "subtraction must have"
103+
msg = "Cannot subtract tz-naive and tz-aware datetime-like objects"
103104
with pytest.raises(TypeError, match=msg):
104105
other.to_datetime64() - ts
105106

@@ -109,6 +110,47 @@ def test_timestamp_sub_datetime(self):
109110
assert (ts - dt).days == 1
110111
assert (dt - ts).days == -1
111112

113+
def test_subtract_tzaware_datetime(self):
114+
t1 = Timestamp("2020-10-22T22:00:00+00:00")
115+
t2 = datetime(2020, 10, 22, 22, tzinfo=timezone.utc)
116+
117+
result = t1 - t2
118+
119+
assert isinstance(result, Timedelta)
120+
assert result == Timedelta("0 days")
121+
122+
def test_subtract_timestamp_from_different_timezone(self):
123+
t1 = Timestamp("20130101").tz_localize("US/Eastern")
124+
t2 = Timestamp("20130101").tz_localize("CET")
125+
126+
result = t1 - t2
127+
128+
assert isinstance(result, Timedelta)
129+
assert result == Timedelta("0 days 06:00:00")
130+
131+
def test_subtracting_involving_datetime_with_different_tz(self):
132+
t1 = datetime(2013, 1, 1, tzinfo=timezone(timedelta(hours=-5)))
133+
t2 = Timestamp("20130101").tz_localize("CET")
134+
135+
result = t1 - t2
136+
137+
assert isinstance(result, Timedelta)
138+
assert result == Timedelta("0 days 06:00:00")
139+
140+
result = t2 - t1
141+
assert isinstance(result, Timedelta)
142+
assert result == Timedelta("-1 days +18:00:00")
143+
144+
def test_subtracting_different_timezones(self, tz_aware_fixture):
145+
t_raw = Timestamp("20130101")
146+
t_UTC = t_raw.tz_localize("UTC")
147+
t_diff = t_UTC.tz_convert(tz_aware_fixture) + Timedelta("0 days 05:00:00")
148+
149+
result = t_diff - t_UTC
150+
151+
assert isinstance(result, Timedelta)
152+
assert result == Timedelta("0 days 05:00:00")
153+
112154
def test_addition_subtraction_types(self):
113155
# Assert on the types resulting from Timestamp +/- various date/time
114156
# objects

0 commit comments

Comments
 (0)