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

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, tzinfo 

8from functools import lru_cache 

9from pathlib import Path 

10 

11import phonenumbers 

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

13from google.protobuf.timestamp_pb2 import Timestamp 

14 

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 

18 

19 

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

24 

25 

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. 

29 

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

34 

35 Returns: 

36 The translated string with substitutions applied 

37 """ 

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

39 

40 

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 ) 

48 

49 

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) 

53 

54 

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 ) 

62 

63 

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

65 """ 

66 Formats a date and time for the given locale. 

67 

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. 

72 

73 Returns: 

74 The localized date and time string. 

75 """ 

76 if isinstance(value, Timestamp): 

77 value = to_aware_datetime(value) 

78 

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

81 

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) 

84 

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 ) 

90 

91 

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 ) 

98 

99 

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 

113 

114 if fallback: 

115 return fallback() 

116 

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

118 

119 

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)