diff --git a/babel/core.py b/babel/core.py index 07b2ad0c7..de814de95 100644 --- a/babel/core.py +++ b/babel/core.py @@ -1063,6 +1063,22 @@ def ordinal_form(self) -> PluralRule: """ return self._data.get('ordinal_form', _default_plural_rule) + @property + def ordinal_day_formats(self) -> localedata.LocaleDataDict: + """Locale patterns for ordinal days of the month, e.g. "13th" in English. + + The keys are `ordinal_form` plural categories, and ``{0}`` is replaced + with the day of the month. + + Introduced in CLDR 49. + + .. note:: The format of the value returned may change between + Babel versions. + + .. versionadded:: 2.19 + """ + return self._data.get('ordinal_day_formats', localedata.LocaleDataDict({})) + @property def measurement_systems(self) -> localedata.LocaleDataDict: """Localized names for various measurement systems. diff --git a/babel/dates.py b/babel/dates.py index 7b626aa11..8b00c85bd 100644 --- a/babel/dates.py +++ b/babel/dates.py @@ -1444,7 +1444,7 @@ def __getitem__(self, name: str) -> str: elif char in ('w', 'W'): return self.format_week(char, num) elif char == 'd': - return self.format(self.value.day, num) + return self.format_day_of_month(num) elif char == 'D': return self.format_day_of_year(num) elif char == 'F': @@ -1580,6 +1580,29 @@ def format_weekday(self, char: str = 'E', num: int = 4) -> str: context = "stand-alone" if char == "c" else "format" return get_day_names(width, context, self.locale)[weekday] + def format_day_of_month(self, num: int) -> str: + """ + Return day of month from parsed datetime according to format pattern. + + >>> from datetime import date + >>> format = DateTimeFormat(date(2016, 2, 28), Locale.parse('en_US')) + >>> format.format_day_of_month(1) + '28' + + 'd': Day of month - Use one or two for the minimum number of digits, or + three for the ordinal form (CLDR 49 and later), e.g. "28th" in + English. Locales without ordinal forms fall back to a single "d". + """ + if num == 3: + ordinal_pattern = self.locale.ordinal_day_formats.get( + self.locale.ordinal_form(self.value.day), + ) + if ordinal_pattern: + return ordinal_pattern.replace('{0}', str(self.value.day)) + # No ordinal forms for this locale; format like a single "d" would. + num = 1 + return self.format(self.value.day, num) + def format_day_of_year(self, num: int) -> str: return self.format(self.get_day_of_year(), num) @@ -1753,7 +1776,7 @@ def get_week_number(self, day_of_period: int, day_of_week: int | None = None) -> 'Q': [1, 2, 3, 4, 5], 'q': [1, 2, 3, 4, 5], # quarter 'M': [1, 2, 3, 4, 5], 'L': [1, 2, 3, 4, 5], # month 'w': [1, 2], 'W': [1], # week - 'd': [1, 2], 'D': [1, 2, 3], 'F': [1], 'g': None, # day + 'd': [1, 2, 3], 'D': [1, 2, 3], 'F': [1], 'g': None, # day 'E': [1, 2, 3, 4, 5, 6], 'e': [1, 2, 3, 4, 5, 6], 'c': [1, 3, 4, 5, 6], # week day 'a': [1, 2, 3, 4, 5], 'b': [1, 2, 3, 4, 5], 'B': [1, 2, 3, 4, 5], # period 'h': [1, 2], 'H': [1, 2], 'K': [1, 2], 'k': [1, 2], # hour diff --git a/scripts/import_cldr.py b/scripts/import_cldr.py index 02e01f199..aacf57631 100755 --- a/scripts/import_cldr.py +++ b/scripts/import_cldr.py @@ -448,6 +448,7 @@ def _process_local_datas(sup, srcdir, destdir, force=True, dump_json=False): parse_calendar_quarters(data, calendar) parse_calendar_eras(data, calendar) parse_calendar_periods(data, calendar) + parse_calendar_ordinal_day_formats(data, calendar) parse_calendar_date_formats(data, calendar) parse_calendar_time_formats(data, calendar) parse_calendar_datetime_skeletons(data, calendar) @@ -720,6 +721,25 @@ def parse_calendar_periods(data, calendar): dest_dict[period_type] = str(day_period.text) +def parse_calendar_ordinal_day_formats(data, calendar): + # Ordinal days of the month, e.g. "1st" in English (CLDR 49 and later). + # These are keyed by the ordinal plural categories of the locale. + ordinal_day_formats = data.setdefault('ordinal_day_formats', {}) + for day_of_month_context in calendar.findall('dayOfMonths/dayOfMonthContext'): + if day_of_month_context.attrib.get('type') != 'format': + continue + for day_of_month_width in day_of_month_context.findall('dayOfMonthWidth'): + if day_of_month_width.attrib.get('type') != 'abbreviated': + continue + for elem in day_of_month_width.findall('dayOfMonth'): + ordinal = elem.attrib.get('ordinal') + if ordinal is None or elem.text is None: + continue + if _should_skip_elem(elem, ordinal, ordinal_day_formats): + continue + ordinal_day_formats[ordinal] = str(elem.text) + + def parse_calendar_date_formats(data, calendar): date_formats = data.setdefault('date_formats', {}) for format in calendar.findall('dateFormats'): diff --git a/tests/test_dates.py b/tests/test_dates.py index 4f8c73907..18a0f96e1 100644 --- a/tests/test_dates.py +++ b/tests/test_dates.py @@ -553,6 +553,49 @@ def test_parse_pattern(): assert dates.parse_pattern("hh' o''clock'").format == "%(hh)s o'clock" +def test_parse_pattern_ordinal_day(): + # CLDR 49 added the ordinal day of month field, e.g. "Feb 1st" + assert dates.parse_pattern("MMM ddd, y").format == '%(MMM)s %(ddd)s, %(y)s' + + +def test_parse_pattern_rejects_unsupported_day_length(): + with pytest.raises(ValueError): + dates.parse_pattern("dddd") + + +def test_format_ordinal_day_of_month(monkeypatch): + # CLDR 49 and later provide one ordinal pattern per ordinal plural category + locale = Locale('en') + ordinal_day_formats = { + 'one': '{0}st', + 'two': '{0}nd', + 'few': '{0}rd', + 'other': '{0}th', + } + monkeypatch.setitem(locale._data, 'ordinal_day_formats', ordinal_day_formats) + + def format_day(day: int) -> str: + return dates.format_datetime(datetime(2016, 2, day), "MMM ddd, y", locale=locale) + + assert format_day(1) == 'Feb 1st, 2016' + assert format_day(2) == 'Feb 2nd, 2016' + assert format_day(3) == 'Feb 3rd, 2016' + assert format_day(4) == 'Feb 4th, 2016' + assert format_day(11) == 'Feb 11th, 2016' + assert format_day(22) == 'Feb 22nd, 2016' + + +def test_format_ordinal_day_of_month_falls_back_without_forms(): + # Locales without ordinal forms format the day like a single "d" would, + # without zero padding (CLDR 49 has no forms for German, for instance). + day = datetime(2016, 2, 1) + assert ( + dates.format_datetime(day, "MMM ddd, y", locale='de') + == dates.format_datetime(day, "MMM d, y", locale='de') + ) + assert dates.format_datetime(day, "dd.MM.y", locale='de') == '01.02.2016' + + def test_lithuanian_long_format(): assert ( dates.format_date(date(2015, 12, 10), locale='lt_LT', format='long') ==