diff --git a/babel/dates.py b/babel/dates.py index 7b626aa11..b83506b84 100644 --- a/babel/dates.py +++ b/babel/dates.py @@ -1480,7 +1480,7 @@ def __getitem__(self, name: str) -> str: else: raise KeyError(f"Unsupported date/time field {char!r}") - def extract(self, char: str) -> int: + def extract(self, char: str) -> int | str: char = str(char)[0] if char == 'y': return self.value.year @@ -1496,6 +1496,12 @@ def extract(self, char: str) -> int: return self.value.minute elif char == 'a': return int(self.value.hour >= 12) # 0 for am, 1 for pm + elif char == 'G': + return int(self.value.year >= 0) # 0 for BCE, 1 for CE + elif char == 'B': + # The day period is rendered from the same identifier, + # so the extracted value matches what `format_period` renders. + return get_period_id(self.value, locale=self.locale) else: raise NotImplementedError( f"Not implemented: extracting {char!r} from {self.value!r}", @@ -1621,14 +1627,22 @@ def format_period(self, char: str, num: int) -> str: ] if char == 'a': period = 'pm' if self.value.hour >= 12 else 'am' - context = 'format' + contexts = ('format',) else: period = get_period_id(self.value, locale=self.locale) - context = 'format' if char == 'B' else 'stand-alone' - for width in widths: - period_names = get_period_names(context=context, width=width, locale=self.locale) - if period in period_names: - return period_names[period] + # CLDR falls back from a stand-alone name to the format name + contexts = ('format', 'stand-alone') if char == 'B' else ('stand-alone', 'format') + # A locale without a name for the flexible day period falls back to + # the name of the day period it belongs to, as CLDR does + general_period = 'pm' if self.value.hour >= 12 else 'am' + for candidate in (period, general_period): + for context in contexts: + for width in widths: + period_names = get_period_names( + context=context, width=width, locale=self.locale, + ) + if candidate in period_names: + return period_names[candidate] raise ValueError(f"Could not format period {period} in {self.locale}") def format_frac_seconds(self, num: int) -> str: diff --git a/tests/test_date_intervals.py b/tests/test_date_intervals.py index 33a85d38a..ee52b75c1 100644 --- a/tests/test_date_intervals.py +++ b/tests/test_date_intervals.py @@ -61,3 +61,32 @@ def test_issue_825(): locale='pt', ) assert formatted == '1 h' + + +@pytest.mark.parametrize( + ("start", "end", "skeleton", "expected"), + [ + # Era ('G') interval formats used to raise NotImplementedError when + # computing the greatest difference between the two instants. + # A year difference must select the 'y' pattern of the "Gy" interval format. + (TEST_DT, TEST_DT.replace(year=2017), "Gy", "2016\u2009\u2013\u20092017 AD"), + ( + TEST_DT, + TEST_DT.replace(year=2017, month=3), + "GyM", + "1/2016\u2009\u2013\u20093/2017 AD", + ), + # Day period ('B') interval formats used to raise NotImplementedError, too. + # Differing day periods must select the 'B' pattern of the "Bh" interval format. + ( + TEST_DT, + TEST_DT.replace(hour=16), + "Bh", + "11 in the morning\u2009\u2013\u20094 in the afternoon", + ), + # ... and a shared day period must fall through to the 'h' pattern. + (TEST_DT, TEST_DT.replace(hour=10), "Bh", "11\u2009\u2013\u200910 in the morning"), + ], +) +def test_format_interval_era_and_day_period(start, end, skeleton, expected): + assert dates.format_interval(start, end, skeleton, locale="en") == expected diff --git a/tests/test_dates.py b/tests/test_dates.py index 4f8c73907..222bebda6 100644 --- a/tests/test_dates.py +++ b/tests/test_dates.py @@ -203,6 +203,18 @@ def test_get_period_names(): assert dates.get_period_names(locale='en_US')['am'] == 'AM' +def test_format_period_without_names_for_the_flexible_day_period(): + # Locales such as az_Arab only carry names for 'am'/'pm', so the flexible + # day period that 'B' and 'b' name has no name to return. Fall back to + # the name of the day period the time belongs to instead of raising. + dt = datetime(2020, 1, 1, 11, 30) + assert dates.format_datetime(dt, 'B', locale='az_Arab') == 'AM' + assert dates.format_datetime(dt, 'b', locale='az_Arab') == 'AM' + assert dates.format_datetime(dt, 'a', locale='az_Arab') == 'AM' + # A locale that does name the flexible day period is unaffected. + assert dates.format_datetime(dt, 'B', locale='fi_FI') == 'aamup.' + + def test_get_day_names(): assert dates.get_day_names('wide', locale='en_US')[1] == 'Tuesday' assert dates.get_day_names('short', locale='en_US')[1] == 'Tu'