Coverage for app/backend/src/couchers/i18n/localize.py: 93%
69 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-21 21:52 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-21 21:52 +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], locale: babel.Locale) -> str:
20 return format_list(items, locale=locale)
23def try_localize_language_name_from_iso639(code: str, locale: babel.Locale, standalone: bool = False) -> str | None:
24 """
25 Attempts to localize the name of a language expressed as an ISO639 code.
27 Args:
28 code: The ISO639 language code.
29 locale: The locale to render the language name in.
30 standalone: The result won't be part of a larger sentence and should be capitalized if the language has capitals.
32 Returns:
33 The localized name, or None if no localized name is available.
34 """
35 try:
36 name = babel.Locale.parse(code).get_language_name(locale)
37 if name is None: 37 ↛ 38line 37 didn't jump to line 38 because the condition on line 37 was never true
38 return None
39 if standalone:
40 # The Unicode CLDR returns a casing that allows embedding in a larger sentence, e.g. "español".
41 # If we're displaying the language name on its own, capitalize its first letter if applicable.
42 # An LLM prompt revealed that this holds for all major languages.
43 # It is a no-op for scripts that don't have capital letters.
44 name = name[:1].title() + name[1:]
45 return name
46 except ValueError, babel.UnknownLocaleError:
47 return None
50def try_localize_region_name_from_iso3166(code: str, locale: babel.Locale) -> str | None:
51 """
52 Gets a region name specified as an ISO3166 alpha2 or alpha3 code, localized in the given locale.
53 """
54 # The Unicode CLDR uses alpha2 codes as keys (all alpha3 codes have a corresponding alpha2 code)
55 code = get_region_code_iso3166_alpha3_to_alpha2().get(code, code)
56 region_name: str | None = locale.territories.get(code, None)
57 return region_name
60def localize_date(
61 value: date, locale: babel.Locale, *, abbrev: bool = False, with_year: bool = True, with_day_of_week: bool = False
62) -> str:
63 """Formats a time- and timezone-agnostic date for the given locale."""
64 pattern = _get_cldr_date_pattern(locale, abbrev=abbrev, with_year=with_year, with_day_of_week=with_day_of_week)
65 return parse_pattern(pattern).apply(value, locale)
68def localize_time(value: time, locale: babel.Locale, *, with_seconds: bool = False) -> str:
69 """Formats a date- and timezone-agnostic time for the given locale."""
70 pattern = _get_cldr_time_pattern(locale, with_seconds=with_seconds)
71 return parse_pattern(pattern).apply(value, locale)
74def localize_datetime(
75 value: datetime,
76 locale: babel.Locale,
77 *,
78 abbrev: bool = False,
79 with_year: bool = True,
80 with_day_of_week: bool = False,
81 with_seconds: bool = False,
82) -> str:
83 """Formats a date and time for the given locale."""
84 # A timezone-unaware datetime is almost certainly a bug, so we don't support it.
85 assert value.tzinfo is not None, "Cannot localize a timezone-unaware datetime."
87 pattern = _combine_cldr_date_time_patterns(
88 locale,
89 _get_cldr_date_pattern(locale, abbrev=abbrev, with_year=with_year, with_day_of_week=with_day_of_week),
90 _get_cldr_time_pattern(locale, with_seconds=with_seconds),
91 )
92 return parse_pattern(pattern).apply(value, locale)
95def _get_cldr_date_pattern(
96 locale: babel.Locale, *, abbrev: bool = False, with_year: bool = True, with_day_of_week: bool = False
97) -> str:
98 # First build a Unicode CLDR datetime pattern skeleton, which is locale and order-agnostic,
99 # and only indicates the components we're interested in formatting.
100 # This is similar to Intl.DateTimeFormat in Javascript.
101 # See https://cldr.unicode.org/translation/date-time/date-time-symbols.
102 requested_skeleton = ""
104 if with_year:
105 requested_skeleton += "y"
106 requested_skeleton += "MMM" if abbrev else "MMMM"
107 requested_skeleton += "d"
108 if with_day_of_week:
109 requested_skeleton += "EEE" if abbrev else "EEEE"
111 # Next, match that skeleton to a similar locale-supported skeleton,
112 # which allows us to lower it to a datetime pattern (locale and order-specific).
113 matched_skeleton = match_skeleton(requested_skeleton, options=locale.datetime_skeletons)
114 if not matched_skeleton: 114 ↛ 115line 114 didn't jump to line 115 because the condition on line 114 was never true
115 raise ValueError(f"Locale {locale.english_name} has no matching datetime skeleton for '{requested_skeleton}'")
117 pattern: str = locale.datetime_skeletons[matched_skeleton].pattern
119 # By CLDR rules, skeleton matching might return a pattern with abbreviations where
120 # we asked for non-abbreviated forms, in which case we can update the returned pattern.
121 if not abbrev:
122 # Abbreviated to non-abbreviated month (MMM = abbreviated)
123 pattern = re.sub(r"(?<!M)MMM(?!M)", "MMMM", pattern)
124 if with_day_of_week:
125 # Abbreviated to non-abbreviated day of week (E = EEE = abbreviated)
126 pattern = re.sub(r"(?<!E)E{1,3}(?!E)", "EEEE", pattern)
128 return pattern
131def _get_cldr_time_pattern(locale: babel.Locale, *, with_seconds: bool = False) -> str:
132 # Use a reference format pattern to figure out if it's using 24h clock
133 reference_time_pattern: str = locale.time_formats["medium"].pattern
135 # Remove literals like 'of'
136 reference_time_pattern = re.sub("'[^']*'", "", reference_time_pattern)
138 # Extract only the hours, minutes and am/pm patterns.
139 requested_skeleton = re.sub("[^hHkKma]+", "", reference_time_pattern)
140 if with_seconds:
141 requested_skeleton += "ss"
143 # Next, match that skeleton to a similar locale-supported skeleton,
144 # which allows us to lower it to a datetime pattern (locale and order-specific).
145 matched_skeleton = match_skeleton(requested_skeleton, options=locale.datetime_skeletons)
146 if not matched_skeleton: 146 ↛ 147line 146 didn't jump to line 147 because the condition on line 146 was never true
147 raise ValueError(f"Locale {locale.english_name} has no matching datetime skeleton for '{requested_skeleton}'")
149 return cast(str, locale.datetime_skeletons[matched_skeleton].pattern) # "pattern" is Any-typed
152def _combine_cldr_date_time_patterns(locale: babel.Locale, date_pattern: str, time_pattern: str) -> str:
153 # get_datetime_format's return value is statically mistyped
154 combining_format = cast(str, get_datetime_format(locale=locale))
156 # CLDR defines {0} to be the time and {1} to be the date
157 return combining_format.replace("{1}", date_pattern).replace("{0}", time_pattern)
160def localize_timezone(timezone: tzinfo, locale: babel.Locale, *, short: bool = False) -> str:
161 return get_timezone_name(timezone, width="short" if short else "long", locale=locale)
164def format_phone_number(value: str) -> str:
165 """Formats a phone number from E.164 format to the international format."""
166 return phonenumbers.format_number(phonenumbers.parse(value), phonenumbers.PhoneNumberFormat.INTERNATIONAL)