Coverage for tests\test_l10n.py: 100.000%

127 statements  

« prev     ^ index     » next       coverage.py v7.9.2, created at 2025-07-23 12:17 +0200

1# SPDX-FileCopyrightText: 2025 Marco Ricci <software@the13thletter.info> 

2# 

3# SPDX-License-Identifier: Zlib 

4 

5"""Test the localization machinery.""" 

6 

7from __future__ import annotations 

8 

9import contextlib 

10import errno 

11import gettext 

12import os 

13import re 

14import string 

15import types 

16from typing import TYPE_CHECKING, cast 

17 

18import hypothesis 

19import pytest 

20from hypothesis import strategies 

21 

22from derivepassphrase._internals import cli_messages as msg 

23 

24if TYPE_CHECKING: 

25 from collections.abc import Iterator 

26 

27 

28class Parametrize(types.SimpleNamespace): 

29 MAYBE_FORMAT_STRINGS = pytest.mark.parametrize( 

30 's', ['{spam}', '{spam}abc', '{', '}', '{{{'] 

31 ) 

32 

33 

34all_translatable_strings_dict: dict[ 

35 msg.TranslatableString, 

36 msg.MsgTemplate, 

37] = {} 

38for enum_class in msg.MSG_TEMPLATE_CLASSES: 

39 all_translatable_strings_dict.update({ 

40 cast('msg.TranslatableString', v.value): v for v in enum_class 

41 }) 

42 

43all_translatable_strings_enum_values = tuple( 

44 sorted(all_translatable_strings_dict.values(), key=str) 

45) 

46all_translatable_strings = [ 

47 cast('msg.TranslatableString', v.value) 

48 for v in all_translatable_strings_enum_values 

49] 

50 

51 

52@pytest.fixture(scope='class') 

53def use_debug_translations() -> Iterator[None]: 

54 """Force the use of debug translations (pytest class fixture).""" 

55 with pytest.MonkeyPatch.context() as monkeypatch: 

56 monkeypatch.setattr(msg, 'translation', msg.DebugTranslations()) 

57 yield 

58 

59 

60@contextlib.contextmanager 

61def monkeypatched_null_translations() -> Iterator[None]: 

62 """Force the use of no-op translations in this context.""" 

63 with pytest.MonkeyPatch.context() as monkeypatch: 1bdie

64 monkeypatch.setattr(msg, 'translation', gettext.NullTranslations()) 1bdie

65 yield 1bdie

66 

67 

68@pytest.mark.usefixtures('use_debug_translations') 

69class TestL10nMachineryWithDebugTranslations: 

70 """Test the localization machinery together with debug translations.""" 

71 

72 error_codes = tuple( 

73 sorted(errno.errorcode, key=errno.errorcode.__getitem__) 

74 ) 

75 """A cache of the known error codes from the [`errno`][] module.""" 

76 known_fields_error_messages = tuple( 

77 e 

78 for e in sorted(msg.ErrMsgTemplate, key=str) 

79 if e.value.fields() == ['error', 'filename'] 

80 ) 

81 """ 

82 A cache of known error messages that contain both `error` and 

83 `filename` replacement fields. 

84 """ 

85 no_fields_messages = tuple( 

86 e for e in all_translatable_strings_enum_values if not e.value.fields() 

87 ) 

88 """A cache of known messages that don't contain replacement fields.""" 

89 

90 @hypothesis.given(value=strategies.text(max_size=100)) 

91 @hypothesis.example('{') 1aj

92 def test_100_debug_translation_get_str(self, value: str) -> None: 

93 """Translating a raw string object does nothing.""" 

94 translated = msg.translation.gettext(value) 1j

95 assert translated == value 1j

96 

97 @hypothesis.given(value=strategies.sampled_from(all_translatable_strings)) 

98 def test_100a_debug_translation_get_ts( 1af

99 self, 

100 value: msg.TranslatableString, 

101 ) -> None: 

102 """Translating a TranslatableString translates and interpolates.""" 

103 ts_name = str(all_translatable_strings_dict[value]) 1f

