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

103 statements  

« prev     ^ index     » next       coverage.py v7.13.2, created at 2026-02-03 06:18 +0000

1""" 

2Implements localizing strings stored in the i18next json format. 

3""" 

4 

5import re 

6from collections.abc import Mapping 

7from dataclasses import dataclass, field 

8from typing import Any 

9 

10from couchers.i18n.plurals import PluralRule 

11 

12PLURALIZABLE_VARIABLE_NAME = "count" 

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

14 

15 

16@dataclass 

17class I18Next: 

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

19 

20 languages_by_code: dict[str, Language] = field(default_factory=dict) 

21 default_language: Language | None = None 

22 """The language used to look up strings in unsupported languages.""" 

23 

24 def add_language(self, code: str, plural_rule: PluralRule, *, json_dict: dict[str, Any] | None = None) -> Language: 

25 language = Language(code, plural_rule) 

26 self.languages_by_code[code] = language 

27 if json_dict: 27 ↛ 28line 27 didn't jump to line 28 because the condition on line 27 was never true

28 language.load_json_dict(json_dict) 

29 return language 

30 

31 def find_string( 

32 self, key: str, language_code: str, substitutions: Mapping[str, str | int] | None = None 

33 ) -> String | None: 

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

35 language = self.languages_by_code.get(language_code, self.default_language) 

36 candidate_languages = [language] + language.fallbacks if language else [] 

37 for candidate_language in candidate_languages: 

38 if string := candidate_language.find_string(key, substitutions): 

39 if string.template.can_render(substitutions): 

40 return string 

41 

42 raise LocalizationError(language_code, key) 

43 

44 def localize( 

45 self, string_key: str, language_code: str, substitutions: Mapping[str, str | int] | None = None 

46 ) -> str: 

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

48 return string.render(substitutions) 

49 else: 

50 raise LocalizationError(language_code, string_key) 

51 

52 

53@dataclass 

54class Language: 

55 """A set of translated strings for a language.""" 

56 

57 code: str 

58 """The language code, e.g. 'en'""" 

59 plural_rule: PluralRule 

60 """The rule for plurals in this language.""" 

61 strings_by_key: dict[str, String] = field(default_factory=dict) 

62 fallbacks: list[Language] = field(default_factory=list) 

63 

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

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

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

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

68 if isinstance(v, str): 

69 self.add_string(full_key, v) 

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

71 add_strings(v, key_prefix=full_key) 

72 else: 

73 raise ValueError(f"Unexpected value type in language JSON: {type(v)}") 

74 

75 add_strings(json_dict, key_prefix=None) 

76 

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

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

79 

80 def find_string(self, key: str, substitutions: Mapping[str, str | int] | None = None) -> String | None: 

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

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

83 # based on the plural category suggested by the count 

84 # according to the current language's rules. 

85 if substitutions: 

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

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

88 plural_category = self.plural_rule(count) 

89 plural_key = key + "_" + plural_category.value 

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

91 return string 

92 return self.strings_by_key.get(key) 

93 

94 def localize(self, string_key: str, substitutions: Mapping[str, str | int] | None = None) -> str: 

95 string = self.find_string(string_key, substitutions) 

96 if string is None: 

97 raise LocalizationError(language_code=self.code, string_key=string_key) 

98 return string.render(substitutions) 

99 

100 

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

102class String: 

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

104 

105 key: str 

106 template: StringTemplate 

107 

108 def render(self, substitutions: Mapping[str, str | int] | None) -> str: 

109 return self.template.render(substitutions) 

110 

111 

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

113class StringTemplate: 

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

115 

116 segments: list[StringSegment] 

117 

118 def can_render(self, substitutions: Mapping[str, str | int] | None) -> bool: 

119 for segment in self.segments: 

120 if segment.is_variable: 

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

122 return False 

123 return True 

124 

125 def render(self, substitutions: Mapping[str, str | int] | None) -> str: 

126 substrings: list[str] = [] 

127 for segment in self.segments: 

128 if segment.is_variable: 

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

130 substrings.append(str(substitutions[segment.text])) 

131 else: 

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

133 else: 

134 substrings.append(segment.text) 

135 return "".join(substrings) 

136 

137 @staticmethod 

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

139 last_index = 0 

140 segments: list[StringSegment] = [] 

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

142 if match.start() > last_index: 

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

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

145 last_index = match.end() 

146 if last_index < len(value): 

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

148 return StringTemplate(segments) 

149 

150 

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

152class StringSegment: 

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

154 

155 text: str 

156 is_variable: bool = False 

157 

158 

159class LocalizationError(Exception): 

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

161 

162 def __init__(self, language_code: str, string_key: str): 

163 self.language_code = language_code 

164 self.string_key = string_key 

165 super().__init__(f"Could not localize string {string_key} for language {language_code}")