Skip to content

Commit 7f87a03

Browse files
ENH: match stdlib behavior for datetimelike comparisons (#36647)
* ENH: match stdlib behavior for datetimelike comparisons * update test Co-authored-by: Jeff Reback <[email protected]>
1 parent faf6d3f commit 7f87a03

File tree

9 files changed

+104
-67
lines changed

9 files changed

+104
-67
lines changed

doc/source/whatsnew/v1.2.0.rst

+1
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,7 @@ Datetimelike
308308
- Bug in :meth:`DatetimeIndex.searchsorted`, :meth:`TimedeltaIndex.searchsorted`, :meth:`PeriodIndex.searchsorted`, and :meth:`Series.searchsorted` with ``datetime64``, ``timedelta64`` or ``Period`` dtype placement of ``NaT`` values being inconsistent with ``NumPy`` (:issue:`36176`, :issue:`36254`)
309309
- Inconsistency in :class:`DatetimeArray`, :class:`TimedeltaArray`, and :class:`PeriodArray` setitem casting arrays of strings to datetimelike scalars but not scalar strings (:issue:`36261`)
310310
- Bug in :class:`DatetimeIndex.shift` incorrectly raising when shifting empty indexes (:issue:`14811`)
311+
- :class:`Timestamp` and :class:`DatetimeIndex` comparisons between timezone-aware and timezone-naive objects now follow the standard library ``datetime`` behavior, returning ``True``/``False`` for ``!=``/``==`` and raising for inequality comparisons (:issue:`28507`)
311312
- Bug in :meth:`DatetimeIndex.equals` and :meth:`TimedeltaIndex.equals` incorrectly considering ``int64`` indexes as equal (:issue:`36744`)
312313

313314
Timedelta

pandas/_libs/lib.pyx

-7
Original file line numberDiff line numberDiff line change
@@ -584,13 +584,6 @@ def array_equivalent_object(left: object[:], right: object[:]) -> bool:
584584
elif not (PyObject_RichCompareBool(x, y, Py_EQ) or
585585
(x is None or is_nan(x)) and (y is None or is_nan(y))):
586586
return False
587-
except TypeError as err:
588-
# Avoid raising TypeError on tzawareness mismatch
589-
# TODO: This try/except can be removed if/when Timestamp
590-
# comparisons are changed to match datetime, see GH#28507
591-
if "tz-naive and tz-aware" in str(err):
592-
return False
593-
raise
594587
except ValueError:
595588
# Avoid raising ValueError when comparing Numpy arrays to other types
596589
if cnp.PyArray_IsAnyScalar(x) != cnp.PyArray_IsAnyScalar(y):

pandas/_libs/tslibs/timestamps.pxd

+1-1
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@ cdef class _Timestamp(ABCTimestamp):
1919
cdef bint _get_start_end_field(self, str field)
2020
cdef _get_date_name_field(self, str field, object locale)
2121
cdef int64_t _maybe_convert_value_to_local(self)
22+
cdef bint _can_compare(self, datetime other)
2223
cpdef to_datetime64(self)
23-
cdef _assert_tzawareness_compat(_Timestamp self, datetime other)
2424
cpdef datetime to_pydatetime(_Timestamp self, bint warn=*)
2525
cdef bint _compare_outside_nanorange(_Timestamp self, datetime other,
2626
int op) except -1

pandas/_libs/tslibs/timestamps.pyx

+17-9
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,10 @@ cdef class _Timestamp(ABCTimestamp):
260260
if other.dtype.kind == "M":
261261
if self.tz is None:
262262
return PyObject_RichCompare(self.asm8, other, op)
263+
elif op == Py_NE:
264+
return np.ones(other.shape, dtype=np.bool_)
265+
elif op == Py_EQ:
266+
return np.zeros(other.shape, dtype=np.bool_)
263267
raise TypeError(
264268
"Cannot compare tz-naive and tz-aware timestamps"
265269
)
@@ -278,24 +282,28 @@ cdef class _Timestamp(ABCTimestamp):
278282
else:
279283
return NotImplemented
280284

281-
self._assert_tzawareness_compat(ots)
285+
if not self._can_compare(ots):
286+
if op == Py_NE or op == Py_EQ:
287+
return NotImplemented
288+
raise TypeError(
289+
"Cannot compare tz-naive and tz-aware timestamps"
290+
)
282291
return cmp_scalar(self.value, ots.value, op)
283292

284293
cdef bint _compare_outside_nanorange(_Timestamp self, datetime other,
285294
int op) except -1:
286295
cdef:
287296
datetime dtval = self.to_pydatetime()
288297

289-
self._assert_tzawareness_compat(other)
298+
if not self._can_compare(other):
299+
return NotImplemented
300+
290301
return PyObject_RichCompareBool(dtval, other, op)
291302

292-
cdef _assert_tzawareness_compat(_Timestamp self, datetime other):
293-
if self.tzinfo is None:
294-
if other.tzinfo is not None:
295-
raise TypeError('Cannot compare tz-naive and tz-aware '
296-
'timestamps')
297-
elif other.tzinfo is None:
298-
raise TypeError('Cannot compare tz-naive and tz-aware timestamps')
303+
cdef bint _can_compare(self, datetime other):
304+
if self.tzinfo is not None:
305+
return other.tzinfo is not None
306+
return other.tzinfo is None
299307

300308
def __add__(self, other):
301309
cdef:

pandas/core/arrays/datetimelike.py

+11-2
Original file line numberDiff line numberDiff line change
@@ -685,7 +685,11 @@ def _validate_comparison_value(self, other, opname: str):
685685

686686
if isinstance(other, self._recognized_scalars) or other is NaT:
687687
other = self._scalar_type(other) # type: ignore[call-arg]
688-
self._check_compatible_with(other)
688+
try:
689+
self._check_compatible_with(other)
690+
except TypeError as err:
691+
# e.g. tzawareness mismatch
692+
raise InvalidComparison(other) from err
689693

690694
elif not is_list_like(other):
691695
raise InvalidComparison(other)
@@ -696,8 +700,13 @@ def _validate_comparison_value(self, other, opname: str):
696700
else:
697701
try:
698702
other = self._validate_listlike(other, opname, allow_object=True)
703+
self._check_compatible_with(other)
699704
except TypeError as err:
700-
raise InvalidComparison(other) from err
705+
if is_object_dtype(getattr(other, "dtype", None)):
706+
# We will have to operate element-wise
707+
pass
708+
else:
709+
raise InvalidComparison(other) from err
701710

702711
return other
703712

pandas/tests/arithmetic/test_datetime64.py

+52-31
Original file line numberDiff line numberDiff line change
@@ -558,26 +558,30 @@ def test_comparison_tzawareness_compat(self, op, box_with_array):
558558
dr = tm.box_expected(dr, box)
559559
dz = tm.box_expected(dz, box)
560560

561-
msg = "Cannot compare tz-naive and tz-aware"
562-
with pytest.raises(TypeError, match=msg):
563-
op(dr, dz)
564-
565561
if box is pd.DataFrame:
566562
tolist = lambda x: x.astype(object).values.tolist()[0]
567563
else:
568564
tolist = list
569565

570-
with pytest.raises(TypeError, match=msg):
571-
op(dr, tolist(dz))
572-
with pytest.raises(TypeError, match=msg):
573-
op(dr, np.array(tolist(dz), dtype=object))
574-
with pytest.raises(TypeError, match=msg):
575-
op(dz, dr)
566+
if op not in [operator.eq, operator.ne]:
567+
msg = (
568+
r"Invalid comparison between dtype=datetime64\[ns.*\] "
569+
"and (Timestamp|DatetimeArray|list|ndarray)"
570+
)
571+
with pytest.raises(TypeError, match=msg):
572+
op(dr, dz)
576573

577-
with pytest.raises(TypeError, match=msg):
578-
op(dz, tolist(dr))
579-
with pytest.raises(TypeError, match=msg):
580-
op(dz, np.array(tolist(dr), dtype=object))
574+
with pytest.raises(TypeError, match=msg):
575+
op(dr, tolist(dz))
576+
with pytest.raises(TypeError, match=msg):
577+
op(dr, np.array(tolist(dz), dtype=object))
578+
with pytest.raises(TypeError, match=msg):
579+
op(dz, dr)
580+
581+
with pytest.raises(TypeError, match=msg):
582+
op(dz, tolist(dr))
583+
with pytest.raises(TypeError, match=msg):
584+
op(dz, np.array(tolist(dr), dtype=object))
581585

582586
# The aware==aware and naive==naive comparisons should *not* raise
583587
assert np.all(dr == dr)
@@ -609,17 +613,20 @@ def test_comparison_tzawareness_compat_scalars(self, op, box_with_array):
609613
ts_tz = pd.Timestamp("2000-03-14 01:59", tz="Europe/Amsterdam")
610614

611615
assert np.all(dr > ts)
612-
msg = "Cannot compare tz-naive and tz-aware"
613-
with pytest.raises(TypeError, match=msg):
614-
op(dr, ts_tz)
616+
msg = r"Invalid comparison between dtype=datetime64\[ns.*\] and Timestamp"
617+
if op not in [operator.eq, operator.ne]:
618+
with pytest.raises(TypeError, match=msg):
619+
op(dr, ts_tz)
615620

616621
assert np.all(dz > ts_tz)
617-
with pytest.raises(TypeError, match=msg):
618-
op(dz, ts)
622+
if op not in [operator.eq, operator.ne]:
623+
with pytest.raises(TypeError, match=msg):
624+
op(dz, ts)
619625

620-
# GH#12601: Check comparison against Timestamps and DatetimeIndex
621-
with pytest.raises(TypeError, match=msg):
622-
op(ts, dz)
626+
if op not in [operator.eq, operator.ne]:
627+
# GH#12601: Check comparison against Timestamps and DatetimeIndex
628+
with pytest.raises(TypeError, match=msg):
629+
op(ts, dz)
623630

624631
@pytest.mark.parametrize(
625632
"op",
@@ -637,15 +644,31 @@ def test_comparison_tzawareness_compat_scalars(self, op, box_with_array):
637644
def test_scalar_comparison_tzawareness(
638645
self, op, other, tz_aware_fixture, box_with_array
639646
):
647+
box = box_with_array
640648
tz = tz_aware_fixture
641649
dti = pd.date_range("2016-01-01", periods=2, tz=tz)
650+
xbox = box if box not in [pd.Index, pd.array] else np.ndarray
642651

643652
dtarr = tm.box_expected(dti, box_with_array)
644-
msg = "Cannot compare tz-naive and tz-aware"
645-
with pytest.raises(TypeError, match=msg):
646-
op(dtarr, other)
647-
with pytest.raises(TypeError, match=msg):
648-
op(other, dtarr)
653+
if op in [operator.eq, operator.ne]:
654+
exbool = op is operator.ne
655+
expected = np.array([exbool, exbool], dtype=bool)
656+
expected = tm.box_expected(expected, xbox)
657+
658+
result = op(dtarr, other)
659+
tm.assert_equal(result, expected)
660+
661+
result = op(other, dtarr)
662+
tm.assert_equal(result, expected)
663+
else:
664+
msg = (
665+
r"Invalid comparison between dtype=datetime64\[ns, .*\] "
666+
f"and {type(other).__name__}"
667+
)
668+
with pytest.raises(TypeError, match=msg):
669+
op(dtarr, other)
670+
with pytest.raises(TypeError, match=msg):
671+
op(other, dtarr)
649672

650673
@pytest.mark.parametrize(
651674
"op",
@@ -745,10 +768,8 @@ def test_dti_cmp_object_dtype(self):
745768
tm.assert_numpy_array_equal(result, expected)
746769

747770
other = dti.tz_localize(None)
748-
msg = "Cannot compare tz-naive and tz-aware"
749-
with pytest.raises(TypeError, match=msg):
750-
# tzawareness failure
751-
dti != other
771+
result = dti != other
772+
tm.assert_numpy_array_equal(result, expected)
752773

753774
other = np.array(list(dti[:5]) + [Timedelta(days=1)] * 5)
754775
result = dti == other

pandas/tests/reductions/test_reductions.py

+5-5
Original file line numberDiff line numberDiff line change
@@ -56,13 +56,13 @@ def test_ops(self, opname, obj):
5656
expected = getattr(obj.values, opname)()
5757
else:
5858
expected = pd.Period(ordinal=getattr(obj.asi8, opname)(), freq=obj.freq)
59-
try:
60-
assert result == expected
61-
except TypeError:
62-
# comparing tz-aware series with np.array results in
63-
# TypeError
59+
60+
if getattr(obj, "tz", None) is not None:
61+
# We need to de-localize before comparing to the numpy-produced result
6462
expected = expected.astype("M8[ns]").astype("int64")
6563
assert result.value == expected
64+
else:
65+
assert result == expected
6666

6767
@pytest.mark.parametrize("opname", ["max", "min"])
6868
@pytest.mark.parametrize(

pandas/tests/scalar/timestamp/test_comparisons.py

+16-11
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,18 @@ def test_comparison_dt64_ndarray_tzaware(self, reverse, all_compare_operators):
5656
if reverse:
5757
left, right = arr, ts
5858

59-
msg = "Cannot compare tz-naive and tz-aware timestamps"
60-
with pytest.raises(TypeError, match=msg):
61-
op(left, right)
59+
if op is operator.eq:
60+
expected = np.array([False, False], dtype=bool)
61+
result = op(left, right)
62+
tm.assert_numpy_array_equal(result, expected)
63+
elif op is operator.ne:
64+
expected = np.array([True, True], dtype=bool)
65+
result = op(left, right)
66+
tm.assert_numpy_array_equal(result, expected)
67+
else:
68+
msg = "Cannot compare tz-naive and tz-aware timestamps"
69+
with pytest.raises(TypeError, match=msg):
70+
op(left, right)
6271

6372
def test_comparison_object_array(self):
6473
# GH#15183
@@ -139,10 +148,8 @@ def test_cant_compare_tz_naive_w_aware(self, utc_fixture):
139148
b = Timestamp("3/12/2012", tz=utc_fixture)
140149

141150
msg = "Cannot compare tz-naive and tz-aware timestamps"
142-
with pytest.raises(TypeError, match=msg):
143-
a == b
144-
with pytest.raises(TypeError, match=msg):
145-
a != b
151+
assert not a == b
152+
assert a != b
146153
with pytest.raises(TypeError, match=msg):
147154
a < b
148155
with pytest.raises(TypeError, match=msg):
@@ -152,10 +159,8 @@ def test_cant_compare_tz_naive_w_aware(self, utc_fixture):
152159
with pytest.raises(TypeError, match=msg):
153160
a >= b
154161

155-
with pytest.raises(TypeError, match=msg):
156-
b == a
157-
with pytest.raises(TypeError, match=msg):
158-
b != a
162+
assert not b == a
163+
assert b != a
159164
with pytest.raises(TypeError, match=msg):
160165
b < a
161166
with pytest.raises(TypeError, match=msg):

pandas/tests/series/indexing/test_datetime.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -258,7 +258,7 @@ def test_getitem_setitem_datetimeindex():
258258

259259
lb = datetime(1990, 1, 1, 4)
260260
rb = datetime(1990, 1, 1, 7)
261-
msg = "Cannot compare tz-naive and tz-aware datetime-like objects"
261+
msg = r"Invalid comparison between dtype=datetime64\[ns, US/Eastern\] and datetime"
262262
with pytest.raises(TypeError, match=msg):
263263
# tznaive vs tzaware comparison is invalid
264264
# see GH#18376, GH#18162

0 commit comments

Comments
 (0)