Coverage for src/couchers/i18n/i18next.py: 94%

109 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-12-25 10:58 +0000

1""" 

2Implements localizing strings stored in the i18next json format. 

3""" 

4 

5import re 

6from dataclasses import dataclass, field 

7 

8from couchers.i18n.plurals import PluralRule 

9 

10PLURALIZABLE_VARIABLE_NAME = "count" 

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

12 

13 

14@dataclass 

15class I18Next: 

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

17 

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

19 fallback_language: "Language | None" = None 

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

21 

22 def add_language(self, code: str, plural_rule: PluralRule) -> "Language": 

23 language = Language(code, plural_rule) 

24 self.languages_by_code[code] = language 

25 return language 

26 

27 def find_string( 

28 self, key: str, language_code: str, substitutions: dict[str, str | int] | None = None 

29 ) -> "String | None": 

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

31 language = self.languages_by_code.get(language_code, self.fallback_language) 

32 while True: 

33 if language is None: 

34 raise LocalizationError(language_code, key) 

35 

36 string = language.find_string(key, substitutions) 

37 if string is None or not string.template.can_render(substitutions): 

38 if language.fallback is None and language != self.fallback_language: 

39 language = self.fallback_language 

40 else: 

41 language = language.fallback 

42 continue 

43 

44 return string 

45 

46 def localize(self, string_key: str, language_code: str, substitutions: dict[str, str | int] | None = None) -> str: 

47 if string := self.find_string(string_key, language_code, substitutions): 

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 fallback: "Language | None" = None 

63 

64 def load_json_dict(self, json_dict: dict): 

65 def add_strings(json_dict: dict, key_prefix: str | 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): 

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): 

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

79 

80 def find_string(self, key: str, substitutions: dict[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): 

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: dict[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: dict[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: dict[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: dict[str, str | int] | None) -> str: 

126 substrings: list[str] = [] 

127 for segment in self.segments: 

128 if segment.is_variable: 

129 if segment.text in substitutions: 

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) -> "list[StringSegment]": 

139 last_index = 0 

140 segments: list[StringSegment] = [] 

141 for match in re.finditer(r"\{\{([^\}]+)\}\}", 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}")