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
16 changes: 16 additions & 0 deletions babel/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -975,6 +975,22 @@ def datetime_formats(self) -> localedata.LocaleDataDict:
"""
return self._data['datetime_formats']

@property
def datetime_formats_relative(self) -> localedata.LocaleDataDict:
"""Locale patterns for combining a relative date with a time.

Introduced in CLDR 48.

.. note:: The format of the value returned may change between
Babel versions.

>>> Locale('en').datetime_formats_relative['full']
"{1} 'at' {0}"

.. versionadded:: 2.19
"""
return self._data['datetime_formats_relative']

@property
def datetime_skeletons(self) -> localedata.LocaleDataDict:
"""Locale patterns for formatting parts of a datetime.
Expand Down
104 changes: 104 additions & 0 deletions babel/dates.py
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,59 @@ def get_era_names(
return Locale.parse(locale or LC_TIME).eras[width]


def get_relative_name(
field: str,
offset: int,
length: Literal['long', 'short', 'narrow'] = 'long',
locale: Locale | str | None = None,
) -> str | None:
"""Return the locale's literal name for a relative offset of a date field.

These are the CLDR ``<relative>`` strings such as "yesterday", "today",
"tomorrow" for ``field='day'``, or "last Monday"/"this Monday"/"next Monday"
for ``field='mon'``.

Returns ``None`` when the locale does not define a literal name for the
requested field/offset (in which case a phrase like "in 2 days" built
via :func:`format_timedelta` is typically the right fallback).

>>> get_relative_name('day', -1, locale='en')
'yesterday'
>>> get_relative_name('day', 1, locale='en')
'tomorrow'
>>> get_relative_name('day', -2, locale='de')
'vorgestern'
>>> get_relative_name('year', 1, locale='en')
'next year'
>>> get_relative_name('year', 1, length='short', locale='en')
'next yr.'
>>> get_relative_name('mon', 1, locale='en')
'next Monday'

Offsets without a literal name return ``None``:

>>> get_relative_name('day', 5, locale='en') is None
True

:param field: the date field, e.g. "day", "week", "month", "year",
"hour", "minute", "second", "quarter", or a weekday
abbreviation like "mon", "tue", etc.
:param offset: the offset from the current value of *field*.
:param length: "long", "short", or "narrow".
:param locale: a `Locale` object or a locale identifier. Defaults to the
system time locale.

.. versionadded:: 2.19
"""
locale = Locale.parse(locale or LC_TIME)
date_fields = locale._data['date_fields']
key = field if length == 'long' else f'{field}-{length}'
data = date_fields.get(key) or date_fields.get(field)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So if the specified length does not exist, you get the long version by default?

if data is None:
return None
return data.get('relative', {}).get(offset)


def get_date_format(
format: _PredefinedTimeFormat = 'medium',
locale: Locale | str | None = None,
Expand Down Expand Up @@ -765,6 +818,57 @@ def format_datetime(
return parse_pattern(format).apply(datetime, locale)


def format_relative_datetime(
relative_date: str,
time: datetime.time | datetime.datetime | float | None = None,
format: _PredefinedTimeFormat = 'medium',
tzinfo: datetime.tzinfo | None = None,
locale: Locale | str | None = None,
) -> str:
"""Combine a pre-formatted relative date with a time using the locale's
relative datetime pattern.

For instance, in English you might say "tomorrow at 3:30 PM" instead of
"tomorrow, 3:30 PM".

The caller supplies the relative date portion as a string
(such as "tomorrow" or "in 3 days"); this function formats the time and joins the two.

>>> from datetime import time
>>> format_relative_datetime('tomorrow', time(15, 30), locale='en', format='long')
'tomorrow at 3:30:00\u202fPM UTC'
>>> format_relative_datetime('morgen', time(15, 30), locale='de', format='long')
'morgen um 15:30:00 UTC'

When the locale does not define a relative pattern for the requested
width, the standard ``datetime_formats`` pattern is used as a fallback:

>>> format_relative_datetime('zítra', time(15, 30), locale='cs', format='short')
'zítra 15:30'

:param relative_date: the already-localized relative date string.
:param time: the time to combine with the relative date.
If ``None`` the current time is used.
:param format: one of "full", "long", "medium", or "short".
:param tzinfo: the timezone to apply to the time for display.
:param locale: a `Locale` object or a locale identifier.
Defaults to the system time locale.

.. versionadded:: 2.19
"""
locale = Locale.parse(locale or LC_TIME)
pattern = (
locale.datetime_formats_relative.get(format)
or locale.datetime_formats[format]
)
Comment on lines +859 to +863
return (
pattern
.replace("'", "")
.replace('{0}', format_time(time, format, tzinfo=tzinfo, locale=locale))
.replace('{1}', relative_date)
)


def format_time(
time: datetime.time | datetime.datetime | float | None = None,
format: _PredefinedTimeFormat | str = 'medium',
Expand Down
4 changes: 4 additions & 0 deletions docs/api/dates.rst
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ Date and Time Formatting

.. autofunction:: format_interval(start, end, skeleton=None, tzinfo=None, fuzzy=True, locale=default_locale('LC_TIME'))

.. autofunction:: format_relative_datetime

Timezone Functionality
----------------------

Expand Down Expand Up @@ -59,6 +61,8 @@ Data Access

.. autofunction:: get_time_format

.. autofunction:: get_relative_name

Basic Parsing
-------------

Expand Down
18 changes: 17 additions & 1 deletion scripts/import_cldr.py
Original file line number Diff line number Diff line change
Expand Up @@ -749,6 +749,7 @@ def parse_calendar_time_formats(data, calendar):

def parse_calendar_datetime_skeletons(data, calendar):
datetime_formats = data.setdefault('datetime_formats', {})
datetime_formats_relative = data.setdefault('datetime_formats_relative', {})
datetime_skeletons = data.setdefault('datetime_skeletons', {})
for format in calendar.findall('dateTimeFormats'):
for elem in format:
Expand All @@ -757,7 +758,15 @@ def parse_calendar_datetime_skeletons(data, calendar):
if _should_skip_elem(elem, type, datetime_formats):
continue
try:
datetime_formats[type] = str(elem.findtext('dateTimeFormat/pattern'))
for dtf in elem.findall('dateTimeFormat'):
pattern_elem = dtf.find('pattern')
if pattern_elem is None or pattern_elem.text is None:
continue
pattern = str(pattern_elem.text)
if dtf.attrib.get('type') == 'relative':
datetime_formats_relative[type] = pattern
elif 'type' not in dtf.attrib:
datetime_formats[type] = pattern
except ValueError as e:
log.error(e)
elif elem.tag == 'alias':
Expand Down Expand Up @@ -933,6 +942,13 @@ def parse_date_fields(data, tree):
for pattern in rel_time.findall('relativeTimePattern'):
type_dict = date_fields[field_type].setdefault(rel_time_type, {})
type_dict[pattern.attrib['count']] = str(pattern.text)
# Literal names for specific offsets, e.g. <relative type="-1">yesterday</relative>.
for rel in elem.findall('relative'):
if rel.text is None:
continue
offset = int(rel.attrib['type'])
names = date_fields[field_type].setdefault('relative', {})
names[offset] = str(rel.text)


def parse_interval_formats(data, tree):
Expand Down
150 changes: 150 additions & 0 deletions tests/test_dates.py
Original file line number Diff line number Diff line change
Expand Up @@ -583,6 +583,156 @@ def test_format_datetime(timezone_getter):
assert custom == '2007.04.01 AD at 11:30:00 EDT'


def test_get_relative_name():
assert dates.get_relative_name('day', -1, locale='en') == 'yesterday'
assert dates.get_relative_name('day', 0, locale='en') == 'today'
assert dates.get_relative_name('day', 1, locale='en') == 'tomorrow'
# German has -2/+2 (vorgestern/übermorgen); English does not.
assert dates.get_relative_name('day', -2, locale='de') == 'vorgestern'
assert dates.get_relative_name('day', 2, locale='de') == 'übermorgen'
assert dates.get_relative_name('day', 2, locale='en') is None
# Length variants
assert dates.get_relative_name('year', 1, locale='en') == 'next year'
assert dates.get_relative_name('year', 1, length='short', locale='en') == 'next yr.'
# Weekday fields
assert dates.get_relative_name('mon', 1, locale='en') == 'next Monday'
# Unknown field returns None
assert dates.get_relative_name('bogus', 0, locale='en') is None


def test_get_relative_name_pairs_with_format_relative_datetime():
# Composing the two APIs should produce a complete localized phrase.
relative = dates.get_relative_name('day', 1, locale='en')
assert dates.format_relative_datetime(
relative, time(15, 30), format='long', locale='en',
) == 'tomorrow at 3:30:00\u202fPM UTC'


@pytest.mark.parametrize(('field', 'offset', 'expected'), [
('day', -2, 'toissa päivänä'),
('day', -1, 'eilen'),
('day', 0, 'tänään'),
('day', 1, 'huomenna'),
('day', 2, 'ylihuomenna'),
('week', -1, 'viime viikolla'),
('week', 1, 'ensi viikolla'),
('month', 0, 'tässä kuussa'),
('year', -1, 'viime vuonna'),
('year', 1, 'ensi vuonna'),
('second', 0, 'nyt'),
('quarter', 1, 'ensi neljännesvuonna'),
('mon', 1, 'ensi maanantaina'),
('sun', -1, 'viime sunnuntaina'),
])
def test_get_relative_name_fi(field: str, offset: int, expected: str):
assert dates.get_relative_name(field, offset, locale='fi') == expected


@pytest.mark.parametrize(('field', 'offset', 'length', 'expected'), [
('day', -2, 'short', 'toissap.'),
('day', 1, 'short', 'huom.'),
('day', 2, 'short', 'ylihuom.'),
# Finnish has no day-narrow; implementation falls back to the long form.
('day', 1, 'narrow', 'huomenna'),
])
def test_get_relative_name_fi_lengths(field: str, offset: int, length: str, expected: str):
assert dates.get_relative_name(field, offset, length=length, locale='fi') == expected


@pytest.mark.parametrize(('field', 'offset', 'expected'), [
('day', -2, 'i förrgår'),
('day', -1, 'i går'),
('day', 0, 'i dag'),
('day', 1, 'i morgon'),
('day', 2, 'i övermorgon'),
('week', 1, 'nästa vecka'),
('month', 0, 'den här månaden'),
('year', -1, 'förra året'),
('year', 0, 'i år'),
('second', 0, 'nu'),
('quarter', 1, 'nästa kvartal'),
('mon', 1, 'måndag nästa vecka'),
('sun', -1, 'söndag förra veckan'),
])
def test_get_relative_name_sv(field: str, offset: int, expected: str):
assert dates.get_relative_name(field, offset, locale='sv') == expected


@pytest.mark.parametrize(('field', 'offset', 'length', 'expected'), [
('day', -1, 'narrow', 'igår'),
('day', 0, 'narrow', 'idag'),
('day', 1, 'narrow', 'imorgon'),
])
def test_get_relative_name_sv_lengths(field: str, offset: int, length: str, expected: str):
assert dates.get_relative_name(field, offset, length=length, locale='sv') == expected


@pytest.mark.parametrize(('field', 'offset', 'fmt', 'expected'), [
# Finnish 'long' uses "klo" joiner.
('day', 1, 'long', 'huomenna klo 15.30.00 UTC'),
('day', -1, 'long', 'eilen klo 15.30.00 UTC'),
('day', -2, 'long', 'toissa päivänä klo 15.30.00 UTC'),
('week', 1, 'long', 'ensi viikolla klo 15.30.00 UTC'),
('year', -1, 'long', 'viime vuonna klo 15.30.00 UTC'),
('mon', 1, 'long', 'ensi maanantaina klo 15.30.00 UTC'),
# Finnish 'short' has no "klo" joiner -> comma pattern.
('day', 1, 'short', 'huomenna, 15.30'),
])
def test_format_relative_datetime_fi(field: str, offset: int, fmt: str, expected: str):
assert dates.format_relative_datetime(
dates.get_relative_name(field, offset, locale='fi'),
time(15, 30),
format=fmt,
locale='fi',
) == expected


@pytest.mark.parametrize(('field', 'offset', 'fmt', 'expected'), [
# Swedish 'long' uses "kl." joiner.
('day', 1, 'long', 'i morgon kl. 15:30:00 UTC'),
('day', -1, 'long', 'i går kl. 15:30:00 UTC'),
('day', -2, 'long', 'i förrgår kl. 15:30:00 UTC'),
('week', 1, 'long', 'nästa vecka kl. 15:30:00 UTC'),
('year', -1, 'long', 'förra året kl. 15:30:00 UTC'),
('mon', 1, 'long', 'måndag nästa vecka kl. 15:30:00 UTC'),
# Swedish 'medium' also uses "kl." joiner.
('day', 1, 'medium', 'i morgon kl. 15:30:00'),
# Swedish 'short' has no relative pattern -> falls back to datetime_formats.
('day', 1, 'short', 'i morgon 15:30'),
])
def test_format_relative_datetime_sv(field: str, offset: int, fmt: str, expected: str):
name = dates.get_relative_name(field, offset, locale='sv')
assert dates.format_relative_datetime(
name,
time(15, 30),
format=fmt,
locale='sv',
) == expected


def test_format_relative_datetime():
# English 'long': distinct "at" joiner
assert dates.format_relative_datetime(
'tomorrow', time(15, 30), format='long', locale='en',
) == 'tomorrow at 3:30:00\u202fPM UTC'
# German 'long': "um" joiner
assert dates.format_relative_datetime(
'morgen', time(15, 30), format='long', locale='de',
) == 'morgen um 15:30:00 UTC'
# French 'full': "à" joiner
assert dates.format_relative_datetime(
'demain', time(15, 30), format='full', locale='fr',
) == 'demain à 15:30:00 temps universel coordonné'
# Czech has no relative pattern, so falls back to datetime_formats

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Surprised there are no relative patterns since they should work fine here, but I guess they'll add them eventually..

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mmmm, I think this is related to #1076...

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's see how this changes after #1310 lands :)

assert dates.format_relative_datetime(
'zítra', time(15, 30), format='short', locale='cs',
) == 'zítra 15:30'
# Medium English has no distinctive relative pattern, so same as standard
assert dates.format_relative_datetime(
'tomorrow', time(15, 30), format='medium', locale='en',
) == 'tomorrow, 3:30:00\u202fPM'


def test_format_time(timezone_getter):
t = time(15, 30)
assert dates.format_time(t, locale='en_US') == '3:30:00\u202fPM'
Expand Down
Loading