https://github.com/python/cpython/commit/5d248020ca7229f5b3dddb18b78580b9e948968c
commit: 5d248020ca7229f5b3dddb18b78580b9e948968c
branch: main
author: yangbaechu <[email protected]>
committer: StanFromIreland <[email protected]>
date: 2026-09-07T20:19:20+01:00
summary:

gh-99772: Fix datetime.time comparison and hashing for sub-minute UTC offsets 
(#155024)

Co-authored-by: Stan Ulbrych <[email protected]>

files:
A Misc/NEWS.d/next/Library/2026-08-01-20-21-37.gh-issue-99772.q7M3vP.rst
M Lib/_pydatetime.py
M Lib/test/datetimetester.py
M Modules/_datetimemodule.c

diff --git a/Lib/_pydatetime.py b/Lib/_pydatetime.py
index 6f53f45d524c99..50126d039e830a 100644
--- a/Lib/_pydatetime.py
+++ b/Lib/_pydatetime.py
@@ -1570,10 +1570,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."""
@@ -1583,17 +1584,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 dae71a6b6679a0..716c662ad453f4 100644
--- a/Lib/test/datetimetester.py
+++ b/Lib/test/datetimetester.py
@@ -4693,6 +4693,33 @@ 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.assertGreater(t1, t2)
+
+        t1 = self.theclass(13, 59, 59, 900000, 
tzinfo=timezone(timedelta(hours=2)))
+        t2 = self.theclass(14, tzinfo=timezone(timedelta(hours=2, 
microseconds=900000)))
+        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-99772.q7M3vP.rst 
b/Misc/NEWS.d/next/Library/2026-08-01-20-21-37.gh-issue-99772.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-99772.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 9a771cd0ad5fbf..bd76b3bd81cce4 100644
--- a/Modules/_datetimemodule.c
+++ b/Modules/_datetimemodule.c
@@ -5051,22 +5051,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) {

_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: [email protected]

Reply via email to