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.
This commit is contained in:
Serhiy Storchaka 2024-02-11 13:06:43 +02:00 committed by GitHub
parent d9d6909697
commit b104360788
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 91 additions and 84 deletions

View file

@ -619,11 +619,27 @@ Notes:
(4) (4)
:class:`date` objects are equal if they represent the same date. :class:`date` objects are equal if they represent the same date.
:class:`!date` objects that are not also :class:`.datetime` instances
are never equal to :class:`!datetime` objects, even if they represent
the same date.
(5) (5)
*date1* is considered less than *date2* when *date1* precedes *date2* in time. *date1* is considered less than *date2* when *date1* precedes *date2* in time.
In other words, ``date1 < date2`` if and only if ``date1.toordinal() < In other words, ``date1 < date2`` if and only if ``date1.toordinal() <
date2.toordinal()``. date2.toordinal()``.
Order comparison between a :class:`!date` object that is not also a
:class:`.datetime` instance and a :class:`!datetime` object raises
:exc:`TypeError`.
.. versionchanged:: 3.13
Comparison between :class:`.datetime` object and an instance of
the :class:`date` subclass that is not a :class:`!datetime` subclass
no longer coverts the latter to :class:`!date`, ignoring the time part
and the time zone.
The default behavior can be changed by overriding the special comparison
methods in subclasses.
In Boolean contexts, all :class:`date` objects are considered to be true. In Boolean contexts, all :class:`date` objects are considered to be true.
Instance methods: Instance methods:
@ -1192,9 +1208,6 @@ Supported operations:
and time, taking into account the time zone. and time, taking into account the time zone.
Naive and aware :class:`!datetime` objects are never equal. Naive and aware :class:`!datetime` objects are never equal.
:class:`!datetime` objects are never equal to :class:`date` objects
that are not also :class:`!datetime` instances, even if they represent
the same date.
If both comparands are aware and have different :attr:`~.datetime.tzinfo` If both comparands are aware and have different :attr:`~.datetime.tzinfo`
attributes, the comparison acts as comparands were first converted to UTC attributes, the comparison acts as comparands were first converted to UTC
@ -1206,9 +1219,8 @@ Supported operations:
*datetime1* is considered less than *datetime2* when *datetime1* precedes *datetime1* is considered less than *datetime2* when *datetime1* precedes
*datetime2* in time, taking into account the time zone. *datetime2* in time, taking into account the time zone.
Order comparison between naive and aware :class:`.datetime` objects, Order comparison between naive and aware :class:`.datetime` objects
as well as a :class:`!datetime` object and a :class:`!date` object raises :exc:`TypeError`.
that is not also a :class:`!datetime` instance, raises :exc:`TypeError`.
If both comparands are aware and have different :attr:`~.datetime.tzinfo` If both comparands are aware and have different :attr:`~.datetime.tzinfo`
attributes, the comparison acts as comparands were first converted to UTC attributes, the comparison acts as comparands were first converted to UTC
@ -1218,6 +1230,14 @@ Supported operations:
Equality comparisons between aware and naive :class:`.datetime` Equality comparisons between aware and naive :class:`.datetime`
instances don't raise :exc:`TypeError`. instances don't raise :exc:`TypeError`.
.. versionchanged:: 3.13
Comparison between :class:`.datetime` object and an instance of
the :class:`date` subclass that is not a :class:`!datetime` subclass
no longer coverts the latter to :class:`!date`, ignoring the time part
and the time zone.
The default behavior can be changed by overriding the special comparison
methods in subclasses.
Instance methods: Instance methods:
.. method:: datetime.date() .. method:: datetime.date()

View file