104 context = value.l10n_context 1f

105 singular = value.singular 1f

106 translated = msg.translation.pgettext(context, singular) 1f

107 assert translated.startswith(ts_name) 1f

108 suffix = translated.removeprefix(ts_name) 1f

109 assert not suffix or suffix.startswith('(') 1f

110 

111 @hypothesis.given( 

112 value=strategies.sampled_from(all_translatable_strings_enum_values) 

113 ) 

114 def test_100b_debug_translation_get_enum( 

115 self, 

116 value: msg.MsgTemplate, 

117 ) -> None: 

118 """Translating a MsgTemplate operates on the enum value.""" 

119 ts_name = str(value) 1g

120 inner_value = cast('msg.TranslatableString', value.value) 1g

121 context = inner_value.l10n_context 1g

122 singular = inner_value.singular 1g

123 translated = msg.translation.pgettext(context, singular) 1g

124 assert translated.startswith(ts_name) 1g

125 suffix = translated.removeprefix(ts_name) 1g

126 assert not suffix or suffix.startswith('(') 1g

127 

128 @hypothesis.given(value=strategies.text(max_size=100)) 

129 @hypothesis.example('{') 1ak

130 def test_100c_debug_translation_get_ts_str(self, value: str) -> None: 

131 """Translating a constant TranslatableString does nothing.""" 

132 translated = msg.TranslatedString.constant(value) 1k

133 assert str(translated) == value 1k

134 

135 @hypothesis.given( 

136 values=strategies.lists( 

137 strategies.sampled_from(no_fields_messages), 

138 min_size=2, 

139 max_size=2, 

140 unique=True, 

141 ) 

142 ) 

143 def test_101_translated_strings_operations( 

144 self, 

145 values: list[msg.MsgTemplate], 

146 ) -> None: 

147 """TranslatableStrings are hashable.""" 

148 assert len(values) == 2 1c

149 ts0 = msg.TranslatedString(values[0]) 1c

150 ts1 = msg.TranslatedString(values[0]) 1c

151 ts2 = msg.TranslatedString(values[1]) 1c

152 assert ts0 == ts1 1c

153 assert ts0 != ts2 1c

154 assert ts1 != ts2 1c

155 strings = {ts0} 1c

156 strings.add(ts1) 1c

157 assert len(strings) == 1 1c

158 strings.add(ts2) 1c

159 assert len(strings) == 2 1c

160 

161 @hypothesis.given( 

162 value=strategies.sampled_from(known_fields_error_messages), 

163 errnos=strategies.lists( 

164 strategies.sampled_from(error_codes), 

165 min_size=2, 

166 max_size=2, 

167 unique=True, 

168 ), 

169 ) 

170 def test_101a_translated_strings_operations_interpolated( 

171 self, 

172 value: msg.ErrMsgTemplate, 

173 errnos: list[int], 

174 ) -> None: 

175 """TranslatableStrings are hashable even with interpolations.""" 

176 assert len(errnos) == 2 1h

177 error1, error2 = [os.strerror(c) for c in errnos] 1h

178 # The Annoying OS has error codes with identical strerror values. 

179 hypothesis.assume(error1 != error2) 1h

180 ts1 = msg.TranslatedString( 1h

181 value, error=error1, filename=None 

182 ).maybe_without_filename() 

183 ts2 = msg.TranslatedString( 1h

184 value, error=error2, filename=None 

185 ).maybe_without_filename() 

186 assert str(ts1) != str(ts2) 1h

187 assert ts1 != ts2 1h

188 assert len({ts1, ts2}) == 2 1h

189 

190 @hypothesis.given( 

191 value=strategies.sampled_from(known_fields_error_messages), 

192 errno_=strategies.sampled_from(error_codes), 

193 ) 

194 def test_101b_translated_strings_operations_interpolated( 

195 self, 

196 value: msg.ErrMsgTemplate, 

197 errno_: int, 

198 ) -> None: 

199 """Interpolated TranslatableStrings with error/filename are hashable.""" 

200 error = os.strerror(errno_) 1d

201 # The debug translations specifically do *not* differ in output 

