From 3da5f78e3b24027e37540cabf63298906ff00888 Mon Sep 17 00:00:00 2001 From: Sasha Mitchell Date: Sat, 3 Oct 2026 00:00:21 +0700 Subject: [PATCH] Carry a rounded fractional second into the next second Formatting 1.999999 seconds as ss.SSS returned 01.1000. The fraction rounded to the next second and was printed as four digits. Round the timestamp to the pattern's S width first so the second carries and the fraction stays within its width. --- babel/dates.py | 46 +++++++++++++++++++++++++++++++--- tests/test_date_time_format.py | 24 +++++++++++++++++- 2 files changed, 66 insertions(+), 4 deletions(-) diff --git a/babel/dates.py b/babel/dates.py index 7b626aa11..0c471f882 100644 --- a/babel/dates.py +++ b/babel/dates.py @@ -1390,6 +1390,40 @@ def parse_time( return datetime.time(hour, minute, second) +def _fractional_second_digits(pattern: str) -> int | None: + digits = 0 + for tok_type, tok_value in tokenize_pattern(pattern): + if tok_type == "field" and isinstance(tok_value, tuple) and tok_value[0] == "S": + digits = max(digits, tok_value[1]) + return digits or None + + +def _add_one_second( + value: datetime.datetime | datetime.time, +) -> datetime.datetime | datetime.time: + if isinstance(value, datetime.datetime): + return value + datetime.timedelta(seconds=1) + combined = datetime.datetime.combine(datetime.date(2000, 1, 1), value) + return (combined + datetime.timedelta(seconds=1)).timetz() + + +def _round_time_for_fraction(value: datetime.date | datetime.time, pattern: str): + """Round to the pattern's fractional-second width, carrying into the next second.""" + digits = _fractional_second_digits(pattern) + # Microseconds are the stored precision, so 6 or more digits cannot carry. + if digits is None or digits >= 6: + return value + if not isinstance(value, (datetime.datetime, datetime.time)): + return value + scaled = round(value.microsecond / 1000000, digits) * 10**digits + if scaled >= 10**digits: + return _add_one_second(value).replace(microsecond=0) + new_us = int(round(scaled)) * 10 ** (6 - digits) + if new_us == value.microsecond: + return value + return value.replace(microsecond=new_us) + + class DateTimePattern: def __init__(self, pattern: str, format: DateTimeFormat): self.pattern = pattern @@ -1413,7 +1447,8 @@ def apply( locale: Locale | str | None, reference_date: datetime.date | None = None, ) -> str: - return self % DateTimeFormat(datetime, locale, reference_date) + value = _round_time_for_fraction(datetime, self.pattern) + return self % DateTimeFormat(value, locale, reference_date) class DateTimeFormat: @@ -1635,10 +1670,15 @@ def format_frac_seconds(self, num: int) -> str: """ Return fractional seconds. Rounds the time's microseconds to the precision given by the number \ - of digits passed in. + of digits passed in. A value that rounds up to the next second is \ + formatted as zero here; ``DateTimePattern.apply`` carries that second \ + into the rest of the timestamp. """ value = self.value.microsecond / 1000000 - return self.format(round(value, num) * 10**num, num) + scaled = round(value, num) * 10**num + if scaled >= 10**num: + scaled = 0 + return self.format(scaled, num) def format_milliseconds_in_day(self, num): msecs = ( diff --git a/tests/test_date_time_format.py b/tests/test_date_time_format.py index 5ca620879..86e616c72 100644 --- a/tests/test_date_time_format.py +++ b/tests/test_date_time_format.py @@ -1,6 +1,6 @@ from datetime import date, datetime, time -from babel.dates import DateTimeFormat, _localize +from babel.dates import DateTimeFormat, _localize, format_datetime, format_time def test_quarter_format(): @@ -175,6 +175,28 @@ def test_fractional_seconds(): assert DateTimeFormat(t, locale='en_US')['SSSSS'] == '00080' +def test_fractional_seconds_round_into_the_next_second(): + almost_two = time(0, 0, 1, 999999) + # The S field is only the fraction. Rounding 999999 microseconds to + # 3 digits is the next second, so the fraction is 0, not 1000. + assert DateTimeFormat(almost_two, locale='en_US')['SSS'] == '000' + assert format_time(almost_two, 'ss.SSS', locale='en_US') == '02.000' + assert format_time(almost_two, 'ss.S', locale='en_US') == '02.0' + # A pattern with no fractional field keeps the original second. + assert format_time(almost_two, 'ss', locale='en_US') == '01' + # A quoted S is literal text, not a fraction width. + assert format_time(almost_two, "ss 'S'", locale='en_US') == '01 S' + # Rounding the fraction without reaching the next second stays on that second. + assert format_time(time(0, 0, 1, 234600), 'ss.SSS', locale='en_US') == '01.235' + # The carry rolls the clock, including across midnight and into the next day. + assert format_time(time(23, 59, 59, 999999), 'HH:mm:ss.SSS', locale='en_US') == '00:00:00.000' + rolled = datetime(2020, 1, 1, 23, 59, 59, 999999) + assert ( + format_datetime(rolled, 'yyyy-MM-dd HH:mm:ss.SSS', locale='en_US') + == '2020-01-02 00:00:00.000' + ) + + def test_fractional_seconds_zero(): t = time(15, 30, 0) assert DateTimeFormat(t, locale='en_US')['SSSS'] == '0000'