Skip to content

Commit b104360

Browse files
gh-49766: Make date-datetime comparison more symmetric and flexible (GH-114760)
Now the special comparison methods like `__eq__` and `__lt__` return NotImplemented if one of comparands is date and other is datetime instead of ignoring the time part and the time zone or forcefully return "not equal" or raise TypeError. It makes comparison of date and datetime subclasses more symmetric and allows to change the default behavior by overriding the special comparison methods in subclasses. It is now the same as if date and datetime was independent classes.
1 parent d9d6909 commit b104360

File tree

5 files changed

+91
-84
lines changed

5 files changed

+91
-84
lines changed

Doc/library/datetime.rst

+26-6
Original file line numberDiff line numberDiff line change
@@ -619,11 +619,27 @@ Notes:
619619
(4)
620620
:class:`date` objects are equal if they represent the same date.
621621

622+
:class:`!date` objects that are not also :class:`.datetime` instances
623+
are never equal to :class:`!datetime` objects, even if they represent
624+
the same date.
625+
622626
(5)
623627
*date1* is considered less than *date2* when *date1* precedes *date2* in time.
624628
In other words, ``date1 < date2`` if and only if ``date1.toordinal() <
625629
date2.toordinal()``.
626630

631+
Order comparison between a :class:`!date` object that is not also a
632+
:class:`.datetime` instance and a :class:`!datetime` object raises
633+
:exc:`TypeError`.
634+
635+
.. versionchanged:: 3.13
636+
Comparison between :class:`.datetime` object and an instance of
637+
the :class:`date` subclass that is not a :class:`!datetime` subclass
638+
no longer coverts the latter to :class:`!date`, ignoring the time part
639+
and the time zone.
640+
The default behavior can be changed by overriding the special comparison
641+
methods in subclasses.
642+
627643
In Boolean contexts, all :class:`date` objects are considered to be true.
628644

629645
Instance methods:
@@ -1192,9 +1208,6 @@ Supported operations:
11921208
and time, taking into account the time zone.
11931209

11941210
Naive and aware :class:`!datetime` objects are never equal.
1195-
:class:`!datetime` objects are never equal to :class:`date` objects
1196-
that are not also :class:`!datetime` instances, even if they represent
1197-
the same date.
11981211

11991212
If both comparands are aware and have different :attr:`~.datetime.tzinfo`
12001213
attributes, the comparison acts as comparands were first converted to UTC
@@ -1206,9 +1219,8 @@ Supported operations:
12061219
*datetime1* is considered less than *datetime2* when *datetime1* precedes
12071220
*datetime2* in time, taking into account the time zone.
12081221

1209-
Order comparison between naive and aware :class:`.datetime` objects,
1210-
as well as a :class:`!datetime` object and a :class:`!date` object
1211-
that is not also a :class:`!datetime` instance, raises :exc:`TypeError`.
1222+
Order comparison between naive and aware :class:`.datetime` objects
1223+
raises :exc:`TypeError`.
12121224

12131225
If both comparands are aware and have different :attr:`~.datetime.tzinfo`
12141226
attributes, the comparison acts as comparands were first converted to UTC
@@ -1218,6 +1230,14 @@ Supported operations:
12181230
Equality comparisons between aware and naive :class:`.datetime`
12191231
instances don't raise :exc:`TypeError`.
12201232

1233+
.. versionchanged:: 3.13
1234+
Comparison between :class:`.datetime` object and an instance of
1235+
the :class:`date` subclass that is not a :class:`!datetime` subclass
1236+
no longer coverts the latter to :class:`!date`, ignoring the time part
1237+
and the time zone.
1238+
The default behavior can be changed by overriding the special comparison
1239+
methods in subclasses.
1240+
12211241
Instance methods:
12221242

12231243
.. method:: datetime.date()

Lib/_pydatetime.py

