Coverage for app / backend / src / couchers / i18n / localize.py: 78%
42 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-05 09:44 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-05 09:44 +0000
1"""
2Defines low-level localization functions for strings, dates, etc.
3Most code should use the higher-level couchers.i18n.LocalizationContext object.
4"""
6from collections.abc import Callable, Mapping
7from datetime import date, datetime, time, tzinfo
8from functools import lru_cache
9from pathlib import Path
11import phonenumbers
12from babel.dates import format_date, format_datetime, format_time, get_timezone_name
13from google.protobuf.timestamp_pb2 import Timestamp
15from couchers.i18n.i18next import I18Next
16from couchers.i18n.locales import DEFAULT_LOCALE, get_locale_fallbacks, load_locales
17from couchers.utils import to_aware_datetime
20@lru_cache(maxsize=1)
21def get_main_i18next() -> I18Next:
22 """Gets the I18Next instance for the main locales files."""
23 return load_locales(Path(__file__).parent / "locales")
26def localize_string(lang: str | None, key: str, *, substitutions: Mapping[str, str | int] | None = None) -> str:
27 """
28 Retrieves a translated string and performs substitutions.
30 Args:
31 lang: Language code (e.g., "en", "pt-BR"). If None, defaults to the default fallback language ("en")
32 key: The key for the string to be looked up.
33 substitutions: Dictionary of variable substitutions for the string (e.g., {"hours": 24})
35 Returns:
36 The translated string with substitutions applied
37 """
38 return get_main_i18next().localize(key, lang or DEFAULT_LOCALE, substitutions)
41def localize_date(value: date, locale: str) -> str:
42 """Formats a time- and timezone-agnostic date for the given locale."""
43 return _localize_with_fallbacks(
44 locale,
45 lambda candidate_locale: format_date(value, locale=candidate_locale),
46 lambda: value.strftime("%A %-d %B %Y"),
47 )
50def localize_date_from_iso(value: str, locale: str) -> str:
51 """Formats a date in ISO YYYY-MM-DD format for the given locale."""
52 return localize_date(date.fromisoformat(value), locale)
55def localize_time(value: time, locale: str) -> str:
56 """Formats a date- and timezone-agnostic time for the given locale."""
57 return _localize_with_fallbacks(
58 locale,
59 lambda candidate_locale: format_time(value, locale=candidate_locale),
60 lambda: value.strftime("%-I:%M %p (%H:%M)"),
61 )
64def localize_datetime(value: datetime | Timestamp, timezone: tzinfo | None, locale: str) -> str:
65 """
66 Formats a date and time for the given locale.
68 Args:
69 datetime: The datetime or timestamp to be formatted.
70 timezone: An optional timezone in which to interpret the date. If None, uses datetime's timezone.
71 locale: The locale for which to format the date.
73 Returns:
74 The localized date and time string.
75 """
76 if isinstance(value, Timestamp):
77 value = to_aware_datetime(value)
79 # A timezone-unaware datetime is almost certainly a bug, so we don't support it.
80 assert value.tzinfo is not None, "Cannot localize a timezone-unaware datetime."
82 if timezone is not None: 82 ↛ 85line 82 didn't jump to line 85 because the condition on line 82 was always true
83 value = value.astimezone(timezone)
85 return _localize_with_fallbacks(
86 locale,
87 lambda candidate_locale: format_datetime(value, locale=candidate_locale),
88 lambda: localize_date(value.date(), locale) + " " + localize_time(value.time(), locale),
89 )
92def localize_timezone(timezone: tzinfo, locale: str) -> str:
93 return _localize_with_fallbacks(
94 locale,
95 lambda candidate_locale: get_timezone_name(timezone, locale=candidate_locale),
96 lambda: datetime.now(tz=timezone).strftime("%Z/UTC%z"),
97 )
100def _localize_with_fallbacks(
101 locale: str, localize: Callable[[str], str], fallback: Callable[[], str] | None = None
102) -> str:
103 """
104 Attempts to localize a value using fallback locales if the locale is unavailable, or a final unlocalized fallback.
105 """
106 exception: Exception | None
107 for candidate_locale in [locale] + get_locale_fallbacks(locale): 107 ↛ 114line 107 didn't jump to line 114 because the loop on line 107 didn't complete
108 try:
109 return localize(candidate_locale.replace("-", "_")) # Babel expects "en_US", not "en-US".
110 except Exception as e:
111 exception = e
112 pass
114 if fallback:
115 return fallback()
117 raise exception or Exception(f"Failed to localize to {locale}.")
120def format_phone_number(value: str) -> str:
121 """Formats a phone number from E.164 format to the international format."""
122 return phonenumbers.format_number(phonenumbers.parse(value), phonenumbers.PhoneNumberFormat.INTERNATIONAL)