@ -556,10 +556,6 @@ def _check_tzinfo_arg(tz):
if tz is not None and not isinstance(tz, tzinfo): if tz is not None and not isinstance(tz, tzinfo):
raise TypeError("tzinfo argument must be None or of a tzinfo subclass") raise TypeError("tzinfo argument must be None or of a tzinfo subclass")
def _cmperror(x, y):
raise TypeError("can't compare '%s' to '%s'" % (
type(x).__name__, type(y).__name__))
def _divide_and_round(a, b): def _divide_and_round(a, b):
"""divide a by b and round result to the nearest integer """divide a by b and round result to the nearest integer
@ -1113,32 +1109,33 @@ class date:
# Comparisons of date objects with other. # Comparisons of date objects with other.
def __eq__(self, other): def __eq__(self, other):
if isinstance(other, date): if isinstance(other, date) and not isinstance(other, datetime):
return self._cmp(other) == 0 return self._cmp(other) == 0
return NotImplemented return NotImplemented
def __le__(self, other): def __le__(self, other):
if isinstance(other, date): if isinstance(other, date) and not isinstance(other, datetime):
return self._cmp(other) <= 0 return self._cmp(other) <= 0
return NotImplemented return NotImplemented
def __lt__(self, other): def __lt__(self, other):
if isinstance(other, date): if isinstance(other, date) and not isinstance(other, datetime):
return self._cmp(other) < 0 return self._cmp(other) < 0
return NotImplemented return NotImplemented
def __ge__(self, other): def __ge__(self, other):
if isinstance(other, date): if isinstance(other, date) and not isinstance(other, datetime):
return self._cmp(other) >= 0 return self._cmp(other) >= 0
return NotImplemented return NotImplemented
def __gt__(self, other): def __gt__(self, other):
if isinstance(other, date): if isinstance(other, date) and not isinstance(other, datetime):
return self._cmp(other) > 0 return self._cmp(other) > 0
return NotImplemented return NotImplemented
def _cmp(self, other): def _cmp(self, other):
assert isinstance(other, date) assert isinstance(other, date)
assert not isinstance(other, datetime)
y, m, d = self._year, self._month, self._day y, m, d = self._year, self._month, self._day
y2, m2, d2 = other._year, other._month, other._day y2, m2, d2 = other._year, other._month, other._day
return _cmp((y, m, d), (y2, m2, d2)) return _cmp((y, m, d), (y2, m2, d2))
@ -2137,42 +2134,32 @@ class datetime(date):
def __eq__(self, other): def __eq__(self, other):
if isinstance(other, datetime): if isinstance(other, datetime):
return self._cmp(other, allow_mixed=True) == 0 return self._cmp(other, allow_mixed=True) == 0
elif not isinstance(other, date):
return NotImplemented
else: else:
return False return NotImplemented
def __le__(self, other): def __le__(self, other):
if isinstance(other, datetime): if isinstance(other, datetime):
return self._cmp(other) <= 0 return self._cmp(other) <= 0
elif not isinstance(other, date):
return NotImplemented
else: else:
_cmperror(self, other) return NotImplemented
def __lt__(self, other): def __lt__(self, other):
if isinstance(other, datetime): if isinstance(other, datetime):
return self._cmp(other) < 0 return self._cmp(other) < 0
elif not isinstance(other, date):
return NotImplemented
else: else:
_cmperror(self, other) return NotImplemented
def __ge__(self, other): def __ge__(self, other):
if isinstance(other, datetime): if isinstance(other, datetime):
return self._cmp(other) >= 0 return self._cmp(other) >= 0
elif not isinstance(other, date):
return NotImplemented
else: else:
_cmperror(self, other) return NotImplemented
def __gt__(self, other): def __gt__(self, other):
if isinstance(other, datetime): if isinstance(other, datetime):
return self._cmp(other) > 0 return self._cmp(other) > 0
elif not isinstance(other, date):
return NotImplemented
else: else:
_cmperror(self, other) return NotImplemented
def _cmp(self, other, allow_mixed=False): def _cmp(self, other, allow_mixed=False):
assert isinstance(other, datetime) assert isinstance(other, datetime)

View file

@ -5435,42 +5435,50 @@ class TestTimezoneConversions(unittest.TestCase):
class Oddballs(unittest.TestCase): class Oddballs(unittest.TestCase):
def test_bug_1028306(self): def test_date_datetime_comparison(self):
# bpo-1028306, bpo-5516 (gh-49766)
# Trying to compare a date to a datetime should act like a mixed- # Trying to compare a date to a datetime should act like a mixed-
# type comparison, despite that datetime is a subclass of date. # type comparison, despite that datetime is a subclass of date.
as_date = date.today() as_date = date.today()
as_datetime = datetime.combine(as_date, time()) as_datetime = datetime.combine(as_date, time())
self.assertTrue(as_date != as_datetime) date_sc = SubclassDate(as_date.year, as_date.month, as_date.day)
self.assertTrue(as_datetime != as_date) datetime_sc = SubclassDatetime(as_date.year, as_date.month,
self.assertFalse(as_date == as_datetime) as_date.day, 0, 0, 0)
self.assertFalse(as_datetime == as_date) for d in (as_date, date_sc):
self.assertRaises(TypeError, lambda: as_date < as_datetime) for dt in (as_datetime, datetime_sc):
self.assertRaises(TypeError, lambda: as_datetime < as_date) for x, y in (d, dt), (dt, d):
self.assertRaises(TypeError, lambda: as_date <= as_datetime) self.assertTrue(x != y)
self.assertRaises(TypeError, lambda: as_datetime <= as_date) self.assertFalse(x == y)
self.assertRaises(TypeError, lambda: as_date > as_datetime) self.assertRaises(TypeError, lambda: x < y)
self.assertRaises(TypeError, lambda: as_datetime > as_date) self.assertRaises(TypeError, lambda: x <= y)
self.assertRaises(TypeError, lambda: as_date >= as_datetime) self.assertRaises(TypeError, lambda: x > y)
self.assertRaises(TypeError, lambda: as_datetime >= as_date) self.assertRaises(TypeError, lambda: x >= y)
# Nevertheless, comparison should work with the base-class (date)
# projection if use of a date method is forced.
self.assertEqual(as_date.__eq__(as_datetime), True)
different_day = (as_date.day + 1) % 20 + 1
as_different = as_datetime.replace(day= different_day)
self.assertEqual(as_date.__eq__(as_different), False)
# And date should compare with other subclasses of date. If a # And date should compare with other subclasses of date. If a
# subclass wants to stop this, it's up to the subclass to do so. # subclass wants to stop this, it's up to the subclass to do so.
date_sc = SubclassDate(as_date.year, as_date.month, as_date.day)
self.assertEqual(as_date, date_sc)
self.assertEqual(date_sc, as_date)
# Ditto for datetimes. # Ditto for datetimes.
datetime_sc = SubclassDatetime(as_datetime.year, as_datetime.month, for x, y in ((as_date, date_sc),
as_date.day, 0, 0, 0) (date_sc, as_date),
self.assertEqual(as_datetime, datetime_sc) (as_datetime, datetime_sc),
self.assertEqual(datetime_sc, as_datetime) (datetime_sc, as_datetime)):
self.assertTrue(x == y)
self.assertFalse(x != y)
self.assertFalse(x < y)
self.assertFalse(x > y)
self.assertTrue(x <= y)
self.assertTrue(x >= y)
# Nevertheless, comparison should work if other object is an instance
# of date or datetime class with overridden comparison operators.
# So special methods should return NotImplemented, as if
# date and datetime were independent classes.
for x, y in (as_date, as_datetime), (as_datetime, as_date):
self.assertEqual(x.__eq__(y), NotImplemented)
self.assertEqual(x.__ne__(y), NotImplemented)
self.assertEqual(x.__lt__(y), NotImplemented)
self.assertEqual(x.__gt__(y), NotImplemented)
self.assertEqual(x.__gt__(y), NotImplemented)
self.assertEqual(x.__ge__(y), NotImplemented)
def test_extra_attributes(self): def test_extra_attributes(self):
with self.assertWarns(DeprecationWarning): with self.assertWarns(DeprecationWarning):

