diff --git a/babel/core.py b/babel/core.py index 3ba8b85ea..bc2991c80 100644 --- a/babel/core.py +++ b/babel/core.py @@ -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. diff --git a/babel/dates.py b/babel/dates.py index 72f2c4439..7dd26ba72 100644 --- a/babel/dates.py +++ b/babel/dates.py @@ -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 ```` 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) + if data is None: + return None + return data.get('relative', {}).get(offset) + + def get_date_format( format: _PredefinedTimeFormat = 'medium', locale: Locale | str | None = None, @@ -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] + ) + 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', diff --git a/docs/api/dates.rst b/docs/api/dates.rst index 9f0c80b57..a8acc0a2b 100644 --- a/docs/api/dates.rst +++ b/docs/api/dates.rst @@ -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 ---------------------- @@ -59,6 +61,8 @@ Data Access .. autofunction:: get_time_format +.. autofunction:: get_relative_name + Basic Parsing ------------- diff --git a/scripts/import_cldr.py b/scripts/import_cldr.py index 2bbdad173..2d8de7e77 100755 --- a/scripts/import_cldr.py +++ b/scripts/import_cldr.py @@ -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: @@ -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': @@ -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. yesterday. + 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): diff --git a/tests/test_dates.py b/tests/test_dates.py index f80bb3288..58c30098a 100644 --- a/tests/test_dates.py +++ b/tests/test_dates.py @@ -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 + 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'