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

54 statements  

« prev     ^ index     » next       coverage.py v7.14.2, created at 2026-06-21 09: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 

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 

14 

15from couchers.i18n.locales import DEFAULT_LOCALE, get_main_i18next 

16 

17 

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

19 """ 

20 Retrieves a translated string and performs substitutions. 

21 

22 Args: 

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

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

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

26 

27 Returns: 

28 The translated string with substitutions applied 

29 """ 

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

31 

32 

33def localize_date( 

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

35) -> str: 

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

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

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

39 

40 

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

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

43 pattern = _get_cldr_time_pattern(locale, with_seconds=with_seconds) 

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

45 

46 

47def localize_datetime( 

48 value: datetime, 

49 locale: babel.Locale, 

50 *, 

51 abbrev: bool = False, 

52 with_year: bool = True, 

53 with_day_of_week: bool = False, 

54 with_seconds: bool = False, 

55) -> str: 

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

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

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

59 

60 pattern = _combine_cldr_date_time_patterns( 

61 locale, 

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

63 _get_cldr_time_pattern(locale, with_seconds=with_seconds), 

64 ) 

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

66 

67 

68def _get_cldr_date_pattern( 

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

70) -> str: 

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

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

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

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

75 requested_skeleton = "" 

76 

77 if with_year: 

78 requested_skeleton += "y" 

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

80 requested_skeleton += "d" 

81 if with_day_of_week: 

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

83 

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

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

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

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

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

89 

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

91 

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

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

94 if not abbrev: 

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

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

97 if with_day_of_week: 

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

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

100 

101 return pattern 

102 

103 

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

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

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

107 

108 # Remove literals like 'of' 

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

110 

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

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

113 if with_seconds: 

114 requested_skeleton += "ss" 

115 

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

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

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

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

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

121 

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

123 

124 

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

126 # get_datetime_format's return value is statically mistyped 

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

128 

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

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

131 

132 

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

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

135 

136 

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

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

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