Coverage for app/backend/src/couchers/i18n/localize.py: 93%
54 statements
« prev ^ index » next coverage.py v7.14.2, created at 2026-06-21 09:29 +0000
« prev ^ index » next coverage.py v7.14.2, created at 2026-06-21 09:29 +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 Mapping
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
15from couchers.i18n.locales import DEFAULT_LOCALE, get_main_i18next
18def localize_string(lang: str | None, key: str, *, substitutions: Mapping[str, str | int] | None = None) -> str:
19 """
20 Retrieves a translated string and performs substitutions.
22 Args:
23 lang: Language code (e.g., "en", "pt-BR"). If None, defaults to the default fallback language ("en")
24 key: The key for the string to be looked up.
25 substitutions: Dictionary of variable substitutions for the string (e.g., {"hours": 24})
27 Returns:
28 The translated string with substitutions applied
29 """
30 return get_main_i18next().localize(key, lang or DEFAULT_LOCALE, substitutions)
33def localize_date(
34 value: date, locale: babel.Locale, *, abbrev: bool = False, with_year: bool = True, with_day_of_week: bool = False
35) -> str:
36 """Formats a time- and timezone-agnostic date for the given locale."""
37 pattern = _get_cldr_date_pattern(locale, abbrev=abbrev, with_year=with_year, with_day_of_week=with_day_of_week)
38 return parse_pattern(pattern).apply(value, locale)
41def localize_time(value: time, locale: babel.Locale, *, with_seconds: bool = False) -> str:
42 """Formats a date- and timezone-agnostic time for the given locale."""
43 pattern = _get_cldr_time_pattern(locale, with_seconds=with_seconds)
44 return parse_pattern(pattern).apply(value, locale)
47def localize_datetime(
48 value: datetime,
49 locale: babel.Locale,
50 *,
51 abbrev: bool = False,
52 with_year: bool = True,
53 with_day_of_week: bool = False,
54 with_seconds: bool = False,
55) -> str:
56 """Formats a date and time for the given locale."""
57 # A timezone-unaware datetime is almost certainly a bug, so we don't support it.
58 assert value.tzinfo is not None, "Cannot localize a timezone-unaware datetime."
60 pattern = _combine_cldr_date_time_patterns(
61 locale,
62 _get_cldr_date_pattern(locale, abbrev=abbrev, with_year=with_year, with_day_of_week=with_day_of_week),
63 _get_cldr_time_pattern(locale, with_seconds=with_seconds),
64 )
65 return parse_pattern(pattern).apply(value, locale)
68def _get_cldr_date_pattern(
69 locale: babel.Locale, *, abbrev: bool = False, with_year: bool = True, with_day_of_week: bool = False
70) -> str:
71 # First build a Unicode CLDR datetime pattern skeleton, which is locale and order-agnostic,
72 # and only indicates the components we're interested in formatting.
73 # This is similar to Intl.DateTimeFormat in Javascript.
74 # See https://cldr.unicode.org/translation/date-time/date-time-symbols.
75 requested_skeleton = ""
77 if with_year:
78 requested_skeleton += "y"
79 requested_skeleton += "MMM" if abbrev else "MMMM"
80 requested_skeleton += "d"
81 if with_day_of_week:
82 requested_skeleton += "EEE" if abbrev else "EEEE"
84 # Next, match that skeleton to a similar locale-supported skeleton,
85 # which allows us to lower it to a datetime pattern (locale and order-specific).
86 matched_skeleton = match_skeleton(requested_skeleton, options=locale.datetime_skeletons)
87 if not matched_skeleton: 87 ↛ 88line 87 didn't jump to line 88 because the condition on line 87 was never true
88 raise ValueError(f"Locale {locale.english_name} has no matching datetime skeleton for '{requested_skeleton}'")
90 pattern: str = locale.datetime_skeletons[matched_skeleton].pattern
92 # By CLDR rules, skeleton matching might return a pattern with abbreviations where
93 # we asked for non-abbreviated forms, in which case we can update the returned pattern.
94 if not abbrev:
95 # Abbreviated to non-abbreviated month (MMM = abbreviated)
96 pattern = re.sub(r"(?<!M)MMM(?!M)", "MMMM", pattern)
97 if with_day_of_week:
98 # Abbreviated to non-abbreviated day of week (E = EEE = abbreviated)
99 pattern = re.sub(r"(?<!E)E{1,3}(?!E)", "EEEE", pattern)
101 return pattern
104def _get_cldr_time_pattern(locale: babel.Locale, *, with_seconds: bool = False) -> str:
105 # Use a reference format pattern to figure out if it's using 24h clock
106 reference_time_pattern: str = locale.time_formats["medium"].pattern
108 # Remove literals like 'of'
109 reference_time_pattern = re.sub("'[^']*'", "", reference_time_pattern)
111 # Extract only the hours, minutes and am/pm patterns.
112 requested_skeleton = re.sub("[^hHkKma]+", "", reference_time_pattern)
113 if with_seconds:
114 requested_skeleton += "ss"
116 # Next, match that skeleton to a similar locale-supported skeleton,
117 # which allows us to lower it to a datetime pattern (locale and order-specific).
118 matched_skeleton = match_skeleton(requested_skeleton, options=locale.datetime_skeletons)
119 if not matched_skeleton: 119 ↛ 120line 119 didn't jump to line 120 because the condition on line 119 was never true
120 raise ValueError(f"Locale {locale.english_name} has no matching datetime skeleton for '{requested_skeleton}'")
122 return cast(str, locale.datetime_skeletons[matched_skeleton].pattern) # "pattern" is Any-typed
125def _combine_cldr_date_time_patterns(locale: babel.Locale, date_pattern: str, time_pattern: str) -> str:
126 # get_datetime_format's return value is statically mistyped
127 combining_format = cast(str, get_datetime_format(locale=locale))
129 # CLDR defines {0} to be the time and {1} to be the date
130 return combining_format.replace("{1}", date_pattern).replace("{0}", time_pattern)
133def localize_timezone(timezone: tzinfo, locale: babel.Locale, *, short: bool = False) -> str:
134 return get_timezone_name(timezone, width="short" if short else "long", locale=locale)
137def format_phone_number(value: str) -> str:
138 """Formats a phone number from E.164 format to the international format."""
139 return phonenumbers.format_number(phonenumbers.parse(value), phonenumbers.PhoneNumberFormat.INTERNATIONAL)