Skip to content

Commit 3b1b5c0

Browse files
committed
BUG: cannot subtract Timestamp with different timezones (#31793)
1 parent 72327f3 commit 3b1b5c0

File tree

7 files changed

+112
-35
lines changed

7 files changed

+112
-35
lines changed

doc/source/whatsnew/v1.4.0.rst

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

491492
Numeric

pandas/_libs/tslibs/timestamps.pyx

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

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

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

pandas/core/arrays/datetimes.py

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

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

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

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

789788
i8 = self.asi8
790789
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
@@ -845,6 +845,61 @@ def test_dt64arr_isub_timedeltalike_scalar(
845845
rng -= two_hours
846846
tm.assert_equal(rng, expected)
847847

848+
def test_dt64_array_sub_dt_with_different_timezone(self, box_with_array):
849+
t1 = date_range("20130101", periods=3).tz_localize("US/Eastern")
850+
t1 = tm.box_expected(t1, box_with_array)
851+
t2 = Timestamp("20130101").tz_localize("CET")
852+
tnaive = Timestamp(20130101)
853+
854+
result = t1 - t2
855+
expected = TimedeltaIndex(
856+
["0 days 06:00:00", "1 days 06:00:00", "2 days 06:00:00"]
857+
)
858+
expected = tm.box_expected(expected, box_with_array)
859+
tm.assert_equal(result, expected)
860+
861+
result = t2 - t1
862+
expected = TimedeltaIndex(
863+
["-1 days +18:00:00", "-2 days +18:00:00", "-3 days +18:00:00"]
864+
)
865+
expected = tm.box_expected(expected, box_with_array)
866+
tm.assert_equal(result, expected)
867+
868+
msg = "Cannot subtract tz-naive and tz-aware datetime-like objects"
869+
with pytest.raises(TypeError, match=msg):
870+
t1 - tnaive
871+
872+
with pytest.raises(TypeError, match=msg):
873+
tnaive - t1
874+
875+
def test_dt64_array_sub_dt64_array_with_different_timezone(self, box_with_array):
876+
t1 = date_range("20130101", periods=3).tz_localize("US/Eastern")
877+
t1 = tm.box_expected(t1, box_with_array)
878+
t2 = date_range("20130101", periods=3).tz_localize("CET")
879+
t2 = tm.box_expected(t2, box_with_array)
880+
tnaive = date_range("20130101", periods=3)
881+
882+
result = t1 - t2
883+
expected = TimedeltaIndex(
884+
["0 days 06:00:00", "0 days 06:00:00", "0 days 06:00:00"]
885+
)
886+
expected = tm.box_expected(expected, box_with_array)
887+
tm.assert_equal(result, expected)
888+
889+
result = t2 - t1
890+
expected = TimedeltaIndex(
891+
["-1 days +18:00:00", "-1 days +18:00:00", "-1 days +18:00:00"]
892+
)
893+
expected = tm.box_expected(expected, box_with_array)
894+
tm.assert_equal(result, expected)
895+
896+
msg = "Cannot subtract tz-naive and tz-aware datetime-like objects"
897+
with pytest.raises(TypeError, match=msg):
898+
t1 - tnaive
899+
900+
with pytest.raises(TypeError, match=msg):
901+
tnaive - t1
902+
848903
# TODO: redundant with test_dt64arr_add_timedeltalike_scalar
849904
def test_dt64arr_add_td64_scalar(self, box_with_array):
850905
# scalar timedeltas/np.timedelta64 objects
@@ -1026,7 +1081,7 @@ def test_dt64arr_aware_sub_dt64ndarray_raises(
10261081
dt64vals = dti.values
10271082

10281083
dtarr = tm.box_expected(dti, box_with_array)
1029-
msg = "subtraction must have the same timezones or"
1084+
msg = "Cannot subtract tz-naive and tz-aware datetime"
10301085
with pytest.raises(TypeError, match=msg):
10311086
dtarr - dt64vals
10321087
with pytest.raises(TypeError, match=msg):
@@ -2223,24 +2278,20 @@ def test_sub_dti_dti(self):
22232278

22242279
dti = date_range("20130101", periods=3)
22252280
dti_tz = date_range("20130101", periods=3).tz_localize("US/Eastern")
2226-
dti_tz2 = date_range("20130101", periods=3).tz_localize("UTC")
22272281
expected = TimedeltaIndex([0, 0, 0])
22282282

22292283
result = dti - dti
22302284
tm.assert_index_equal(result, expected)
22312285

22322286
result = dti_tz - dti_tz
22332287
tm.assert_index_equal(result, expected)
2234-
msg = "DatetimeArray subtraction must have the same timezones or"
2288+
msg = "Cannot subtract tz-naive and tz-aware datetime-like objects"
22352289
with pytest.raises(TypeError, match=msg):
22362290
dti_tz - dti
22372291

22382292
with pytest.raises(TypeError, match=msg):
22392293
dti - dti_tz
22402294

2241-
with pytest.raises(TypeError, match=msg):
2242-
dti_tz - dti_tz2
2243-
22442295
# isub
22452296
dti -= dti
22462297
tm.assert_index_equal(dti, expected)

pandas/tests/arithmetic/test_timedelta64.py

+3-9
Original file line numberDiff line numberDiff line change
@@ -390,35 +390,29 @@ def _check(result, expected):
390390
_check(result, expected)
391391

392392
# tz mismatches
393-
msg = "Timestamp subtraction must have the same timezones or no timezones"
393+
msg = "Cannot subtract tz-naive and tz-aware datetime-like objects."
394394
with pytest.raises(TypeError, match=msg):
395395
dt_tz - ts
396396
msg = "can't subtract offset-naive and offset-aware datetimes"
397397
with pytest.raises(TypeError, match=msg):
398398
dt_tz - dt
399-
msg = "Timestamp subtraction must have the same timezones or no timezones"
400-
with pytest.raises(TypeError, match=msg):
401-
dt_tz - ts_tz2
402399
msg = "can't subtract offset-naive and offset-aware datetimes"
403400
with pytest.raises(TypeError, match=msg):
404401
dt - dt_tz
405-
msg = "Timestamp subtraction must have the same timezones or no timezones"
402+
msg = "Cannot subtract tz-naive and tz-aware datetime-like objects."
406403
with pytest.raises(TypeError, match=msg):
407404
ts - dt_tz
408405
with pytest.raises(TypeError, match=msg):
409406
ts_tz2 - ts
410407
with pytest.raises(TypeError, match=msg):
411408
ts_tz2 - dt
412-
with pytest.raises(TypeError, match=msg):
413-
ts_tz - ts_tz2
414409

410+
msg = "Cannot subtract tz-naive and tz-aware"
415411
# with dti
416412
with pytest.raises(TypeError, match=msg):
417413
dti - ts_tz
418414
with pytest.raises(TypeError, match=msg):
419415
dti_tz - ts
420-
with pytest.raises(TypeError, match=msg):
421-
dti_tz - ts_tz2
422416

423417
result = dti_tz - dt_tz
424418
expected = TimedeltaIndex(["0 days", "1 days", "2 days"])

pandas/tests/scalar/timestamp/test_arithmetic.py

+33-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,37 @@ 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+
112144
def test_addition_subtraction_types(self):
113145
# Assert on the types resulting from Timestamp +/- various date/time
114146
# objects

pandas/tests/series/methods/test_shift.py

+4-4
Original file line numberDiff line numberDiff line change
@@ -110,11 +110,11 @@ def test_shift(self, datetime_series):
110110
exp = Series(TimedeltaIndex(["NaT"] + ["1 days"] * 4), name="foo")
111111
tm.assert_series_equal(result, exp)
112112

113-
# incompat tz
113+
# mismatched timezone
114114
s2 = Series(date_range("2000-01-01 09:00:00", periods=5, tz="CET"), name="foo")
115-
msg = "DatetimeArray subtraction must have the same timezones or no timezones"
116-
with pytest.raises(TypeError, match=msg):
117-
s - s2
115+
exp = Series(TimedeltaIndex(["6 hours"] * 5), name="foo")
116+
result = s - s2
117+
tm.assert_series_equal(result, exp)
118118

119119
def test_shift2(self):
120120
ts = Series(

0 commit comments

Comments
 (0)