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

1""" 

2Defines low-level localization functions for strings, dates, etc. 

3Most code should use the higher-level couchers.i18n.LocalizationContext object. 

4""" 

5 

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 

11 

12import phonenumbers 

13from babel.dates import format_date, format_datetime, format_time, get_timezone_name 

14from google.protobuf.timestamp_pb2 import Timestamp 

15 

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 

19 

20 

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") 

25 

26 

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. 

30 

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}) 

35 

36 Returns: 

37 The translated string with substitutions applied 

38 """ 

39 return get_main_i18next().localize(key, lang or DEFAULT_LOCALE, substitutions) 

40 

41 

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 ) 

49 

50 

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) 

54 

55 

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 ) 

63 

64 

65def localize_datetime(value: datetime | Timestamp, timezone: ZoneInfo | None, locale: str) -> str: 

66 """ 

67 Formats a date and time for the given locale. 

68 

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. 

73 

74 Returns: 

75 The localized date and time string. 

76 """ 

77 if isinstance(value, Timestamp): 

78 value = to_aware_datetime(value) 

79 

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." 

82 

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) 

85 

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 ) 

91 

92 

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 ) 

99 

100 

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 

114 

115 if fallback: 

116 return fallback() 

117 

118 raise exception or Exception(f"Failed to localize to {locale}.") 

119 

120 

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)