Coverage for app/backend/src/couchers/i18n/localize.py: 90%
79 statements
« prev ^ index » next coverage.py v7.15.3, created at 2026-08-04 22:32 +0000
« prev ^ index » next coverage.py v7.15.3, created at 2026-08-04 22:32 +0000
1"""
2Defines low-level localization functions for strings, dates, etc.
3Most code should use the higher-level couchers.i18n.LocalizationContext object.
4"""
6import re
7from collections.abc import Sequence
8from datetime import date, datetime, time, tzinfo
9from typing import cast
11import babel
12import phonenumbers
13from babel.dates import get_datetime_format, get_timezone_name, match_skeleton, parse_pattern
14from babel.lists import format_list
16from couchers.resources import get_region_code_iso3166_alpha3_to_alpha2
19def localize_list(items: Sequence[str], locales: list[babel.Locale]) -> str:
20 for locale in locales: 20 ↛ 25line 20 didn't jump to line 25 because the loop on line 20 didn't complete
21 try:
22 return format_list(items, locale=locale)
23 except ValueError: # Raised if the locale doesn't support list formatting
24 continue
25 return format_list(items, locale=babel.Locale.parse("en"))
28def try_localize_language_name_from_iso639(
29 code: str, locales: list[babel.Locale], standalone: bool = False
30) -> str | None:
31 """
32 Attempts to localize the name of a language expressed as an ISO639 code.
34 Args:
35 code: The ISO639 language code.
36 locales: The acceptable locales to render the language name in.
37 standalone: The result won't be part of a larger sentence and should be capitalized if the language has capitals.
39 Returns:
40 The localized name, or None if no localized name is available.
41 """
42 for locale in locales:
43 try:
44 name = babel.Locale.parse(code).get_language_name(locale)
45 if name is None: 45 ↛ 46line 45 didn't jump to line 46 because the condition on line 45 was never true
46 continue
47 if standalone:
48 # The Unicode CLDR returns a casing that allows embedding in a larger sentence, e.g. "español".
49 # If we're displaying the language name on its own, capitalize its first letter if applicable.
50 # An LLM prompt revealed that this holds for all major languages.
51 # It is a no-op for scripts that don't have capital letters.
52 name = name[:1].title() + name[1:]
53 return name
54 except ValueError, babel.UnknownLocaleError:
55 continue
56 return None
59def try_localize_region_name_from_iso3166(code: str, locales: list[babel.Locale]) -> str | None:
60 """
61 Gets a region name specified as an ISO3166 alpha2 or alpha3 code,
62 localized in the first acceptable locale provided.
63 """
64 # The Unicode CLDR uses alpha2 codes as keys (all alpha3 codes have a corresponding alpha2 code)
65 code = get_region_code_iso3166_alpha3_to_alpha2().get(code, code)
66 for locale in locales:
67 region_name: str | None = locale.territories.get(code, None)
68 if region_name is not None:
69 return region_name
70 return None
73def localize_date(
74 value: date, locale: babel.Locale, *, abbrev: bool = False, with_year: bool = True, with_day_of_week: bool = False
75) -> str:
76 """Formats a time- and timezone-agnostic date for the given locale."""
77 pattern = _get_cldr_date_pattern(locale, abbrev=abbrev, with_year=with_year, with_day_of_week=with_day_of_week)
78 return parse_pattern(pattern).apply(value, locale)
81def localize_time(value: time, locale: babel.Locale, *, with_seconds: bool = False) -> str:
82 """Formats a date- and timezone-agnostic time for the given locale."""
83 pattern = _get_cldr_time_pattern(locale, with_seconds=with_seconds)
84 return parse_pattern(pattern).apply(value, locale)
87def localize_datetime(
88 value: datetime,
89 locale: babel.Locale,
90 *,
91 abbrev: bool = False,
92 with_year: bool = True,
93 with_day_of_week: bool = False,
94 with_seconds: bool = False,
95) -> str:
96 """Formats a date and time for the given locale."""
97 # A timezone-unaware datetime is almost certainly a bug, so we don't support it.
98 assert value.tzinfo is not None, "Cannot localize a timezone-unaware datetime."
100 pattern = _combine_cldr_date_time_patterns(
101 locale,
102 _get_cldr_date_pattern(locale, abbrev=abbrev, with_year=with_year, with_day_of_week=with_day_of_week),
103 _get_cldr_time_pattern(locale, with_seconds=with_seconds),
104 )
105 return parse_pattern(pattern).apply(value, locale)
108def _get_cldr_date_pattern(
109 locale: babel.Locale, *, abbrev: bool = False, with_year: bool = True, with_day_of_week: bool = False
110) -> str:
111 # First build a Unicode CLDR datetime pattern skeleton, which is locale and order-agnostic,
112 # and only indicates the components we're interested in formatting.
113 # This is similar to Intl.DateTimeFormat in Javascript.
114 # See https://cldr.unicode.org/translation/date-time/date-time-symbols.
115 requested_skeleton = ""
117 if with_year:
118 requested_skeleton += "y"
119 requested_skeleton += "MMM" if abbrev else "MMMM"
120 requested_skeleton += "d"
121 if with_day_of_week:
122 requested_skeleton += "EEE" if abbrev else "EEEE"
124 # Next, match that skeleton to a similar locale-supported skeleton,
125 # which allows us to lower it to a datetime pattern (locale and order-specific).
126 matched_skeleton = match_skeleton(requested_skeleton, options=locale.datetime_skeletons)
127 if not matched_skeleton: 127 ↛ 128line 127 didn't jump to line 128 because the condition on line 127 was never true
128 raise ValueError(f"Locale {locale.english_name} has no matching datetime skeleton for '{requested_skeleton}'")
130 pattern: str = locale.datetime_skeletons[matched_skeleton].pattern
132 # By CLDR rules, skeleton matching might return a pattern with abbreviations where
133 # we asked for non-abbreviated forms, in which case we can update the returned pattern.
134 if not abbrev:
135 # Abbreviated to non-abbreviated month (MMM = abbreviated)
136 pattern = re.sub(r"(?<!M)MMM(?!M)", "MMMM", pattern)
137 if with_day_of_week:
138 # Abbreviated to non-abbreviated day of week (E = EEE = abbreviated)
139 pattern = re.sub(r"(?<!E)E{1,3}(?!E)", "EEEE", pattern)
141 return pattern
144def _get_cldr_time_pattern(locale: babel.Locale, *, with_seconds: bool = False) -> str:
145 # Use a reference format pattern to figure out if it's using 24h clock
146 reference_time_pattern: str = locale.time_formats["medium"].pattern
148 # Remove literals like 'of'
149 reference_time_pattern = re.sub("'[^']*'", "", reference_time_pattern)
151 # Extract only the hours, minutes and am/pm patterns.
152 requested_skeleton = re.sub("[^hHkKma]+", "", reference_time_pattern)
153 if with_seconds:
154 requested_skeleton += "ss"
156 # Next, match that skeleton to a similar locale-supported skeleton,
157 # which allows us to lower it to a datetime pattern (locale and order-specific).
158 matched_skeleton = match_skeleton(requested_skeleton, options=locale.datetime_skeletons)
159 if not matched_skeleton: 159 ↛ 160line 159 didn't jump to line 160 because the condition on line 159 was never true
160 raise ValueError(f"Locale {locale.english_name} has no matching datetime skeleton for '{requested_skeleton}'")
162 return cast(str, locale.datetime_skeletons[matched_skeleton].pattern) # "pattern" is Any-typed
165def _combine_cldr_date_time_patterns(locale: babel.Locale, date_pattern: str, time_pattern: str) -> str:
166 # get_datetime_format's return value is statically mistyped
167 combining_format = cast(str, get_datetime_format(locale=locale))
169 # CLDR defines {0} to be the time and {1} to be the date
170 return combining_format.replace("{1}", date_pattern).replace("{0}", time_pattern)
173def localize_timezone(timezone: tzinfo, locales: list[babel.Locale], *, short: bool = False) -> str:
174 # From the implementation, get_timezone_name has no failure condition for unsupported locales,
175 # so just used the preferred locale.
176 return get_timezone_name(timezone, width="short" if short else "long", locale=locales[0])
179def format_phone_number(value: str) -> str:
180 """Formats a phone number from E.164 format to the international format."""
181 return phonenumbers.format_number(phonenumbers.parse(value), phonenumbers.PhoneNumberFormat.INTERNATIONAL)