+11-24
Original file line numberDiff line numberDiff line change
@@ -556,10 +556,6 @@ def _check_tzinfo_arg(tz):
556556
if tz is not None and not isinstance(tz, tzinfo):
557557
raise TypeError("tzinfo argument must be None or of a tzinfo subclass")
558558

559-
def _cmperror(x, y):
560-
raise TypeError("can't compare '%s' to '%s'" % (
561-
type(x).__name__, type(y).__name__))
562-
563559
def _divide_and_round(a, b):
564560
"""divide a by b and round result to the nearest integer
565561
@@ -1113,32 +1109,33 @@ def replace(self, year=None, month=None, day=None):
11131109
# Comparisons of date objects with other.
11141110

11151111
def __eq__(self, other):
1116-
if isinstance(other, date):
1112+
if isinstance(other, date) and not isinstance(other, datetime):
11171113
return self._cmp(other) == 0
11181114
return NotImplemented
11191115

11201116
def __le__(self, other):
1121-
if isinstance(other, date):
1117+
if isinstance(other, date) and not isinstance(other, datetime):
11221118
return self._cmp(other) <= 0
11231119
return NotImplemented
11241120

11251121
def __lt__(self, other):
1126-
if isinstance(other, date):
1122+
if isinstance(other, date) and not isinstance(other, datetime):
11271123
return self._cmp(other) < 0
11281124
return NotImplemented
11291125

11301126
def __ge__(self, other):
1131-
if isinstance(other, date):
1127+
if isinstance(other, date) and not isinstance(other, datetime):
11321128
return self._cmp(other) >= 0
11331129
return NotImplemented
11341130

11351131
def __gt__(self, other):
1136-
if isinstance(other, date):
1132+
if isinstance(other, date) and not isinstance(other, datetime):
11371133
return self._cmp(other) > 0
11381134
return NotImplemented
11391135

11401136
def _cmp(self, other):
11411137
assert isinstance(other, date)
1138+
assert not isinstance(other, datetime)
11421139
y, m, d = self._year, self._month, self._day
11431140
y2, m2, d2 = other._year, other._month, other._day
11441141
return _cmp((y, m, d), (y2, m2, d2))
@@ -2137,42 +2134,32 @@ def dst(self):
21372134
def __eq__(self, other):
21382135
if isinstance(other, datetime):
21392136
return self._cmp(other, allow_mixed=True) == 0
2140-
elif not isinstance(other, date):
2141-
return NotImplemented
21422137
else:
2143-
return False
2138+
return NotImplemented
21442139

21452140
def __le__(self, other):
21462141
if isinstance(other, datetime):
21472142
return self._cmp(other) <= 0
2148-
elif not isinstance(other, date):
2149-
return NotImplemented
21502143
else:
2151-
_cmperror(self, other)
2144+
return NotImplemented
21522145

21532146
def __lt__(self, other):
21542147
if isinstance(other, datetime):
21552148
return self._cmp(other) < 0
2156-
elif not isinstance(other, date):
2157-
return NotImplemented
21582149
else:
2159-
_cmperror(self, other)
2150+
return NotImplemented
21602151

21612152
def __ge__(self, other):
21622153
if isinstance(other, datetime):
21632154
return self._cmp(other) >= 0
2164-
elif not isinstance(other, date):
2165-
return NotImplemented
21662155
else:
2167-
_cmperror(self, other)
2156+
return NotImplemented
21682157

21692158
def __gt__(self, other):
21702159
if isinstance(other, datetime):
21712160
return self._cmp(other) > 0
2172-
elif not isinstance(other, date):
2173-
return NotImplemented
21742161
else:
2175-
_cmperror(self, other)
2162+
return NotImplemented
21762163

21772164
def _cmp(self, other, allow_mixed=False):
21782165
assert isinstance(other, datetime)

Lib/test/datetimetester.py

+36-28
Original file line numberDiff line numberDiff line change
@@ -5435,42 +5435,50 @@ def fromutc(self, dt):
54355435

54365436
class Oddballs(unittest.TestCase):
54375437

5438-
def test_bug_1028306(self):
5438+
def test_date_datetime_comparison(self):
5439+
# bpo-1028306, bpo-5516 (gh-49766)
54395440
# Trying to compare a date to a datetime should act like a mixed-
54405441
# type comparison, despite that datetime is a subclass of date.
54415442
as_date = date.today()
54425443
as_datetime = datetime.combine(as_date, time())
5443-
self.assertTrue(as_date != as_datetime)
5444-
self.assertTrue(as_datetime != as_date)
5445-
self.assertFalse(as_date == as_datetime)
5446-
self.assertFalse(as_datetime == as_date)
5447-
self.assertRaises(TypeError, lambda: as_date < as_datetime)
5448-
self.assertRaises(TypeError, lambda: as_datetime < as_date)
5449-
self.assertRaises(TypeError, lambda: as_date <= as_datetime)
5450-
self.assertRaises(TypeError, lambda: as_datetime <= as_date)
5451-
self.assertRaises(TypeError, lambda: as_date > as_datetime)
5452-
self.assertRaises(TypeError, lambda: as_datetime > as_date)
5453-
self.assertRaises(TypeError, lambda: as_date >= as_datetime)
5454-
self.assertRaises(TypeError, lambda: as_datetime >= as_date)
5455-
5456-
# Nevertheless, comparison should work with the base-class (date)
5457-
# projection if use of a date method is forced.
5458-
self.assertEqual(as_date.__eq__(as_datetime), True)
5459-
different_day = (as_date.day + 1) % 20 + 1
5460-
as_different = as_datetime.replace(day= different_day)
5461-
self.assertEqual(as_date.__eq__(as_different), False)
5444+
date_sc = SubclassDate(as_date.year, as_date.month, as_date.day)
5445+
datetime_sc = SubclassDatetime(as_date.year, as_date.month,
5446+
as_date.day, 0, 0, 0)
5447+
for d in (as_date, date_sc):
5448+
for dt in (as_datetime, datetime_sc):
5449+
for x, y in (d, dt), (dt, d):
5450+
self.assertTrue(x != y)
5451+
self.assertFalse(x == y)
5452+
self.assertRaises(TypeError, lambda: x < y)
5453+
self.assertRaises(TypeError, lambda: x <= y)
5454+
self.assertRaises(TypeError, lambda: x > y)
5455+
self.assertRaises(TypeError, lambda: x >= y)
54625456

54635457
# And date should compare with other subclasses of date. If a
54645458
# subclass wants to stop this, it's up to the subclass to do so.
5465-
date_sc = SubclassDate(as_date.year, as_date.month, as_date.day)
5466-
self.assertEqual(as_date, date_sc)
5467-
self.assertEqual(date_sc, as_date)
5468-
54695459
# Ditto for datetimes.
5470-
datetime_sc = SubclassDatetime(as_datetime.year, as_datetime.month,
5471-
as_date.day, 0, 0, 0)
5472-
self.assertEqual(as_datetime, datetime_sc)
5473-
self.assertEqual(datetime_sc, as_datetime)
5460+
for x, y in ((as_date, date_sc),
5461+
(date_sc, as_date),
5462+
(as_datetime, datetime_sc),
5463+
(datetime_sc, as_datetime)):
5464+
self.assertTrue(x == y)
5465+
self.assertFalse(x != y)
5466+
self.assertFalse(x < y)
5467+
self.assertFalse(x > y)
5468+
self.assertTrue(x <= y)
5469+
self.assertTrue(x >= y)
5470+
5471+
# Nevertheless, comparison should work if other object is an instance
5472+
# of date or datetime class with overridden comparison operators.
5473+
# So special methods should return NotImplemented, as if
5474+
# date and datetime were independent classes.
5475+
for x, y in (as_date, as_datetime), (as_datetime, as_date):
5476+
self.assertEqual(x.__eq__(y), NotImplemented)
5477+
self.assertEqual(x.__ne__(y), NotImplemented)
5478+
self.assertEqual(x.__lt__(y), NotImplemented)
5479+
self.assertEqual(x.__gt__(y), NotImplemented)
5480+
self.assertEqual(x.__gt__(y), NotImplemented)
5481+
self.assertEqual(x.__ge__(y), NotImplemented)
54745482

54755483
def test_extra_attributes(self):
54765484
with self.assertWarns(DeprecationWarning):
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
Fix :class:`~datetime.date`-:class:`~datetime.datetime` comparison. Now the
2+
special comparison methods like ``__eq__`` and ``__lt__`` return
3+
:data:`NotImplemented` if one of comparands is :class:`!date` and other is
4+
:class:`!datetime` instead of ignoring the time part and the time zone or
5+
forcefully return "not equal" or raise :exc:`TypeError`. It makes comparison
6+
of :class:`!date` and :class:`!datetime` subclasses more symmetric and
7+
allows to change the default behavior by overriding the special comparison
8+
methods in subclasses.

Modules/_datetimemodule.c

+10-26
Original file line numberDiff line numberDiff line change
@@ -1816,16 +1816,6 @@ diff_to_bool(int diff, int op)
18161816
Py_RETURN_RICHCOMPARE(diff, 0, op);
18171817
}
18181818

1819-
/* Raises a "can't compare" TypeError and returns NULL. */
1820-
static PyObject *
1821-
cmperror(PyObject *a, PyObject *b)
1822-
{
1823-
PyErr_Format(PyExc_TypeError,
1824-
"can't compare %s to %s",
1825-
Py_TYPE(a)->tp_name, Py_TYPE(b)->tp_name);
1826-
return NULL;
1827-
}
1828-
18291819
/* ---------------------------------------------------------------------------
18301820
* Class implementations.
18311821
*/
@@ -3448,7 +3438,15 @@ date_isocalendar(PyDateTime_Date *self, PyObject *Py_UNUSED(ignored))
34483438
static PyObject *
34493439
date_richcompare(PyObject *self, PyObject *other, int op)
34503440
{
3451-
if (PyDate_Check(other)) {
3441+
/* Since DateTime is a subclass of Date, if the other object is
3442+
* a DateTime, it would compute an equality testing or an ordering
3443+
* based on the date part alone, and we don't want that.
3444+
* So return NotImplemented here in that case.
3445+
* If a subclass wants to change this, it's up to the subclass to do so.
3446+
* The behavior is the same as if Date and DateTime were independent
3447+
* classes.
3448+
*/
3449+
if (PyDate_Check(other) && !PyDateTime_Check(other)) {
34523450
int diff = memcmp(((PyDateTime_Date *)self)->data,
34533451
((PyDateTime_Date *)other)->data,
34543452
_PyDateTime_DATE_DATASIZE);
@@ -5880,21 +5878,7 @@ datetime_richcompare(PyObject *self, PyObject *other, int op)
58805878
PyObject *offset1, *offset2;
58815879
int diff;
58825880

5883-
if (! PyDateTime_Check(other)) {
5884-
if (PyDate_Check(other)) {
5885-
/* Prevent invocation of date_richcompare. We want to
5886-
return NotImplemented here to give the other object
5887-
a chance. But since DateTime is a subclass of
5888-
Date, if the other object is a Date, it would
5889-
compute an ordering based on the date part alone,
5890-
and we don't want that. So force unequal or
5891-
uncomparable here in that case. */
5892-
if (op == Py_EQ)
5893-
Py_RETURN_FALSE;
5894-
if (op == Py_NE)
5895-
Py_RETURN_TRUE;
5896-
return cmperror(self, other);
5897-
}
5881+
if (!PyDateTime_Check(other)) {
58985882
Py_RETURN_NOTIMPLEMENTED;
58995883
}
59005884

0 commit comments

Comments
 (0)