From 455c1b08654e12e1999833948ad4f9369dd8515e Mon Sep 17 00:00:00 2001 From: Can <116688414+canblmz1@users.noreply.github.com> Date: Wed, 28 Oct 2026 10:34:13 +0300 Subject: [PATCH] gh-160806: Fix precision loss in pure-Python timedelta with large float arguments _pydatetime.timedelta() added its float arguments together before splitting off the fractional part, so large values lost their sub-second part. Sum the microseconds like the C implementation does: the integer part of each argument exactly, and only the leftover fractional microseconds as a float, rounded half to even at the end. Co-Authored-By: Claude Opus 5.5 --- Lib/_pydatetime.py | 111 +++++------------- Lib/test/datetimetester.py | 7 ++ ...-10-28-07-34-05.gh-issue-160806.ZZRkhi.rst | 3 + 3 files changed, 39 insertions(+), 82 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-10-28-07-34-05.gh-issue-160806.ZZRkhi.rst diff --git a/Lib/_pydatetime.py b/Lib/_pydatetime.py index 076183139b28ae..d2adfd452185c5 100644 --- a/Lib/_pydatetime.py +++ b/Lib/_pydatetime.py @@ -663,14 +663,6 @@ class timedelta: def __new__(cls, days=0, seconds=0, microseconds=0, milliseconds=0, minutes=0, hours=0, weeks=0): - # Doing this efficiently and accurately in C is going to be difficult - # and error-prone, due to ubiquitous overflow possibilities, and that - # C double doesn't have enough bits of precision to represent - # microseconds over 10K years faithfully. The code here tries to make - # explicit where go-fast assumptions can be relied on, in order to - # guide the C implementation; it's way more convoluted than speed- - # ignoring auto-overflow-to-long idiomatic Python could be. - for name, value in ( ("days", days), ("seconds", seconds), @@ -685,80 +677,35 @@ def __new__(cls, days=0, seconds=0, microseconds=0, f"unsupported type for timedelta {name} component: {type(value).__name__}" ) - # Final values, all integer. - # s and us fit in 32-bit signed ints; d isn't bounded. - d = s = us = 0 - - # Normalize everything to days, seconds, microseconds. - days += weeks*7 - seconds += minutes*60 + hours*3600 - microseconds += milliseconds*1000 - - # Get rid of all fractions, and normalize s and us. - # Take a deep breath . - if isinstance(days, float): - dayfrac, days = _math.modf(days) - daysecondsfrac, daysecondswhole = _math.modf(dayfrac * (24.*3600.)) - assert daysecondswhole == int(daysecondswhole) # can't overflow - s = int(daysecondswhole) - assert days == int(days) - d = int(days) - else: - daysecondsfrac = 0.0 - d = days - assert isinstance(daysecondsfrac, float) - assert abs(daysecondsfrac) <= 1.0 - assert isinstance(d, int) - assert abs(s) <= 24 * 3600 - # days isn't referenced again before redefinition - - if isinstance(seconds, float): - secondsfrac, seconds = _math.modf(seconds) - assert seconds == int(seconds) - seconds = int(seconds) - secondsfrac += daysecondsfrac - assert abs(secondsfrac) <= 2.0 - else: - secondsfrac = daysecondsfrac - # daysecondsfrac isn't referenced again - assert isinstance(secondsfrac, float) - assert abs(secondsfrac) <= 2.0 - - assert isinstance(seconds, int) - days, seconds = divmod(seconds, 24*3600) - d += days - s += int(seconds) # can't overflow - assert isinstance(s, int) - assert abs(s) <= 2 * 24 * 3600 - # seconds isn't referenced again before redefinition - - usdouble = secondsfrac * 1e6 - assert abs(usdouble) < 2.1e6 # exact value not critical - # secondsfrac isn't referenced again - - if isinstance(microseconds, float): - microseconds = round(microseconds + usdouble) - seconds, microseconds = divmod(microseconds, 1000000) - days, seconds = divmod(seconds, 24*3600) - d += days - s += seconds - else: - microseconds = int(microseconds) - seconds, microseconds = divmod(microseconds, 1000000) - days, seconds = divmod(seconds, 24*3600) - d += days - s += seconds - microseconds = round(microseconds + usdouble) - assert isinstance(s, int) - assert isinstance(microseconds, int) - assert abs(s) <= 3 * 24 * 3600 - assert abs(microseconds) < 3.1e6 - - # Just a little bit of carrying possible for microseconds and seconds. - seconds, us = divmod(microseconds, 1000000) - s += seconds - days, s = divmod(s, 24*3600) - d += days + # Sum the exact number of microseconds, like the C implementation: + # the integer part of each float is converted exactly, and only the + # fractional microseconds left over are added up as a float and + # rounded (half to even) at the end. + us = 0 + leftover = 0.0 + for value, factor in ( + (microseconds, 1), + (milliseconds, 1000), + (seconds, 1000000), + (minutes, 60*1000000), + (hours, 3600*1000000), + (days, 24*3600*1000000), + (weeks, 7*24*3600*1000000) + ): + if isinstance(value, float): + frac, whole = _math.modf(value) + us += int(whole) * factor + frac, whole = _math.modf(frac * factor) + us += int(whole) + leftover += frac + else: + us += int(value) * factor + if leftover: + n, m = leftover.as_integer_ratio() + us = _divide_and_round(us * m + n, m) + + s, us = divmod(us, 1000000) + d, s = divmod(s, 24*3600) assert isinstance(d, int) assert isinstance(s, int) and 0 <= s < 24*3600 diff --git a/Lib/test/datetimetester.py b/Lib/test/datetimetester.py index 53285d918c17ca..92607e381db20c 100644 --- a/Lib/test/datetimetester.py +++ b/Lib/test/datetimetester.py @@ -554,6 +554,13 @@ def test_constructor(self): eq(td(seconds=0.001), td(milliseconds=1)) eq(td(milliseconds=0.001), td(microseconds=1)) + # Large float args keep their sub-second part + eq(td(weeks=12345678.9), td(86419752, 25920, 225)) + eq(td(hours=123456789.1), td(5144032, 75959, 999979)) + eq(td(minutes=1234567890.1), td(857338, 70205, 999994)) + eq(td(days=1.1, weeks=1000000.1), td(7000001, 69119, 999986)) + eq(td(seconds=0.5, microseconds=2.0**60), td(13343998, 77407, 346976)) + # Check type of args to constructor ra(TypeError, lambda: td(weeks='1')) ra(TypeError, lambda: td(days='1')) diff --git a/Misc/NEWS.d/next/Library/2026-10-28-07-34-05.gh-issue-160806.ZZRkhi.rst b/Misc/NEWS.d/next/Library/2026-10-28-07-34-05.gh-issue-160806.ZZRkhi.rst new file mode 100644 index 00000000000000..1eb916ce8e9843 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-10-28-07-34-05.gh-issue-160806.ZZRkhi.rst @@ -0,0 +1,3 @@ +Fix the pure-Python implementation of :class:`datetime.timedelta` losing +sub-second precision with large float arguments. It now gives the same +results as the C implementation.