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