Coverage for tests\test_l10n.py: 100.000%

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

2#

3# SPDX-License-Identifier: Zlib

4

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

6

7 from __future__ import annotations (empty)

8

9 import contextlib (empty)

10 import errno (empty)

11 import gettext (empty)

12 import os (empty)

13 import re (empty)

14 import string (empty)

15 import types (empty)

16 from typing import TYPE_CHECKING, cast (empty)

17

18 import hypothesis (empty)

19 import pytest (empty)

20 from hypothesis import strategies (empty)

21

22 from derivepassphrase._internals import cli_messages as msg (empty)

23

24 if TYPE_CHECKING: (empty)

25 from collections.abc import Iterator

26

27

28 class Parametrize(types.SimpleNamespace): (empty)

29 MAYBE_FORMAT_STRINGS = pytest.mark.parametrize( (empty)

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

31 )

32

33

34 all_translatable_strings_dict: dict[ (empty)

35 msg.TranslatableString,

36 msg.MsgTemplate,

37] = {}

38 for enum_class in msg.MSG_TEMPLATE_CLASSES: (empty)

39 all_translatable_strings_dict.update({ (empty)

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

41 })

42

43 all_translatable_strings_enum_values = tuple( (empty)

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

45)

46 all_translatable_strings = [ (empty)

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

48 for v in all_translatable_strings_enum_values

49]

50

51

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

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

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

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

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

57 yield (empty)

58

59

60@contextlib.contextmanager (empty)

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

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

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

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

65 yield 4 ctx 1bdie

66

67

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

69 class TestL10nMachineryWithDebugTranslations: (empty)

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

71

72 error_codes = tuple( (empty)

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

74 )

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

76 known_fields_error_messages = tuple( (empty)

77 e

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

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

80 )

81 """ (empty)

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

83 `filename` replacement fields.

84 """

85 no_fields_messages = tuple( (empty)

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.""" (empty)

89

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

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

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

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

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

95 assert translated == value 1 ctx 1j

96

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

98 def test_100a_debug_translation_get_ts( 2 ctx 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]) 1 ctx 1f

104 context = value.l10n_context 1 ctx 1f

105 singular = value.singular 1 ctx 1f

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

107 assert translated.startswith(ts_name) 1 ctx 1f

108 suffix = translated.removeprefix(ts_name) 1 ctx 1f

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

110

111 @hypothesis.given( (empty)

112 value=strategies.sampled_from(all_translatable_strings_enum_values)

113 )

114 def test_100b_debug_translation_get_enum( (empty)

115 self,

116 value: msg.MsgTemplate,

117 ) -> None:

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

119 ts_name = str(value) 1 ctx 1g

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

121 context = inner_value.l10n_context 1 ctx 1g

122 singular = inner_value.singular 1 ctx 1g

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

124 assert translated.startswith(ts_name) 1 ctx 1g

125 suffix = translated.removeprefix(ts_name) 1 ctx 1g

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

127

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

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

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

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

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

133 assert str(translated) == value 1 ctx 1k

134

135 @hypothesis.given( (empty)

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( (empty)

144 self,

145 values: list[msg.MsgTemplate],

146 ) -> None:

147 """TranslatableStrings are hashable."""

148 assert len(values) == 2 1 ctx 1c

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

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

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

152 assert ts0 == ts1 1 ctx 1c

153 assert ts0 != ts2 1 ctx 1c

154 assert ts1 != ts2 1 ctx 1c

155 strings = {ts0} 1 ctx 1c

156 strings.add(ts1) 1 ctx 1c

157 assert len(strings) == 1 1 ctx 1c

158 strings.add(ts2) 1 ctx 1c

159 assert len(strings) == 2 1 ctx 1c

160

161 @hypothesis.given( (empty)

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( (empty)

171 self,

172 value: msg.ErrMsgTemplate,

173 errnos: list[int],

174 ) -> None:

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

176 assert len(errnos) == 2 1 ctx 1h

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

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

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

180 ts1 = msg.TranslatedString( 1 ctx 1h

181 value, error=error1, filename=None

182 ).maybe_without_filename()

183 ts2 = msg.TranslatedString( 1 ctx 1h

184 value, error=error2, filename=None

185 ).maybe_without_filename()

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

187 assert ts1 != ts2 1 ctx 1h

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

189

190 @hypothesis.given( (empty)

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( (empty)

195 self,

196 value: msg.ErrMsgTemplate,

197 errno_: int,

198 ) -> None:

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

200 error = os.strerror(errno_) 1 ctx 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(): 1 ctx 1d

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

211 ts1 = ts0.maybe_without_filename() 1 ctx 1d

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

213 assert ts0 != ts1 1 ctx 1d

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

215

216 @Parametrize.MAYBE_FORMAT_STRINGS (empty)

217 def test_102_translated_strings_suppressed_interpolation_fail( (empty)

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(): 1 ctx 1b

229 ts1 = msg.TranslatedString(s) 1 ctx 1b

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

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

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

233 str(ts1) 1 ctx 1b

234 assert str(ts2) == s.replace('{spam}', 'eggs') 1 ctx 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( 1 ctx 1b

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

246 )

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

248 str(ts1) 1 ctx 1b

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

250 str(ts2) 1 ctx 1b

251

252 @hypothesis.given( (empty)

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( (empty)

260 self,

261 s: str,

262 ) -> None:

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

264 with monkeypatched_null_translations(): 1 ctx 1i

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

266 try: 1 ctx 1i

267 assert str(ts) == s 1 ctx 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( (empty)

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( (empty)

282 self,

283 s: str,

284 ) -> None:

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

286 with monkeypatched_null_translations(): 1 ctx 1e

287 ts_inner = msg.TranslatableString( 1 ctx 1e

288 '',

289 '{spam}' + s,

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

291 )

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

293 try: 1 ctx 1e

294 assert str(ts) == '{spam}' + s 1 ctx 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