Coverage for app/backend/src/couchers/i18n/i18next.py: 88%

130 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-21 21:52 +0000

1""" 

2Implements localizing strings stored in the i18next json format. 

3""" 

4 

5import re 

6from collections.abc import Mapping 

7from dataclasses import dataclass 

8from html import escape, unescape 

9from typing import Any 

10 

11import babel 

12from markupsafe import Markup 

13 

14PLURALIZABLE_VARIABLE_NAME = "count" 

15"""Special variable name for which i18next supports pluralization forms.""" 

16 

17 

18# A dictionary of values to substitute placeholders with. 

19# str: Default case, will be escaped if localizing with markup. 

20# int: Supports plurals. 

21# Markup: Will be unescaped if localizing without markup. 

22SubstitutionDict = Mapping[str, str | int | Markup] 

23 

24 

25class I18Next: 

26 """Retrieves translated strings from their keys based on the i18next format.""" 

27 

28 def __init__(self) -> None: 

29 self.translations_by_locale: dict[str, Translation] = dict() 

30 

31 def add_translation(self, locale: str, *, json_dict: dict[str, Any] | None = None) -> Translation: 

32 translation = Translation(babel_locale=babel.Locale.parse(locale, sep="-")) 

33 self.translations_by_locale[locale] = translation 

34 if json_dict: 

35 translation.load_json_dict(json_dict) 

36 return translation 

37 

38 def find_string(self, key: str, locales: list[str], substitutions: SubstitutionDict | None = None) -> String | None: 

39 """Find the string that will be localized, trying each locale in order.""" 

40 for locale in locales: 

41 if t := self.translations_by_locale.get(locale): 

42 if string := t.find_string(key, substitutions): 

43 if string.template.can_render(substitutions): 

44 return string 

45 

46 raise LocalizationError(locales, key) 

47 

48 def localize(self, string_key: str, locales: list[str], substitutions: SubstitutionDict | None = None) -> str: 

49 """Finds a translated string in the best matching locale and performs substitutions.""" 

50 if string := self.find_string(string_key, locales, substitutions): 50 ↛ 53line 50 didn't jump to line 53 because the condition on line 50 was always true

51 return string.render(substitutions) 

52 else: 

53 raise LocalizationError(locales, string_key) 

54 

55 def localize_with_markup( 

56 self, string_key: str, locales: list[str], substitutions: SubstitutionDict | None = None 

57 ) -> Markup: 

58 """ 

59 Finds a translated string that might contain markup in the best matching locale, 

60 and performs substitutions in a way that results in a markup-safe string. 

61 """ 

62 if string := self.find_string(string_key, locales, substitutions): 62 ↛ 65line 62 didn't jump to line 65 because the condition on line 62 was always true

63 return string.render_with_markup(substitutions) 

64 else: 

65 raise LocalizationError(locales, string_key) 

66 

67 

68class Translation: 

69 """A set of translated strings for a locale.""" 

70 

71 def __init__(self, *, babel_locale: babel.Locale) -> None: 

72 # The Babel library locale for this translation. Used to resolved plural forms. 

73 self.babel_locale = babel_locale 

74 self.strings_by_key: dict[str, String] = dict() 

75 

76 @property 

77 def locale(self) -> str: 

78 return str(self.babel_locale).replace("_", "-") 

79 

80 def load_json_dict(self, json_dict: Mapping[str, Any]) -> None: 

81 def add_strings(json_dict: Mapping[str, Any], key_prefix: str | None) -> None: 

82 for k, v in json_dict.items(): 

83 full_key = f"{key_prefix}.{k}" if key_prefix else k 

84 if isinstance(v, str): 

85 self.add_string(full_key, v) 

86 elif isinstance(v, dict): 86 ↛ 89line 86 didn't jump to line 89 because the condition on line 86 was always true

87 add_strings(v, key_prefix=full_key) 

88 else: 

89 raise ValueError(f"Unexpected value type in locale JSON: {type(v)}") 

90 

91 add_strings(json_dict, key_prefix=None) 

92 

93 def add_string(self, key: str, value: str) -> None: 

94 # Weblate and i18next consider an empty string as the absence of a translation. 

95 if value: 

96 self.strings_by_key[key] = String(key, StringTemplate.parse(value)) 

97 

98 def find_string(self, key: str, substitutions: SubstitutionDict | None = None) -> String | None: 

99 # if we have a numerical "count" substitution, 

100 # i18next will first search for a key with a suffix 

101 # based on the plural category suggested by the count 

102 # according to the current locale's rules. 

103 if substitutions: 

104 if count := substitutions.get(PLURALIZABLE_VARIABLE_NAME): 