202 # when the filename is trimmed. So we need to request some 

203 # other predictable, non-debug output. 

204 # 

205 # Also, because of the class-scoped fixture, and because 

206 # hypothesis interferes with a function-scoped fixture, we also 

207 # need to do our own manual monkeypatching here, separately, for 

208 # each hypothesis iteration. 

209 with monkeypatched_null_translations(): 1d

210 ts0 = msg.TranslatedString(value, error=error, filename=None) 1d

211 ts1 = ts0.maybe_without_filename() 1d

212 assert str(ts0) != str(ts1) 1d

213 assert ts0 != ts1 1d

214 assert len({ts0, ts1}) == 2 1d

215 

216 @Parametrize.MAYBE_FORMAT_STRINGS 

217 def test_102_translated_strings_suppressed_interpolation_fail( 

218 self, 

219 s: str, 

220 ) -> None: 

221 """TranslatableStrings require fixed replacement fields. 

222 

223 They reject attempts at stringification if unknown fields are 

224 passed, or if fields are missing, or if the format string is 

225 invalid. 

226 

227 """ 

228 with monkeypatched_null_translations(): 1b

229 ts1 = msg.TranslatedString(s) 1b

230 ts2 = msg.TranslatedString(s, spam='eggs') 1b

231 if '{spam}' in s: 1b

232 with pytest.raises(KeyError, match=r'spam'): 1b

233 str(ts1) 1b

234 assert str(ts2) == s.replace('{spam}', 'eggs') 1b

235 else: 

236 # Known error message variations: 

237 # 

238 # * Single { encountered in the pattern string 

239 # * Single } encountered in the pattern string 

240 # * Single '{' encountered in the pattern string 

241 # * Single '}' encountered in the pattern string 

242 # * Single '{' 

243 # * Single '}' 

244 pattern = re.compile( 1b

245 r"Single (?:\{|\}|'\{'|'\}')(?: encountered in the pattern string)?" 

246 ) 

247 with pytest.raises(ValueError, match=pattern): 1b

248 str(ts1) 1b

249 with pytest.raises(ValueError, match=pattern): 1b

250 str(ts2) 1b

251 

252 @hypothesis.given( 

253 s=strategies.text( 

254 strategies.sampled_from(string.ascii_lowercase + '{}'), 

255 min_size=1, 

256 max_size=20, 

257 ) 

258 ) 

259 def test_102a_translated_strings_suppressed_interpolation_str( 

260 self, 

261 s: str, 

262 ) -> None: 

263 """Constant TranslatedStrings don't interpolate fields.""" 

264 with monkeypatched_null_translations(): 1i

265 ts = msg.TranslatedString.constant(s) 1i

266 try: 1i

267 assert str(ts) == s 1i

268 except ValueError as exc: # pragma: no cover 

269 # Not a test error (= test author's fault), but 

270 # a regression (= code under test is at fault). 

271 err_msg = 'Interpolation attempted' 

272 raise AssertionError(err_msg) from exc 

273 

274 @hypothesis.given( 

275 s=strategies.text( 

276 strategies.sampled_from(string.ascii_lowercase + '{}'), 

277 min_size=1, 

278 max_size=20, 

279 ) 

280 ) 

281 def test_102b_translated_strings_suppressed_interpolation_ts_manual( 

282 self, 

283 s: str, 

284 ) -> None: 

285 """Non-format TranslatedStrings don't interpolate fields.""" 

286 with monkeypatched_null_translations(): 1e

287 ts_inner = msg.TranslatableString( 1e

288 '', 

289 '{spam}' + s, 

290 flags=frozenset({'no-python-brace-format'}), 

291 ) 

292 ts = msg.TranslatedString(ts_inner, spam='eggs') 1e

293 try: 1e

294 assert str(ts) == '{spam}' + s 1e

295 except ValueError as exc: # pragma: no cover 

296 # Not a test error (= test author's fault), but 

297 # a regression (= code under test is at fault). 

298 err_msg = 'Interpolation attempted' 

299 raise AssertionError(err_msg) from exc