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

133 statements  

« prev     ^ index     » next       coverage.py v7.14.2, created at 2026-06-21 09:29 +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 # The translation used to look up strings in unsupported locales. 

32 self.default_translation: Translation | None = None 

33 

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

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

36 self.translations_by_locale[locale] = translation 

37 if json_dict: 

38 translation.load_json_dict(json_dict) 

39 return translation 

40 

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

42 """Find the string that will be localized, applying fallbacks and variant selection.""" 

43 translation = self.translations_by_locale.get(locale, self.default_translation) 

44 candidate_translations = [translation] + translation.fallbacks if translation else [] 

45 for candidate_translation in candidate_translations: 

46 if string := candidate_translation.find_string(key, substitutions): 

47 if string.template.can_render(substitutions): 

48 return string 

49 

50 raise LocalizationError(locale, key) 

51 

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

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

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

55 return string.render(substitutions) 

56 else: 

57 raise LocalizationError(locale, string_key) 

58 

59 def localize_with_markup( 

60 self, string_key: str, locale: str, substitutions: SubstitutionDict | None = None 

61 ) -> Markup: 

62 """ 

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

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

65 """ 

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

67 return string.render_with_markup(substitutions) 

68 else: 

69 raise LocalizationError(locale, string_key) 

70 

71 

72class Translation: 

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

74 

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

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

77 self.babel_locale = babel_locale 

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

79 self.fallbacks: list[Translation] = [] 

80 

81 @property 

82 def locale(self) -> str: 

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

84 

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

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

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

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

89 if isinstance(v, str): 

90 self.add_string(full_key, v) 

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

92 add_strings(v, key_prefix=full_key) 

93 else: 

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

95 

96 add_strings(json_dict, key_prefix=None) 

97 

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

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

100 if value: 

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

102 

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

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

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

106 # based on the plural category suggested by the count 

107 # according to the current locale's rules. 

108 if substitutions: 

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

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

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

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

113 return string 

114 return self.strings_by_key.get(key) 

115 

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

117 string = self.find_string(string_key, substitutions) 

118 if string is None: 

119 raise LocalizationError(locale=self.locale, string_key=string_key) 

120 return string.render(substitutions) 

121 

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

123 string = self.find_string(string_key, substitutions) 

124 if string is None: 

125 raise LocalizationError(locale=self.locale, string_key=string_key) 

126 return string.render_with_markup(substitutions) 

127 

128 

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

130class String: 

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

132 

133 key: str 

134 template: StringTemplate 

135 

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

137 return self.template.render(substitutions) 

138 

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

140 return self.template.render_with_markup(substitutions) 

141 

142 

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

144class StringTemplate: 

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

146 

147 segments: list[StringSegment] 

148 

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

150 for segment in self.segments: 

151 if segment.is_variable: 

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

153 return False 

154 return True 

155 

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

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

158 

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

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

161 

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

163 substrings: list[str] = [] 

164 for segment in self.segments: 

165 if segment.is_variable: 

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

167 value = substitutions[segment.text] 

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

169 # Auto-escape since we're producing markup 

170 substrings.append(escape(value)) 

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

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

173 # so resolve escape sequences like " 

174 substrings.append(unescape(value)) 

175 else: 

176 substrings.append(str(value)) 

177 else: 

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

179 else: 

180 substrings.append(segment.text) 

181 return "".join(substrings) 

182 

183 @staticmethod 

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

185 last_index = 0 

186 segments: list[StringSegment] = [] 

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

188 if match.start() > last_index: 

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

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

191 last_index = match.end() 

192 if last_index < len(value): 

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

194 return StringTemplate(segments) 

195 

196 

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

198class StringSegment: 

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

200 

201 text: str 

202 is_variable: bool = False 

203 

204 

205class LocalizationError(Exception): 

206 """Raised failing to localize a string, e.g. if it is not found in any fallback locale.""" 

207 

208 def __init__(self, locale: str, string_key: str): 

209 self.locale = locale 

210 self.string_key = string_key 

211 super().__init__(f"Could not localize string {string_key} for locale {locale}") 

212 

213 

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

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

216 if key.startswith("."): 

217 if relative_base is None: 

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

219 return relative_base + key 

220 return key