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
« 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
5"""Test the localization machinery."""
7from __future__ import annotations
9import contextlib
10import errno
11import gettext
12import os
13import re
14import string
15import types
16from typing import TYPE_CHECKING, cast
18import hypothesis
19import pytest
20from hypothesis import strategies
22from derivepassphrase._internals import cli_messages as msg
24if TYPE_CHECKING:
25 from collections.abc import Iterator
28class Parametrize(types.SimpleNamespace):
29 MAYBE_FORMAT_STRINGS = pytest.mark.parametrize(
30 's', ['{spam}', '{spam}abc', '{', '}', '{{{']
31 )
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 })
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]
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
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
68@pytest.mark.usefixtures('use_debug_translations')
69class TestL10nMachineryWithDebugTranslations:
70 """Test the localization machinery together with debug translations."""
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."""
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
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
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
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
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
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
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
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.
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.
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
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
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