Coverage for tests / test_l10n.py: 100.000%

149 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-07-06 21:34 +0200

1# SPDX-FileCopyrightText: 2026 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 types 

15from typing import TYPE_CHECKING, TypeVar 

16 

17import hypothesis 

18import pytest 

19from hypothesis import strategies 

20 

21from derivepassphrase._internals import cli_messages as msg 

22from tests.machinery import hypothesis as hypothesis_machinery 

23 

24if TYPE_CHECKING: 

25 from collections.abc import Generator 

26 from typing import ClassVar 

27 

28 

29class Parametrize(types.SimpleNamespace): 

30 MAYBE_FORMAT_STRINGS = hypothesis_machinery.explicit_examples( 

31 "s", ["{spam}", "{spam}abc", "{", "}", "{{{"] 

32 ) 

33 

34 

35@pytest.fixture(scope="class") 

36def use_debug_translations() -> Generator[None, None, None]: 

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

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

39 monkeypatch.setattr(msg, "translation", msg.DebugTranslations()) 

40 yield 

41 

42 

43@contextlib.contextmanager 

44def monkeypatched_null_translations() -> Generator[None, None, None]: 

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

46 with pytest.MonkeyPatch.context() as monkeypatch: 1fdbe

47 monkeypatch.setattr(msg, "translation", gettext.NullTranslations()) 1fdbe

48 yield 1fdbe

49 

50 

51class Strategies: 

52 """`hypothesis` strategies for testing the localization machinery.""" 

53 

54 all_translatable_strings_dict: ClassVar[ 

55 dict[ 

56 msg.TranslatableString, 

57 msg.MsgTemplate, 

58 ] 

59 ] = {} 

60 for enum_class in msg.MSG_TEMPLATE_CLASSES: 

61 all_translatable_strings_dict.update({v.value: v for v in enum_class}) 

62 

63 all_translatable_strings_enum_values = tuple( 

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

65 ) 

66 all_translatable_strings = tuple( 

67 v.value for v in all_translatable_strings_enum_values 

68 ) 

69 

70 error_codes = tuple( 

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

72 ) 

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

74 known_fields_error_messages = tuple( 

75 e 

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

77 if e.value.fields() == ["error", "filename"] 

78 ) 

79 """ 

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

81 `filename` replacement fields. 

82 """ 

83 no_fields_messages = tuple( 

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

85 ) 

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

87 

88 @classmethod 

89 def errnos(cls) -> strategies.SearchStrategy[int]: 

90 """Return a strategy for errno values.""" 

91 return strategies.sampled_from(cls.error_codes) 

92 

93 @classmethod 

94 def error_messages_with_fields( 

95 cls, 

96 ) -> strategies.SearchStrategy[msg.ErrMsgTemplate]: 

97 """Return a strategy for error messages with known replacement fields. 

98 

99 All error messages from this strategy contain (only) the "error" 

100 and "filename" replacement fields. 

101 

102 """ 

103 return strategies.sampled_from(cls.known_fields_error_messages) 

104 

105 @staticmethod 

106 def printable_strings() -> strategies.SearchStrategy[str]: 

107 """Return a strategy for printable strings.""" 

108 return strategies.text( 

109 strategies.characters(min_codepoint=32, max_codepoint=126), 

110 min_size=1, 

111 max_size=100, 

112 ) 

113 

114 @classmethod 

115 def strings_without_fields( 

116 cls, 

117 ) -> strategies.SearchStrategy[msg.MsgTemplate]: 

118 """Return a strategy for messages without any replacement fields.""" 

119 return strategies.sampled_from(cls.no_fields_messages) 

120 

121 @classmethod 

122 def translatable_string_enums( 

123 cls, 

124 ) -> strategies.SearchStrategy[msg.MsgTemplate]: 

125 """Return a strategy for translatable message enum values.""" 

126 return strategies.sampled_from( 

127 cls.all_translatable_strings_enum_values 

128 ) 

129 

130 @classmethod 

131 def translatable_strings( 

132 cls, 

133 ) -> strategies.SearchStrategy[msg.TranslatableString]: 

134 """Return a strategy for translatable messages.""" 

135 return strategies.sampled_from(cls.all_translatable_strings) 

136 

137 T = TypeVar("T") 

138 

139 @staticmethod 

140 def two_different( 

141 strategy: strategies.SearchStrategy[T], / 

142 ) -> strategies.SearchStrategy[tuple[T, T]]: 

143 """Return a strategy for generating unique pairs of things. 

144 

145 We assume the "things" strategy to generate hashable elements. 

146 

147 Args: 

148 strategy: 

149 An existing search strategy. 

150 

151 """ 

152 return strategies.lists( 

153 strategy, min_size=2, max_size=2, unique=True 

154 ).map(tuple) # type: ignore[arg-type] 

155 

156 

157@pytest.mark.usefixtures("use_debug_translations") 

158class TestDebugTranslations: 

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

160 

161 @staticmethod 

162 def _test_interpolated( 

163 ts_name: str, ts_value: msg.TranslatableString 

164 ) -> None: 

165 context = ts_value.l10n_context 1gh

166 singular = ts_value.singular 1gh

167 translated = msg.translation.pgettext(context, singular) 1gh

168 assert translated.startswith(ts_name) 1gh

169 suffix = translated.removeprefix(ts_name) 1gh

170 assert not suffix or suffix.startswith("(") 1gh

171 

172 @hypothesis.given(value=Strategies.printable_strings()) 

173 @hypothesis.example(value="{") 1aj

174 def test_get_str(self, value: str) -> None: 

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

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

