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'