Skip to content

Commit 455c1b0

Browse files
canblmz1claude
andcommitted
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 <noreply@anthropic.com>
1 parent e354246 commit 455c1b0

3 files changed

Lines changed: 39 additions & 82 deletions

File tree

‎Lib/_pydatetime.py‎

Lines changed: 29 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -663,14 +663,6 @@ class timedelta:
663663

664664
def __new__(cls, days=0, seconds=0, microseconds=0,
665665
milliseconds=0, minutes=0, hours=0, weeks=0):
666-
# Doing this efficiently and accurately in C is going to be difficult
667-
# and error-prone, due to ubiquitous overflow possibilities, and that
668-
# C double doesn't have enough bits of precision to represent
669-
# microseconds over 10K years faithfully. The code here tries to make
670-
# explicit where go-fast assumptions can be relied on, in order to
671-
# guide the C implementation; it's way more convoluted than speed-
672-
# ignoring auto-overflow-to-long idiomatic Python could be.
673-
674666
for name, value in (
675667
("days", days),
676668
("seconds", seconds),
@@ -685,80 +677,35 @@ def __new__(cls, days=0, seconds=0, microseconds=0,
685677
f"unsupported type for timedelta {name} component: {type(value).__name__}"
686678
)
687679

688-
# Final values, all integer.
689-
# s and us fit in 32-bit signed ints; d isn't bounded.
690-
d = s = us = 0
691-
692-
# Normalize everything to days, seconds, microseconds.
693-
days += weeks*7
694-
seconds += minutes*60 + hours*3600
695-
microseconds += milliseconds*1000
696-
697-
# Get rid of all fractions, and normalize s and us.
698-
# Take a deep breath <wink>.
699-
if isinstance(days, float):
700-
dayfrac, days = _math.modf(days)
701-
daysecondsfrac, daysecondswhole = _math.modf(dayfrac * (24.*3600.))
702-
assert daysecondswhole == int(daysecondswhole) # can't overflow
703-
s = int(daysecondswhole)
704-
assert days == int(days)
705-
d = int(days)
706-
else:
707-
daysecondsfrac = 0.0
708-
d = days
709-
assert isinstance(daysecondsfrac, float)
710-
assert abs(daysecondsfrac) <= 1.0
711-
assert isinstance(d, int)
712-
assert abs(s) <= 24 * 3600
713-
# days isn't referenced again before redefinition
714-
715-
if isinstance(seconds, float):
716-
secondsfrac, seconds = _math.modf(seconds)
717-
assert seconds == int(seconds)
718-
seconds = int(seconds)
719-
secondsfrac += daysecondsfrac
720-
assert abs(secondsfrac) <= 2.0
721-
else:
722-
secondsfrac = daysecondsfrac
723-
# daysecondsfrac isn't referenced again
724-
assert isinstance(secondsfrac, float)
725-
assert abs(secondsfrac) <= 2.0
726-
727-
assert isinstance(seconds, int)
728-
days, seconds = divmod(seconds, 24*3600)
729-
d += days
730-
s += int(seconds) # can't overflow
731-
assert isinstance(s, int)
732-
assert abs(s) <= 2 * 24 * 3600
733-
# seconds isn't referenced again before redefinition
734-
735-
usdouble = secondsfrac * 1e6
736-
assert abs(usdouble) < 2.1e6 # exact value not critical
737-
# secondsfrac isn't referenced again
738-
739-
if isinstance(microseconds, float):
740-
microseconds = round(microseconds + usdouble)
741-
seconds, microseconds = divmod(microseconds, 1000000)
742-
days, seconds = divmod(seconds, 24*3600)
743-
d += days
744-
s += seconds
745-
else:
746-
microseconds = int(microseconds)
747-
seconds, microseconds = divmod(microseconds, 1000000)
748-
days, seconds = divmod(seconds, 24*3600)
749-
d += days
750-
s += seconds
751-
microseconds = round(microseconds + usdouble)
752-
assert isinstance(s, int)
753-
assert isinstance(microseconds, int)
754-
assert abs(s) <= 3 * 24 * 3600
755-
assert abs(microseconds) < 3.1e6
756-
757-
# Just a little bit of carrying possible for microseconds and seconds.
758-
seconds, us = divmod(microseconds, 1000000)
759-
s += seconds
760-
days, s = divmod(s, 24*3600)
761-
d += days
680+
# Sum the exact number of microseconds, like the C implementation:
681+
# the integer part of each float is converted exactly, and only the
682+
# fractional microseconds left over are added up as a float and
683+
# rounded (half to even) at the end.
684+
us = 0
685+
leftover = 0.0
686+
for value, factor in (
687+
(microseconds, 1),
688+
(milliseconds, 1000),
689+
(seconds, 1000000),
690+
(minutes, 60*1000000),
691+
(hours, 3600*1000000),
692+
(days, 24*3600*1000000),
693+
(weeks, 7*24*3600*1000000)
694+
):
695+
if isinstance(value, float):
696+
frac, whole = _math.modf(value)
697+
us += int(whole) * factor
698+
frac, whole = _math.modf(frac * factor)
699+
us += int(whole)
700+
leftover += frac
701+
else:
702+
us += int(value) * factor
703+
if leftover:
704+
n, m = leftover.as_integer_ratio()
705+
us = _divide_and_round(us * m + n, m)
706+
707+
s, us = divmod(us, 1000000)
708+
d, s = divmod(s, 24*3600)
762709

763710
assert isinstance(d, int)
764711
assert isinstance(s, int) and 0 <= s < 24*3600

‎Lib/test/datetimetester.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -554,6 +554,13 @@ def test_constructor(self):
554554
eq(td(seconds=0.001), td(milliseconds=1))
555555
eq(td(milliseconds=0.001), td(microseconds=1))
556556

557+
# Large float args keep their sub-second part
558+
eq(td(weeks=12345678.9), td(86419752, 25920, 225))
559+
eq(td(hours=123456789.1), td(5144032, 75959, 999979))
560+
eq(td(minutes=1234567890.1), td(857338, 70205, 999994))
561+
eq(td(days=1.1, weeks=1000000.1), td(7000001, 69119, 999986))
562+
eq(td(seconds=0.5, microseconds=2.0**60), td(13343998, 77407, 346976))
563+
557564
# Check type of args to constructor
558565
ra(TypeError, lambda: td(weeks='1'))
559566
ra(TypeError, lambda: td(days='1'))
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Fix the pure-Python implementation of :class:`datetime.timedelta` losing
2+
sub-second precision with large float arguments. It now gives the same
3+
results as the C implementation.

0 commit comments

Comments
 (0)