Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 43 additions & 3 deletions babel/dates.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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 = (
Expand Down
24 changes: 23 additions & 1 deletion tests/test_date_time_format.py
Original file line number Diff line number Diff line change
@@ -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():
Expand Down Expand Up @@ -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'
Expand Down