Skip to content

Commit fe57d29

Browse files
committed
gh-155023: Fix datetime.time comparison and hashing for sub-minute UTC offsets
Normalize the complete UTC offset in the C and pure Python comparison paths, and align the pure Python hash with the C implementation. Add shared regression tests for both implementations.
1 parent a646c99 commit fe57d29

4 files changed

Lines changed: 80 additions & 28 deletions

File tree

Lib/_pydatetime.py

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1560,10 +1560,11 @@ def _cmp(self, other, allow_mixed=False):
15601560
return 2 # arbitrary non-zero value
15611561
else:
15621562
raise TypeError("cannot compare naive and aware times")
1563-
myhhmm = self._hour * 60 + self._minute - myoff//timedelta(minutes=1)
1564-
othhmm = other._hour * 60 + other._minute - otoff//timedelta(minutes=1)
1565-
return _cmp((myhhmm, self._second, self._microsecond),
1566-
(othhmm, other._second, other._microsecond))
1563+
myus = (((self._hour * 60 + self._minute) * 60 + self._second)
1564+
* 1000000 + self._microsecond - myoff._to_microseconds())
1565+
otus = (((other._hour * 60 + other._minute) * 60 + other._second)
1566+
* 1000000 + other._microsecond - otoff._to_microseconds())
1567+
return _cmp(myus, otus)
15671568

15681569
def __hash__(self):
15691570
"""Hash."""
@@ -1573,17 +1574,13 @@ def __hash__(self):
15731574
else:
15741575
t = self
15751576
tzoff = t.utcoffset()
1576-
if not tzoff: # zero or None
1577+
if tzoff is None:
15771578
self._hashcode = hash(t._getstate()[0])
15781579
else:
1579-
h, m = divmod(timedelta(hours=self.hour, minutes=self.minute) - tzoff,
1580-
timedelta(hours=1))
1581-
assert not m % timedelta(minutes=1), "whole minute"
1582-
m //= timedelta(minutes=1)
1583-
if 0 <= h < 24:
1584-
self._hashcode = hash(time(h, m, self.second, self.microsecond))
1585-
else:
1586-
self._hashcode = hash((h, m, self.second, self.microsecond))
1580+
self._hashcode = hash(timedelta(hours=t.hour,
1581+
minutes=t.minute,
1582+
seconds=t.second,
1583+
microseconds=t.microsecond) - tzoff)
15871584
return self._hashcode
15881585

15891586
# Conversion to string

Lib/test/datetimetester.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4676,6 +4676,48 @@ def tzname(self, dt): return self.tz
46764676
Badtzname.tz = '\ud800'
46774677
self.assertEqual(t.strftime("%Z"), '\ud800')
46784678

4679+
def test_subminute_offset_equality(self):
4680+
t1 = self.theclass(12, tzinfo=timezone.utc)
4681+
t2 = self.theclass(
4682+
12, 0, 1, tzinfo=timezone(timedelta(seconds=1))
4683+
)
4684+
self.assertEqual(t1, t2)
4685+
t2 = self.theclass(
4686+
12, 0, 0, 1,
4687+
tzinfo=timezone(timedelta(microseconds=1)),
4688+
)
4689+
self.assertEqual(t1, t2)
4690+
t2 = self.theclass(
4691+
11, 59, 59, 999999,
4692+
tzinfo=timezone(timedelta(microseconds=-1)),
4693+
)
4694+
self.assertEqual(t1, t2)
4695+
4696+
def test_subminute_offset_ordering(self):
4697+
t1 = self.theclass(0, tzinfo=timezone.utc)
4698+
t2 = self.theclass(
4699+
0, tzinfo=timezone(timedelta(microseconds=1))
4700+
)
4701+
self.assertNotEqual(t1, t2)
4702+
self.assertGreater(t1, t2)
4703+
4704+
def test_subminute_offset_hash(self):
4705+
t1 = self.theclass(12, tzinfo=timezone.utc)
4706+
t2 = self.theclass(
4707+
12, 0, 1, tzinfo=timezone(timedelta(seconds=1))
4708+
)
4709+
self.assertEqual(hash(t1), hash(t2))
4710+
t2 = self.theclass(
4711+
12, 0, 0, 1,
4712+
tzinfo=timezone(timedelta(microseconds=1)),
4713+
)
4714+
self.assertEqual(hash(t1), hash(t2))
4715+
t2 = self.theclass(
4716+
11, 59, 59, 999999,
4717+
tzinfo=timezone(timedelta(microseconds=-1)),
4718+
)
4719+
self.assertEqual(hash(t1), hash(t2))
4720+
46794721
def test_hash_edge_cases(self):
46804722
# Offsets that overflow a basic time.
46814723
t1 = self.theclass(0, 1, 2, 3, tzinfo=FixedOffset(1439, ""))
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Fix comparisons and hashing of :class:`datetime.time` objects with sub-minute
2+
UTC offsets.

Modules/_datetimemodule.c

Lines changed: 26 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5057,22 +5057,33 @@ time_richcompare(PyObject *self, PyObject *other, int op)
50575057
}
50585058
/* The hard case: both aware with different UTC offsets */
50595059
else if (offset1 != Py_None && offset2 != Py_None) {
5060-
int offsecs1, offsecs2;
5060+
long long norm_us1, norm_us2;
50615061
assert(offset1 != offset2); /* else last "if" handled it */
5062-
offsecs1 = TIME_GET_HOUR(self) * 3600 +
5063-
TIME_GET_MINUTE(self) * 60 +
5064-
TIME_GET_SECOND(self) -
5065-
GET_TD_DAYS(offset1) * 86400 -
5066-
GET_TD_SECONDS(offset1);
5067-
offsecs2 = TIME_GET_HOUR(other) * 3600 +
5068-
TIME_GET_MINUTE(other) * 60 +
5069-
TIME_GET_SECOND(other) -
5070-
GET_TD_DAYS(offset2) * 86400 -
5071-
GET_TD_SECONDS(offset2);
5072-
diff = offsecs1 - offsecs2;
5073-
if (diff == 0)
5074-
diff = TIME_GET_MICROSECOND(self) -
5075-
TIME_GET_MICROSECOND(other);
5062+
norm_us1 =
5063+
((TIME_GET_HOUR(self) * 3600 +
5064+
TIME_GET_MINUTE(self) * 60 +
5065+
TIME_GET_SECOND(self)) * 1000000LL +
5066+
TIME_GET_MICROSECOND(self)) -
5067+
((GET_TD_DAYS(offset1) * 86400LL +
5068+
GET_TD_SECONDS(offset1)) * 1000000LL +
5069+
GET_TD_MICROSECONDS(offset1));
5070+
norm_us2 =
5071+
((TIME_GET_HOUR(other) * 3600 +
5072+
TIME_GET_MINUTE(other) * 60 +
5073+
TIME_GET_SECOND(other)) * 1000000LL +
5074+
TIME_GET_MICROSECOND(other)) -
5075+
((GET_TD_DAYS(offset2) * 86400LL +
5076+
GET_TD_SECONDS(offset2)) * 1000000LL +
5077+
GET_TD_MICROSECONDS(offset2));
5078+
if (norm_us1 < norm_us2) {
5079+
diff = -1;
5080+
}
5081+
else if (norm_us1 > norm_us2) {
5082+
diff = 1;
5083+
}
5084+
else {
5085+
diff = 0;
5086+
}
50765087
result = diff_to_bool(diff, op);
50775088
}
50785089
else if (op == Py_EQ) {

0 commit comments

Comments
 (0)