Coverage for app/backend/src/couchers/i18n/localize.py: 93%

57 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-06-28 00:29 +0000

1""" 

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

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

4""" 

5 

6import re 

7from collections.abc import Mapping, Sequence 

8from datetime import date, datetime, time, tzinfo 

9from typing import cast 

10 

11import babel 

12import phonenumbers 

13from babel.dates import get_datetime_format, get_timezone_name, match_skeleton, parse_pattern 

14from babel.lists import format_list 

15 

16from couchers.i18n.locales import DEFAULT_LOCALE, get_main_i18next 

17 

18 

19def localize_string(lang: str | None, key: str, *, substitutions: Mapping[str, str | int] | None = None) -> str: 

20 """ 

21 Retrieves a translated string and performs substitutions. 

22 

23 Args: 

24 lang: Language code (e.g., "en", "pt-BR"). If None, defaults to the default fallback language ("en") 

25 key: The key for the string to be looked up. 

26 substitutions: Dictionary of variable substitutions for the string (e.g., {"hours": 24}) 

27 

28 Returns: 

29 The translated string with substitutions applied 

30 """ 

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

32 

33 

34def localize_list(items: Sequence[str], locale: babel.Locale) -> str: 

35 return format_list(items, locale=locale) 

36 

37 

38def localize_date( 

39 value: date, locale: babel.Locale, *, abbrev: bool = False, with_year: bool = True, with_day_of_week: bool = False 

40) -> str: 

41 """Formats a time- and timezone-agnostic date for the given locale.""" 

42 pattern = _get_cldr_date_pattern(locale, abbrev=abbrev, with_year=with_year, with_day_of_week=with_day_of_week) 

43 return parse_pattern(pattern).apply(value, locale) 

44 

45 

46def localize_time(value: time, locale: babel.Locale, *, with_seconds: bool = False) -> str: 

47 """Formats a date- and timezone-agnostic time for the given locale.""" 

48 pattern = _get_cldr_time_pattern(locale, with_seconds=with_seconds) 

49 return parse_pattern(pattern).apply(value, locale) 

50 

51 

52def localize_datetime( 

53 value: datetime, 

54 locale: babel.Locale, 

55 *, 

56 abbrev: bool = False, 

57 with_year: bool = True, 

58 with_day_of_week: bool = False, 

59 with_seconds: bool = False, 

60) -> str: 

61 """Formats a date and time for the given locale.""" 

62 # A timezone-unaware datetime is almost certainly a bug, so we don't support it. 

63 assert value.tzinfo is not None, "Cannot localize a timezone-unaware datetime." 

64 

65 pattern = _combine_cldr_date_time_patterns( 

66 locale, 

67 _get_cldr_date_pattern(locale, abbrev=abbrev, with_year=with_year, with_day_of_week=with_day_of_week), 

68 _get_cldr_time_pattern(locale, with_seconds=with_seconds), 

69 ) 

70 return parse_pattern(pattern).apply(value, locale) 

71 

72 

73def _get_cldr_date_pattern( 

74 locale: babel.Locale, *, abbrev: bool = False, with_year: bool = True, with_day_of_week: bool = False 

75) -> str: 

76 # First build a Unicode CLDR datetime pattern skeleton, which is locale and order-agnostic, 

77 # and only indicates the components we're interested in formatting. 

78 # This is similar to Intl.DateTimeFormat in Javascript. 

79 # See https://cldr.unicode.org/translation/date-time/date-time-symbols. 

80 requested_skeleton = "" 

81 

82 if with_year: 

83 requested_skeleton += "y" 

84 requested_skeleton += "MMM" if abbrev else "MMMM" 

85 requested_skeleton += "d" 

86 if with_day_of_week: 

87 requested_skeleton += "EEE" if abbrev else "EEEE" 

88 

89 # Next, match that skeleton to a similar locale-supported skeleton, 

90 # which allows us to lower it to a datetime pattern (locale and order-specific). 

91 matched_skeleton = match_skeleton(requested_skeleton, options=locale.datetime_skeletons) 

92 if not matched_skeleton: 92 ↛ 93line 92 didn't jump to line 93 because the condition on line 92 was never true

93 raise ValueError(f"Locale {locale.english_name} has no matching datetime skeleton for '{requested_skeleton}'") 

94 

95 pattern: str = locale.datetime_skeletons[matched_skeleton].pattern 

96 

97 # By CLDR rules, skeleton matching might return a pattern with abbreviations where 

98 # we asked for non-abbreviated forms, in which case we can update the returned pattern. 

99 if not abbrev: 

100 # Abbreviated to non-abbreviated month (MMM = abbreviated) 

101 pattern = re.sub(r"(?<!M)MMM(?!M)", "MMMM", pattern) 

102 if with_day_of_week: 

103 # Abbreviated to non-abbreviated day of week (E = EEE = abbreviated) 

104 pattern = re.sub(r"(?<!E)E{1,3}(?!E)", "EEEE", pattern) 

105 

106 return pattern 

107 

108 

109def _get_cldr_time_pattern(locale: babel.Locale, *, with_seconds: bool = False) -> str: 

110 # Use a reference format pattern to figure out if it's using 24h clock 

111 reference_time_pattern: str = locale.time_formats["medium"].pattern 

112 

113 # Remove literals like 'of' 

114 reference_time_pattern = re.sub("'[^']*'", "", reference_time_pattern) 

115 

116 # Extract only the hours, minutes and am/pm patterns. 

117 requested_skeleton = re.sub("[^hHkKma]+", "", reference_time_pattern) 

118 if with_seconds: 

119 requested_skeleton += "ss" 

120 

121 # Next, match that skeleton to a similar locale-supported skeleton, 

122 # which allows us to lower it to a datetime pattern (locale and order-specific). 

123 matched_skeleton = match_skeleton(requested_skeleton, options=locale.datetime_skeletons) 

124 if not matched_skeleton: 124 ↛ 125line 124 didn't jump to line 125 because the condition on line 124 was never true

125 raise ValueError(f"Locale {locale.english_name} has no matching datetime skeleton for '{requested_skeleton}'") 

126 

127 return cast(str, locale.datetime_skeletons[matched_skeleton].pattern) # "pattern" is Any-typed 

128 

129 

130def _combine_cldr_date_time_patterns(locale: babel.Locale, date_pattern: str, time_pattern: str) -> str: 

131 # get_datetime_format's return value is statically mistyped 

132 combining_format = cast(str, get_datetime_format(locale=locale)) 

133 

134 # CLDR defines {0} to be the time and {1} to be the date 

135 return combining_format.replace("{1}", date_pattern).replace("{0}", time_pattern) 

136 

137 

138def localize_timezone(timezone: tzinfo, locale: babel.Locale, *, short: bool = False) -> str: 

139 return get_timezone_name(timezone, width="short" if short else "long", locale=locale) 

140 

141 

142def format_phone_number(value: str) -> str: 

143 """Formats a phone number from E.164 format to the international format.""" 

144 return phonenumbers.format_number(phonenumbers.parse(value), phonenumbers.PhoneNumberFormat.INTERNATIONAL)