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

79 statements  

« prev     ^ index     » next       coverage.py v7.15.3, created at 2026-08-04 22:32 +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 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.resources import get_region_code_iso3166_alpha3_to_alpha2 

17 

18 

19def localize_list(items: Sequence[str], locales: list[babel.Locale]) -> str: 

20 for locale in locales: 20 ↛ 25line 20 didn't jump to line 25 because the loop on line 20 didn't complete

21 try: 

22 return format_list(items, locale=locale) 

23 except ValueError: # Raised if the locale doesn't support list formatting 

24 continue 

25 return format_list(items, locale=babel.Locale.parse("en")) 

26 

27 

28def try_localize_language_name_from_iso639( 

29 code: str, locales: list[babel.Locale], standalone: bool = False 

30) -> str | None: 

31 """ 

32 Attempts to localize the name of a language expressed as an ISO639 code. 

33 

34 Args: 

35 code: The ISO639 language code. 

36 locales: The acceptable locales to render the language name in. 

37 standalone: The result won't be part of a larger sentence and should be capitalized if the language has capitals. 

38 

39 Returns: 

40 The localized name, or None if no localized name is available. 

41 """ 

42 for locale in locales: 

43 try: 

44 name = babel.Locale.parse(code).get_language_name(locale) 

45 if name is None: 45 ↛ 46line 45 didn't jump to line 46 because the condition on line 45 was never true

46 continue 

47 if standalone: 

48 # The Unicode CLDR returns a casing that allows embedding in a larger sentence, e.g. "español". 

49 # If we're displaying the language name on its own, capitalize its first letter if applicable. 

50 # An LLM prompt revealed that this holds for all major languages. 

51 # It is a no-op for scripts that don't have capital letters. 

52 name = name[:1].title() + name[1:] 

53 return name 

54 except ValueError, babel.UnknownLocaleError: 

55 continue 

56 return None 

57 

58 

59def try_localize_region_name_from_iso3166(code: str, locales: list[babel.Locale]) -> str | None: 

60 """ 

61 Gets a region name specified as an ISO3166 alpha2 or alpha3 code, 

62 localized in the first acceptable locale provided. 

63 """ 

64 # The Unicode CLDR uses alpha2 codes as keys (all alpha3 codes have a corresponding alpha2 code) 

65 code = get_region_code_iso3166_alpha3_to_alpha2().get(code, code) 

66 for locale in locales: 

67 region_name: str | None = locale.territories.get(code, None) 

68 if region_name is not None: 

69 return region_name 

70 return None 

71 

72 

73def localize_date( 

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

75) -> str: 

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

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

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

79 

80 

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

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

83 pattern = _get_cldr_time_pattern(locale, with_seconds=with_seconds) 

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

85 

86 

87def localize_datetime( 

88 value: datetime, 

89 locale: babel.Locale, 

90 *, 

91 abbrev: bool = False, 

92 with_year: bool = True, 

93 with_day_of_week: bool = False, 

94 with_seconds: bool = False, 

95) -> str: 

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

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

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

99 

100 pattern = _combine_cldr_date_time_patterns( 

101 locale, 

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

103 _get_cldr_time_pattern(locale, with_seconds=with_seconds), 

104 ) 

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

106 

107 

108def _get_cldr_date_pattern( 

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

110) -> str: 

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

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

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

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

115 requested_skeleton = "" 

116 

117 if with_year: 

118 requested_skeleton += "y" 

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

120 requested_skeleton += "d" 

121 if with_day_of_week: 

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

123 

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

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

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

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

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

129 

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

131 

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

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

134 if not abbrev: 

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

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

137 if with_day_of_week: 

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

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

140 

141 return pattern 

142 

143 

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

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

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

147 

148 # Remove literals like 'of' 

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

150 

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

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

153 if with_seconds: 

154 requested_skeleton += "ss" 

155 

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

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

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

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

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

161 

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

163 

164 

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

166 # get_datetime_format's return value is statically mistyped 

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

168 

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

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

171 

172 

173def localize_timezone(timezone: tzinfo, locales: list[babel.Locale], *, short: bool = False) -> str: 

174 # From the implementation, get_timezone_name has no failure condition for unsupported locales, 

175 # so just used the preferred locale. 

176 return get_timezone_name(timezone, width="short" if short else "long", locale=locales[0]) 

177 

178 

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

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

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