Skip to content

Commit d50ca1a

Browse files
committed
datetime: Improve comparing datetime objects (#146236)
Comparing datetime objects with different `fold=` attributes did not preserve the documented "precedes/follows" semantics of comparisons.
1 parent f6b5eed commit d50ca1a

File tree

6 files changed

+68
-6
lines changed

6 files changed

+68
-6
lines changed

Doc/library/datetime.rst

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1329,9 +1329,11 @@ Supported operations:
13291329

13301330
Naive and aware :class:`.datetime` objects are never equal.
13311331

1332-
If both comparands are aware, and have the same :attr:`!tzinfo` attribute,
1333-
the :attr:`!tzinfo` and :attr:`~.datetime.fold` attributes are ignored and
1334-
the base datetimes are compared.
1332+
If both comparands are aware, and have the same :attr:`!tzinfo` and
1333+
:attr:`~.datetime.fold` attributes, the base datetimes are compared.
1334+
If both comparands are aware, and have the same :attr:`!tzinfo` but
1335+
differing :attr:`~.datetime.fold` attributes, the objects are converted to
1336+
timestamps, and the timestamps are compared.
13351337
If both comparands are aware and have different :attr:`~.datetime.tzinfo`
13361338
attributes, the comparison acts as comparands were first converted to UTC
13371339
datetimes except that the implementation never overflows.
@@ -1345,9 +1347,11 @@ Supported operations:
13451347
Order comparison between naive and aware :class:`.datetime` objects
13461348
raises :exc:`TypeError`.
13471349

1348-
If both comparands are aware, and have the same :attr:`!tzinfo` attribute,
1349-
the :attr:`!tzinfo` and :attr:`~.datetime.fold` attributes are ignored and
1350-
the base datetimes are compared.
1350+
If both comparands are aware, and have the same :attr:`!tzinfo` and
1351+
:attr:`~.datetime.fold` attributes, the base datetimes are compared.
1352+
If both comparands are aware, and have the same :attr:`!tzinfo` but
1353+
differing :attr:`~.datetime.fold` attributes, the objects are converted to
1354+
timestamps, and the timestamps are compared.
13511355
If both comparands are aware and have different :attr:`~.datetime.tzinfo`
13521356
attributes, the comparison acts as comparands were first converted to UTC
13531357
datetimes except that the implementation never overflows.
@@ -1364,6 +1368,11 @@ Supported operations:
13641368
The default behavior can be changed by overriding the special comparison
13651369
methods in subclasses.
13661370

1371+
.. versionchanged:: 3.15
1372+
Comparison between :class:`.datetime` objects with matching :attr:`!tzinfo`
1373+
and differing :attr:`~.datetime.fold` attributes uses timestamps for
1374+
comparison, so that ordering is preserved even in the case of a repeated
1375+
interval.
13671376

13681377
Instance methods:
13691378

Lib/_pydatetime.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2296,6 +2296,11 @@ def _cmp(self, other, allow_mixed=False):
22962296
myoff = otoff = None
22972297

22982298
if mytz is ottz:
2299+
# If the objects' fold properties differ, the `fold=1` timestamp may
2300+
# follow the `fold=0` timestamp even though fielf-by-field comparison
2301+
# would otherwise conclude that it occurs before. (#146236)
2302+
if self.fold != other.fold:
2303+
return _cmp(self.timestamp(), other.timestamp())
22992304
base_compare = True
23002305
else:
23012306
myoff = self.utcoffset()

Lib/test/datetimetester.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import textwrap
1616
import unittest
1717
import warnings
18+
import zoneinfo
1819

1920
from array import array
2021

@@ -5985,6 +5986,20 @@ def test_tricky(self):
59855986
self.assertEqual(astz.replace(tzinfo=None), expected)
59865987
asutcbase += HOUR
59875988

5989+
@unittest.skipUnless("America/Los_Angeles" in zoneinfo.available_timezones(),
5990+
"Can't find timezone database")
5991+
def test_ordering_dst(self):
5992+
for utc in utc_real, utc_fake:
5993+
for tz in zoneinfo.ZoneInfo("America/Los_Angeles"), zoneinfo.ZoneInfo("America/New_York"):
5994+
print(f"{tz!r} {self.dstoff!r} {utc is utc_fake} {id(datetime)}")
5995+
tm = tm0 = self.dstoff.replace(tzinfo=tz, hour=0)
5996+
print(f"{tm0!r}")
5997+
for h in range(4):
5998+
for m in 1, 30, 59:
5999+
tm1 = (tm.astimezone(utc) + timedelta(hours=h, minutes=m)).astimezone(tz)
6000+
print(f"{tm1!r}")
6001+
self.assertLess(tm0, tm1)
6002+
tm0 = tm1
59886003

59896004
def test_bogus_dst(self):
59906005
class ok(tzinfo):

Lib/test/test_zoneinfo/test_zoneinfo.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,6 +389,21 @@ def test_folds_from_utc(self):
389389
dt_after = dt_after_utc.astimezone(zi)
390390
self.assertEqual(dt_after.fold, 1, (dt_after, dt_utc))
391391

392+
393+
def test_ordering_dst(self):
394+
UTC = self.klass("UTC")
395+
dstoff = datetime(2002, 10, 27, 1)
396+
tz = self.klass("America/Los_Angeles")
397+
print(f"{tz!r} {dstoff!r} {id(datetime)}")
398+
tm = tm0 = dstoff.replace(tzinfo=tz, hour=0)
399+
print(f"{tm0!r}")
400+
for h in range(4):
401+
for m in 1, 30, 59:
402+
tm1 = (tm.astimezone(UTC) + timedelta(hours=h, minutes=m)).astimezone(tz)
403+
print(f"{tm1!r}")
404+
self.assertLess(tm0, tm1)
405+
tm0 = tm1
406+
392407
def test_time_variable_offset(self):
393408
# self.zones() only ever returns variable-offset zones
394409
for key in self.zones():
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Comparison of datetime values with ``fold=1`` now compares the objects'
2+
timestamps so that correct ordering of timestamps is maintained at the end
3+
of DST.

Modules/_datetimemodule.c

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,9 @@ typedef struct {
114114
#define CONST_EPOCH(st) st->epoch
115115
#define CONST_UTC(st) ((PyObject *)&utc_timezone)
116116

117+
static PyObject *
118+
datetime_timestamp(PyObject *op, PyObject *Py_UNUSED(dummy));
119+
117120
static datetime_state *
118121
get_module_state(PyObject *module)
119122
{
@@ -6565,6 +6568,18 @@ datetime_richcompare(PyObject *self, PyObject *other, int op)
65656568
}
65666569

65676570
if (GET_DT_TZINFO(self) == GET_DT_TZINFO(other)) {
6571+
// If the objects' fold properties differ, the `fold=1` timestamp may
6572+
// follow the `fold=0` timestamp even though fielf-by-field comparison
6573+
// would otherwise conclude that it occurs before. (#146236)
6574+
if (DATE_GET_FOLD(self) != DATE_GET_FOLD(other)) {
6575+
PyObject *ts_self = datetime_timestamp(self, NULL);
6576+
PyObject *ts_other = datetime_timestamp(other, NULL);
6577+
PyObject *result = PyObject_RichCompare(ts_self, ts_other, op);
6578+
Py_DECREF(ts_self);
6579+
Py_DECREF(ts_other);
6580+
return result;
6581+
}
6582+
65686583
diff = memcmp(((PyDateTime_DateTime *)self)->data,
65696584
((PyDateTime_DateTime *)other)->data,
65706585
_PyDateTime_DATETIME_DATASIZE);

0 commit comments

Comments
 (0)