From 02bcc3a04640e9606eb389b5997bbab9c9921ef2 Mon Sep 17 00:00:00 2001 From: tonghuaroot Date: Wed, 24 Jun 2026 14:05:00 +0000 Subject: [PATCH 1/2] gh-152079: Fix C datetime.fromisoformat() dropping sub-second UTC offset The C accelerator's tzinfo_from_isoformat_results() collapsed any offset with a zero whole-second part to timezone.utc, discarding the parsed sub-second microseconds. So '+00:00:00.000001' round-tripped through isoformat()/fromisoformat() lost its 1-microsecond offset, while pure Python preserved it. Only short-circuit to UTC when both the whole-second and sub-second parts are zero. --- Lib/test/datetimetester.py | 26 +++++++++++++++++++ ...-06-24-12-00-00.gh-issue-152079.f1tzus.rst | 3 +++ Modules/_datetimemodule.c | 2 +- 3 files changed, 30 insertions(+), 1 deletion(-) create mode 100644 Misc/NEWS.d/next/Library/2026-06-24-12-00-00.gh-issue-152079.f1tzus.rst diff --git a/Lib/test/datetimetester.py b/Lib/test/datetimetester.py index 1cbe78c1ecbfdc6..0270f98bac92cdb 100644 --- a/Lib/test/datetimetester.py +++ b/Lib/test/datetimetester.py @@ -3802,6 +3802,32 @@ def test_fromisoformat_utc(self): self.assertIs(dt.tzinfo, timezone.utc) + def test_fromisoformat_utc_subsecond_offset(self): + # A UTC offset whose whole-second part is zero but with a non-zero + # microsecond part must be preserved, not collapsed to UTC. + for us in (1, -1, 999999, -999999): + with self.subTest(microseconds=us): + tz = timezone(timedelta(microseconds=us)) + dt = self.theclass(2020, 6, 15, 12, 34, 56, tzinfo=tz) + rt = self.theclass.fromisoformat(dt.isoformat()) + self.assertEqual(rt.utcoffset(), timedelta(microseconds=us)) + self.assertEqual(rt, dt) + self.assertIsNot(rt.tzinfo, timezone.utc) + + tz = timezone(timedelta(hours=5, minutes=30, seconds=15, + microseconds=123456)) + dt = self.theclass(2020, 6, 15, 12, 34, 56, tzinfo=tz) + rt = self.theclass.fromisoformat(dt.isoformat()) + self.assertEqual(rt.utcoffset(), tz.utcoffset(None)) + self.assertEqual(rt, dt) + + for tstr in ('2020-06-15T12:34:56+00:00', + '2020-06-15T12:34:56+00:00:00.000000', + '2020-06-15T12:34:56Z'): + with self.subTest(tstr=tstr): + self.assertIs(self.theclass.fromisoformat(tstr).tzinfo, + timezone.utc) + def test_fromisoformat_subclass(self): class DateTimeSubclass(self.theclass): pass diff --git a/Misc/NEWS.d/next/Library/2026-06-24-12-00-00.gh-issue-152079.f1tzus.rst b/Misc/NEWS.d/next/Library/2026-06-24-12-00-00.gh-issue-152079.f1tzus.rst new file mode 100644 index 000000000000000..492d00724f6a46e --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-06-24-12-00-00.gh-issue-152079.f1tzus.rst @@ -0,0 +1,3 @@ +Fix :meth:`datetime.datetime.fromisoformat` in the C implementation dropping +the sub-second part of a UTC offset whose whole-second part is zero, matching +the pure-Python implementation. diff --git a/Modules/_datetimemodule.c b/Modules/_datetimemodule.c index 979aa1beb8657b2..6789cdcd26a0e48 100644 --- a/Modules/_datetimemodule.c +++ b/Modules/_datetimemodule.c @@ -1669,7 +1669,7 @@ tzinfo_from_isoformat_results(int rv, int tzoffset, int tz_useconds) PyObject *tzinfo; if (rv == 1) { // Create a timezone from offset in seconds (0 returns UTC) - if (tzoffset == 0) { + if (tzoffset == 0 && tz_useconds == 0) { return Py_NewRef(CONST_UTC(NO_STATE)); } From fbd5f0f5bb2565c41bb1ee57cf2e336e7b8000f8 Mon Sep 17 00:00:00 2001 From: Stan Ulbrych Date: Wed, 24 Jun 2026 21:33:22 +0100 Subject: [PATCH 2/2] Fix comment too --- Modules/_datetimemodule.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/_datetimemodule.c b/Modules/_datetimemodule.c index 6789cdcd26a0e48..fd8d95d05c933e0 100644 --- a/Modules/_datetimemodule.c +++ b/Modules/_datetimemodule.c @@ -1668,7 +1668,7 @@ tzinfo_from_isoformat_results(int rv, int tzoffset, int tz_useconds) { PyObject *tzinfo; if (rv == 1) { - // Create a timezone from offset in seconds (0 returns UTC) + // Create a timezone from the offset (a zero offset returns UTC) if (tzoffset == 0 && tz_useconds == 0) { return Py_NewRef(CONST_UTC(NO_STATE)); }