diff --git a/babel/dates.py b/babel/dates.py index 7f3371248..ba525ac8e 100644 --- a/babel/dates.py +++ b/babel/dates.py @@ -1377,10 +1377,11 @@ def parse_time( # 12 AM and 12 PM are 00:00 and 12:00 respectively. hour = int(numbers[indexes['H']]) if 'a' in format_str: - if 'pm' in string.lower(): + period = _parse_period(string, Locale.parse(locale or LC_TIME)) + if period == 'pm': if hour != 12: hour += 12 - elif 'am' in string.lower() and hour == 12: + elif period == 'am' and hour == 12: hour = 0 # Parse up to three numbers from the string. @@ -1392,6 +1393,35 @@ def parse_time( return datetime.time(hour, minute, second) +def _parse_period(string: str, locale: Locale) -> Literal['am', 'pm'] | None: + """Find which AM/PM day period marker, if any, occurs in `string`. + + The locale's own period names (e.g. ``오후`` for Korean or ``p.m.`` for + Spanish) are checked first, longest first so that a short name can't + shadow a longer one; the English ``am`` and ``pm`` are accepted as a + fallback. Comparison ignores case and whitespace, since CLDR data uses + various non-breaking spaces inside period names. + """ + + def _normalize(value: str) -> str: + return ''.join(value.split()).casefold() + + text = _normalize(string) + names = set() + for context in locale.day_periods.values(): + for width in context.values(): + for period in ('am', 'pm'): + if name := _normalize(width.get(period) or ''): + names.add((name, period)) + for name, period in sorted(names, key=lambda item: (-len(item[0]), item)): + if name in text: + return period + for period in ('pm', 'am'): + if period in text: + return period + return None + + class DateTimePattern: def __init__(self, pattern: str, format: DateTimeFormat): self.pattern = pattern diff --git a/tests/test_dates.py b/tests/test_dates.py index ee7285e27..0c75f2c0f 100644 --- a/tests/test_dates.py +++ b/tests/test_dates.py @@ -540,6 +540,29 @@ def test_parse_time(input, expected): assert dates.parse_time(input, locale='en_US') == expected +def test_parse_time_localized_am_pm(): + # The day period marker must be recognized in the locale's own language, + # not only as the English "am"/"pm". + assert dates.parse_time('오후 3:30', locale='ko_KR', format='short') == time(15, 30) + assert dates.parse_time('오전 12:05', locale='ko_KR', format='short') == time(0, 5) + assert dates.parse_time('3:30\u202fp.m.', locale='es_US', format='short') == time(15, 30) + assert dates.parse_time('12:30\u202fa.m.', locale='es_MX', format='short') == time(0, 30) + assert dates.parse_time('3:30\u202fμ.μ.', locale='el_GR', format='short') == time(15, 30) + assert dates.parse_time('3:30 م', locale='ar_EG', format='short') == time(15, 30) + assert dates.parse_time('3:30 ص', locale='ar_EG', format='short') == time(3, 30) + # CLDR names may contain non-breaking spaces; any whitespace is accepted + assert dates.parse_time('3:30 p. m.', locale='ca', format='h:mm a') == time(15, 30) + # English markers keep working, including with other locales + assert dates.parse_time('3:30 PM', locale='de_DE', format='h:mm a') == time(15, 30) + + # Everything format_time() produces on a 12-hour clock parses back + for locale in ('en_US', 'ko_KR', 'es_US', 'el_GR', 'ar_EG', 'aa', 'sq', 'fa', 'ca'): + for hour in range(24): + value = time(hour, 7, 9) + formatted = dates.format_time(value, 'h:mm:ss a', locale=locale) + assert dates.parse_time(formatted, locale=locale, format='h:mm:ss a') == value, (locale, formatted) + + def test_parse_time_no_seconds_in_format(): # parse time using a time format which does not include seconds locale = 'cs_CZ'