177 assert translated == value 1j

178 

179 @hypothesis.given(value=Strategies.printable_strings()) 

180 @hypothesis.example(value="{") 1ak

181 def test_get_ts_str(self, value: str) -> None: 

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

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

184 assert str(translated) == value 1k

185 

186 @hypothesis.given(value=Strategies.translatable_strings()) 

187 def test_get_ts( 1ah

188 self, 

189 value: msg.TranslatableString, 

190 ) -> None: 

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

192 self._test_interpolated( 1h

193 str(Strategies.all_translatable_strings_dict[value]), value 

194 ) 

195 

196 @hypothesis.given(value=Strategies.translatable_string_enums()) 

197 def test_get_enum( 1ag

198 self, 

199 value: msg.MsgTemplate, 

200 ) -> None: 

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

202 self._test_interpolated(str(value), value.value) 1g

203 

204 

205@pytest.mark.usefixtures("use_debug_translations") 

206class TestTranslatedStrings: 

207 """Test the translated string objects.""" 

208 

209 @hypothesis.given( 

210 values=Strategies.two_different(Strategies.strings_without_fields()) 

211 ) 

212 def test_hashable( 

213 self, 

214 values: list[msg.MsgTemplate], 

215 ) -> None: 

216 """TranslatableStrings are hashable.""" 

217 assert len(values) == 2 1c

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

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

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

221 assert ts0 == ts1 1c

222 assert ts0 != ts2 1c

223 assert ts1 != ts2 1c

224 strings = {ts0} 1c

225 strings.add(ts1) 1c

226 assert len(strings) == 1 1c

227 strings.add(ts2) 1c

228 assert len(strings) == 2 1c

229 

230 @hypothesis.given( 

231 value=Strategies.error_messages_with_fields(), 

232 errnos=Strategies.two_different(Strategies.errnos()), 

233 ) 

234 def test_hashable_interpolated( 

235 self, 

236 value: msg.ErrMsgTemplate, 

237 errnos: list[int], 

238 ) -> None: 

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

240 assert len(errnos) == 2 1i

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

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

243 hypothesis.assume(error1 != error2) 1i

244 ts1 = msg.TranslatedString( 1i

245 value, error=error1, filename=None 

246 ).maybe_without_filename() 

247 ts2 = msg.TranslatedString( 1i

248 value, error=error2, filename=None 

249 ).maybe_without_filename() 

250 assert str(ts1) != str(ts2) 1i

251 assert ts1 != ts2 1i

252 assert len({ts1, ts2}) == 2 1i

253 

254 @hypothesis.given( 

255 value=Strategies.error_messages_with_fields(), 

256 errno_=Strategies.errnos(), 

257 ) 

258 def test_hashable_interpolated_error_and_filename( 

259 self, 

260 value: msg.ErrMsgTemplate, 

261 errno_: int, 

262 ) -> None: 

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

264 error = os.strerror(errno_) 1d

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

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

267 # other predictable, non-debug output. 

268 # 

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

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

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

272 # each hypothesis iteration. 

273 with monkeypatched_null_translations(): 1d

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

275 ts1 = ts0.maybe_without_filename() 1d

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

277 assert ts0 != ts1 1d

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

279 

280 

281@pytest.mark.usefixtures("use_debug_translations") 

282class TestSuppressedInterpolations: 

283 """Test the interpolation suppression behavior of translated strings.""" 

284 

285 # TODO(the-13th-letter): Revise this test, in particular, the error 

286 # assertions. 

287 @Parametrize.MAYBE_FORMAT_STRINGS 

288 def test_missing_fields(self, s: str) -> None: 1ab

289 """TranslatableStrings require fixed replacement fields. 

290 

291 They reject attempts at stringification if unknown fields are 

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

293 invalid. 

294 

295 """ 

296 with monkeypatched_null_translations(): 1b

297 ts1 = msg.TranslatedString(s) 1b

298 ts2 = msg.TranslatedString(s, spam="eggs") 1b

299 if "{spam}" in s: 1b

300 with pytest.raises(KeyError, match=r"spam"): 1b

301 str(ts1) 1b

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

303 else: 

304 # Known error message variations: 

305 # 

306 # * Single { encountered in the pattern string 

307 # * Single } encountered in the pattern string 

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

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

310 # * Single '{' 

311 # * Single '}' 

312 pattern = re.compile( 1b

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

314 ) 

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

316 str(ts1) 1b

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

318 str(ts2) 1b

319 

320 @hypothesis.given(s=Strategies.printable_strings()) 

321 def test_constant_strings(self, s: str) -> None: 1af

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

323 with monkeypatched_null_translations(): 1f

324 ts = msg.TranslatedString.constant(s) 1f

325 try: 1f

326 assert str(ts) == s 1f

327 except ValueError as exc: # pragma: no cover 

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

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

330 err_msg = "Interpolation attempted" 

331 raise AssertionError(err_msg) from exc 

332 

333 @hypothesis.given(s=Strategies.printable_strings()) 

334 def test_nonformat_strings(self, s: str) -> None: 1ae

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

336 with monkeypatched_null_translations(): 1e

337 ts_inner = msg.TranslatableString( 1e

338 "", 

339 "{spam}" + s, 

340 flags=frozenset({"no-python-brace-format"}), 

341 ) 

342 ts = msg.TranslatedString(ts_inner, spam="eggs") 1e

343 try: 1e

344 assert str(ts) == "{spam}" + s 1e

345 except ValueError as exc: # pragma: no cover 

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

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

348 err_msg = "Interpolation attempted" 

349 raise AssertionError(err_msg) from exc