View file

@ -0,0 +1,8 @@
Fix :class:`~datetime.date`-:class:`~datetime.datetime` comparison. Now the
special comparison methods like ``__eq__`` and ``__lt__`` return
:data:`NotImplemented` if one of comparands is :class:`!date` and other is
:class:`!datetime` instead of ignoring the time part and the time zone or
forcefully return "not equal" or raise :exc:`TypeError`. It makes comparison
of :class:`!date` and :class:`!datetime` subclasses more symmetric and
allows to change the default behavior by overriding the special comparison
methods in subclasses.

View file

@ -1816,16 +1816,6 @@ diff_to_bool(int diff, int op)
Py_RETURN_RICHCOMPARE(diff, 0, op); Py_RETURN_RICHCOMPARE(diff, 0, op);
} }
/* Raises a "can't compare" TypeError and returns NULL. */
static PyObject *
cmperror(PyObject *a, PyObject *b)
{
PyErr_Format(PyExc_TypeError,
"can't compare %s to %s",
Py_TYPE(a)->tp_name, Py_TYPE(b)->tp_name);
return NULL;
}
/* --------------------------------------------------------------------------- /* ---------------------------------------------------------------------------
* Class implementations. * Class implementations.
*/ */
@ -3448,7 +3438,15 @@ date_isocalendar(PyDateTime_Date *self, PyObject *Py_UNUSED(ignored))
static PyObject * static PyObject *
date_richcompare(PyObject *self, PyObject *other, int op) date_richcompare(PyObject *self, PyObject *other, int op)
{ {
if (PyDate_Check(other)) { /* Since DateTime is a subclass of Date, if the other object is
* a DateTime, it would compute an equality testing or an ordering
* based on the date part alone, and we don't want that.
* So return NotImplemented here in that case.
* If a subclass wants to change this, it's up to the subclass to do so.
* The behavior is the same as if Date and DateTime were independent
* classes.
*/
if (PyDate_Check(other) && !PyDateTime_Check(other)) {
int diff = memcmp(((PyDateTime_Date *)self)->data, int diff = memcmp(((PyDateTime_Date *)self)->data,
((PyDateTime_Date *)other)->data, ((PyDateTime_Date *)other)->data,
_PyDateTime_DATE_DATASIZE); _PyDateTime_DATE_DATASIZE);
@ -5880,21 +5878,7 @@ datetime_richcompare(PyObject *self, PyObject *other, int op)
PyObject *offset1, *offset2; PyObject *offset1, *offset2;
int diff; int diff;
if (! PyDateTime_Check(other)) { if (!PyDateTime_Check(other)) {
if (PyDate_Check(other)) {
/* Prevent invocation of date_richcompare. We want to
return NotImplemented here to give the other object
a chance. But since DateTime is a subclass of
Date, if the other object is a Date, it would
compute an ordering based on the date part alone,
and we don't want that. So force unequal or
uncomparable here in that case. */
if (op == Py_EQ)
Py_RETURN_FALSE;
if (op == Py_NE)
Py_RETURN_TRUE;
return cmperror(self, other);
}
Py_RETURN_NOTIMPLEMENTED; Py_RETURN_NOTIMPLEMENTED;
} }