105 if isinstance(count, int): 105 ↛ 109line 105 didn't jump to line 109 because the condition on line 105 was always true

106 plural_key = key + "_" + self.babel_locale.plural_form(count) 

107 if string := self.strings_by_key.get(plural_key): 

108 return string 

109 return self.strings_by_key.get(key) 

110 

111 def localize(self, string_key: str, substitutions: SubstitutionDict | None = None) -> str: 

112 string = self.find_string(string_key, substitutions) 

113 if string is None: 

114 raise LocalizationError(locales=[self.locale], string_key=string_key) 

115 return string.render(substitutions) 

116 

117 def localize_with_markup(self, string_key: str, substitutions: SubstitutionDict | None = None) -> Markup: 

118 string = self.find_string(string_key, substitutions) 

119 if string is None: 

120 raise LocalizationError(locales=[self.locale], string_key=string_key) 

121 return string.render_with_markup(substitutions) 

122 

123 

124@dataclass(frozen=True, slots=True) 

125class String: 

126 """An i18next string key + template pair.""" 

127 

128 key: str 

129 template: StringTemplate 

130 

131 def render(self, substitutions: SubstitutionDict | None) -> str: 

132 return self.template.render(substitutions) 

133 

134 def render_with_markup(self, substitutions: SubstitutionDict | None) -> Markup: 

135 return self.template.render_with_markup(substitutions) 

136 

137 

138@dataclass(frozen=True, slots=True) 

139class StringTemplate: 

140 """A string value which may contain variable placeholders.""" 

141 

142 segments: list[StringSegment] 

143 

144 def can_render(self, substitutions: SubstitutionDict | None) -> bool: 

145 for segment in self.segments: 

146 if segment.is_variable: 

147 if not substitutions or substitutions.get(segment.text) is None: 

148 return False 

149 return True 

150 

151 def render(self, substitutions: SubstitutionDict | None) -> str: 

152 return self._render(substitutions, with_markup=False) 

153 

154 def render_with_markup(self, substitutions: SubstitutionDict | None) -> Markup: 

155 return Markup(self._render(substitutions, with_markup=True)) 

156 

157 def _render(self, substitutions: SubstitutionDict | None, with_markup: bool) -> str: 

158 substrings: list[str] = [] 

159 for segment in self.segments: 

160 if segment.is_variable: 

161 if substitutions is not None and segment.text in substitutions: 161 ↛ 173line 161 didn't jump to line 173 because the condition on line 161 was always true

162 value = substitutions[segment.text] 

163 if with_markup and not isinstance(value, Markup) and not isinstance(value, int): 

164 # Auto-escape since we're producing markup 

165 substrings.append(escape(value)) 

166 elif isinstance(value, Markup) and not with_markup: 

167 # We're producing a string where markup is not evaluated 

168 # so resolve escape sequences like " 

169 substrings.append(unescape(value)) 

170 else: 

171 substrings.append(str(value)) 

172 else: 

173 raise ValueError(f"Missing substitution for variable '{segment.text}'") 

174 else: 

175 substrings.append(segment.text) 

176 return "".join(substrings) 

177 

178 @staticmethod 

179 def parse(value: str) -> StringTemplate: 

180 last_index = 0 

181 segments: list[StringSegment] = [] 

182 for match in re.finditer(r"\{\{\s*([^\}]+?)\s*\}\}", value): 

183 if match.start() > last_index: 

184 segments.append(StringSegment(text=value[last_index : match.start()], is_variable=False)) 

185 segments.append(StringSegment(text=match.group(1), is_variable=True)) 

186 last_index = match.end() 

187 if last_index < len(value): 

188 segments.append(StringSegment(text=value[last_index:], is_variable=False)) 

189 return StringTemplate(segments) 

190 

191 

192@dataclass(frozen=True, slots=True) 

193class StringSegment: 

194 """Either a literal text segment or a variable placeholder.""" 

195 

196 text: str 

197 is_variable: bool = False 

198 

199 

200class LocalizationError(Exception): 

201 """Raised failing to localize a string, e.g. if it is not found in any of the given locales.""" 

202 

203 def __init__(self, locales: list[str], string_key: str): 

204 self.locales = locales 

205 self.string_key = string_key 

206 super().__init__(f"Could not localize string {string_key} for locales {locales}") 

207 

208 

209def full_string_key(key: str, *, relative_base: str | None) -> str: 

210 """Resolves any relative string key (starting with '.') into a full string key.""" 

211 if key.startswith("."): 

212 if relative_base is None: 

213 raise ValueError("Relative string key requires a relative base.") 

214 return relative_base + key 

215 return key