diff --git a/Lib/_pydatetime.py b/Lib/_pydatetime.py index c47f4e671b39de..a5576276a119d2 100644 --- a/Lib/_pydatetime.py +++ b/Lib/_pydatetime.py @@ -1560,10 +1560,11 @@ def _cmp(self, other, allow_mixed=False): return 2 # arbitrary non-zero value else: raise TypeError("cannot compare naive and aware times") - myhhmm = self._hour * 60 + self._minute - myoff//timedelta(minutes=1) - othhmm = other._hour * 60 + other._minute - otoff//timedelta(minutes=1) - return _cmp((myhhmm, self._second, self._microsecond), - (othhmm, other._second, other._microsecond)) + myus = (((self._hour * 60 + self._minute) * 60 + self._second) + * 1000000 + self._microsecond - myoff._to_microseconds()) + otus = (((other._hour * 60 + other._minute) * 60 + other._second) + * 1000000 + other._microsecond - otoff._to_microseconds()) + return _cmp(myus, otus) def __hash__(self): """Hash.""" @@ -1573,17 +1574,13 @@ def __hash__(self): else: t = self tzoff = t.utcoffset() - if not tzoff: # zero or None + if tzoff is None: self._hashcode = hash(t._getstate()[0]) else: - h, m = divmod(timedelta(hours=self.hour, minutes=self.minute) - tzoff, - timedelta(hours=1)) - assert not m % timedelta(minutes=1), "whole minute" - m //= timedelta(minutes=1) - if 0 <= h < 24: - self._hashcode = hash(time(h, m, self.second, self.microsecond)) - else: - self._hashcode = hash((h, m, self.second, self.microsecond)) + self._hashcode = hash(timedelta(hours=t.hour, + minutes=t.minute, + seconds=t.second, + microseconds=t.microsecond) - tzoff) return self._hashcode # Conversion to string diff --git a/Lib/test/datetimetester.py b/Lib/test/datetimetester.py index c11e9c068bed3b..2ba0db0527513e 100644 --- a/Lib/test/datetimetester.py +++ b/Lib/test/datetimetester.py @@ -4676,6 +4676,48 @@ def tzname(self, dt): return self.tz Badtzname.tz = '\ud800' self.assertEqual(t.strftime("%Z"), '\ud800') + def test_subminute_offset_equality(self): + t1 = self.theclass(12, tzinfo=timezone.utc) + t2 = self.theclass( + 12, 0, 1, tzinfo=timezone(timedelta(seconds=1)) + ) + self.assertEqual(t1, t2) + t2 = self.theclass( + 12, 0, 0, 1, + tzinfo=timezone(timedelta(microseconds=1)), + ) + self.assertEqual(t1, t2) + t2 = self.theclass( + 11, 59, 59, 999999, + tzinfo=timezone(timedelta(microseconds=-1)), + ) + self.assertEqual(t1, t2) + + def test_subminute_offset_ordering(self): + t1 = self.theclass(0, tzinfo=timezone.utc) + t2 = self.theclass( + 0, tzinfo=timezone(timedelta(microseconds=1)) + ) + self.assertNotEqual(t1, t2) + self.assertGreater(t1, t2) + + def test_subminute_offset_hash(self): + t1 = self.theclass(12, tzinfo=timezone.utc) + t2 = self.theclass( + 12, 0, 1, tzinfo=timezone(timedelta(seconds=1)) + ) + self.assertEqual(hash(t1), hash(t2)) + t2 = self.theclass( + 12, 0, 0, 1, + tzinfo=timezone(timedelta(microseconds=1)), + ) + self.assertEqual(hash(t1), hash(t2)) + t2 = self.theclass( + 11, 59, 59, 999999, + tzinfo=timezone(timedelta(microseconds=-1)), + ) + self.assertEqual(hash(t1), hash(t2)) + def test_hash_edge_cases(self): # Offsets that overflow a basic time. t1 = self.theclass(0, 1, 2, 3, tzinfo=FixedOffset(1439, "")) diff --git a/Misc/NEWS.d/next/Library/2026-08-01-20-21-37.gh-issue-155023.q7M3vP.rst b/Misc/NEWS.d/next/Library/2026-08-01-20-21-37.gh-issue-155023.q7M3vP.rst new file mode 100644 index 00000000000000..45df5211bb637f --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-08-01-20-21-37.gh-issue-155023.q7M3vP.rst @@ -0,0 +1,2 @@ +Fix comparisons and hashing of :class:`datetime.time` objects with sub-minute +UTC offsets. diff --git a/Modules/_datetimemodule.c b/Modules/_datetimemodule.c index cd02b298b406e6..d1ab5ec8b30c7f 100644 --- a/Modules/_datetimemodule.c +++ b/Modules/_datetimemodule.c @@ -5057,22 +5057,33 @@ time_richcompare(PyObject *self, PyObject *other, int op) } /* The hard case: both aware with different UTC offsets */ else if (offset1 != Py_None && offset2 != Py_None) { - int offsecs1, offsecs2; + long long norm_us1, norm_us2; assert(offset1 != offset2); /* else last "if" handled it */ - offsecs1 = TIME_GET_HOUR(self) * 3600 + - TIME_GET_MINUTE(self) * 60 + - TIME_GET_SECOND(self) - - GET_TD_DAYS(offset1) * 86400 - - GET_TD_SECONDS(offset1); - offsecs2 = TIME_GET_HOUR(other) * 3600 + - TIME_GET_MINUTE(other) * 60 + - TIME_GET_SECOND(other) - - GET_TD_DAYS(offset2) * 86400 - - GET_TD_SECONDS(offset2); - diff = offsecs1 - offsecs2; - if (diff == 0) - diff = TIME_GET_MICROSECOND(self) - - TIME_GET_MICROSECOND(other); + norm_us1 = + ((TIME_GET_HOUR(self) * 3600 + + TIME_GET_MINUTE(self) * 60 + + TIME_GET_SECOND(self)) * 1000000LL + + TIME_GET_MICROSECOND(self)) - + ((GET_TD_DAYS(offset1) * 86400LL + + GET_TD_SECONDS(offset1)) * 1000000LL + + GET_TD_MICROSECONDS(offset1)); + norm_us2 = + ((TIME_GET_HOUR(other) * 3600 + + TIME_GET_MINUTE(other) * 60 + + TIME_GET_SECOND(other)) * 1000000LL + + TIME_GET_MICROSECOND(other)) - + ((GET_TD_DAYS(offset2) * 86400LL + + GET_TD_SECONDS(offset2)) * 1000000LL + + GET_TD_MICROSECONDS(offset2)); + if (norm_us1 < norm_us2) { + diff = -1; + } + else if (norm_us1 > norm_us2) { + diff = 1; + } + else { + diff = 0; + } result = diff_to_bool(diff, op); } else if (op == Py_EQ) {