Add hypothesis-based tests for l10n machinery
Marco Ricci

Marco Ricci commited on 2025-01-14 03:06:58
Zeige 2 geänderte Dateien mit 313 Einfügungen und 15 Löschungen.


This specifically tests the `DebugTranslations` object, and various
asserted properties of `TranslatedStrings` and `TranslatableStrings`
such as hashability and equality under various modifications (e.g.
trimming the filename).

To further support this, `TranslatedStrings` now know their own format
string replacement fields (except for `%`-formatted strings), and the
`DebugTranslations` class now uses that knowledge instead of duplicating
it inline.  Additionally, because `TranslatedString`s are always
interpolated, there is a new `TranslatedString.constant` constructor
for `str` templates which ensures that nothing is interpolated.

Some code branches previously excluded from coverage are now considered
again.
... ...
@@ -154,15 +154,9 @@ class DebugTranslations(gettext.NullTranslations):
154 154
         ts: TranslatableString,
155 155
         trimmed: frozenset[str] = frozenset(),
156 156
     ) -> str:
157
-        formatter = string.Formatter()
158
-        fields: dict[str, int] = {}
159
-        for _lit, field, _spec, _conv in formatter.parse(ts.singular):
160
-            if field is not None and field not in fields:
161
-                fields[field] = len(fields)
162
-        sorted_fields = sorted(fields.keys(), key=fields.__getitem__)
163 157
         formatted_fields = [
164 158
             f'{f}=None' if f in trimmed else f'{f}={{{f}!r}}'
165
-            for f in sorted_fields
159
+            for f in ts.fields()
166 160
         ]
167 161
         return (
168 162
             '{!s}({})'.format(enum_name, ', '.join(formatted_fields))
... ...
@@ -175,7 +169,7 @@ class DebugTranslations(gettext.NullTranslations):
175 169
         self,
176 170
         message: str,
177 171
         /,
178
-    ) -> str:  # pragma: no cover
172
+    ) -> str:
179 173
         return self._locate_message(message)
180 174
 
181 175
     @override
... ...
@@ -221,6 +215,33 @@ class TranslatableString(NamedTuple):
221 215
     flags: frozenset[str] = frozenset()
222 216
     translator_comments: str = ''
223 217
 
218
+    def fields(self) -> list[str]:
219
+        """Return the replacement fields this template requires.
220
+
221
+        Raises:
222
+            NotImplementedError:
223
+                Replacement field discovery for %-formatting is not
224
+                implemented.
225
+
226
+        """
227
+        if 'python-format' in self.flags:  # pragma: no cover
228
+            err_msg = (
229
+                'Replacement field discovery for %-formatting '
230
+                'is not implemented'
231
+            )
232
+            raise NotImplementedError(err_msg)
233
+        if (
234
+            'no-python-brace-format' in self.flags
235
+            or 'python-brace-format' not in self.flags
236
+        ):
237
+            return []
238
+        formatter = string.Formatter()
239
+        fields: dict[str, int] = {}
240
+        for _lit, field, _spec, _conv in formatter.parse(self.singular):
241
+            if field is not None and field not in fields:
242
+                fields[field] = len(fields)
243
+        return sorted(fields, key=fields.__getitem__)
244
+
224 245
     @staticmethod
225 246
     def _maybe_rewrap(
226 247
         string: str,
... ...
@@ -436,17 +457,20 @@ class TranslatedString:
436 457
 
437 458
     def __str__(self) -> str:
438 459
         if self._rendered is None:
439
-            # raw str support is currently unneeded, so excluded from coverage
440
-            if isinstance(self.template, str):  # pragma: no cover
441
-                context = None
460
+            do_escape = False
461
+            if isinstance(self.template, str):
462
+                context = ''
442 463
                 template = self.template
443 464
             else:
444 465
                 context = self.template.l10n_context
445 466
                 template = self.template.singular
446
-            if context is not None:
447
-                template = translation.pgettext(context, template)
448
-            else:  # pragma: no cover
449
-                template = translation.gettext(template)
467
+                do_escape = 'no-python-brace-format' in self.template.flags
468
+            template = (
469
+                translation.pgettext(context, template)
470
+                if context
471
+                else translation.gettext(template)
472
+            )
473
+            template = self._escape(template) if do_escape else template
450 474
             kwargs = {
451 475
                 k: str(v) if isinstance(v, TranslatedString) else v
452 476
                 for k, v in self.kwargs.items()
... ...
@@ -454,6 +478,17 @@ class TranslatedString:
454 478
             self._rendered = template.format(**kwargs)
455 479
         return self._rendered
456 480
 
481
+    @staticmethod
482
+    def _escape(template: str) -> str:
483
+        return template.translate({
484
+            ord('{'): '{{',
485
+            ord('}'): '}}',
486
+        })
487
+
488
+    @classmethod
489
+    def constant(cls, template: str) -> Self:
490
+        return cls(cls._escape(template))
491
+
457 492
     def maybe_without_filename(self) -> Self:
458 493
         """Return a new string without the "filename" field.
459 494
 
... ...
@@ -0,0 +1,263 @@
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
8
+
9
+import contextlib
10
+import errno
11
+import gettext
12
+import os
13
+import string
14
+from typing import TYPE_CHECKING, cast
15
+
16
+import hypothesis
17
+import pytest
18
+from hypothesis import strategies
19
+
20
+from derivepassphrase import _cli_msg as msg
21
+
22
+if TYPE_CHECKING:
23
+    from collections.abc import Iterator
24
+
25
+all_translatable_strings_dict: dict[
26
+    msg.TranslatableString,
27
+    msg.MsgTemplate,
28
+] = {}
29
+for enum_class in msg.MSG_TEMPLATE_CLASSES:
30
+    all_translatable_strings_dict.update({
31
+        cast('msg.TranslatableString', v.value): v for v in enum_class
32
+    })
33
+
34
+all_translatable_strings_enum_values = tuple(
35
+    sorted(all_translatable_strings_dict.values(), key=str)
36
+)
37
+all_translatable_strings = [
38
+    cast('msg.TranslatableString', v.value)
39
+    for v in all_translatable_strings_enum_values
40
+]
41
+
42
+
43
+@pytest.fixture(scope='class')
44
+def use_debug_translations() -> Iterator[None]:
45
+    with pytest.MonkeyPatch.context() as monkeypatch:
46
+        monkeypatch.setattr(msg, 'translation', msg.DebugTranslations())
47
+        yield
48
+
49
+
50
+@contextlib.contextmanager
51
+def monkeypatched_null_translations() -> Iterator[None]:
52
+    with pytest.MonkeyPatch.context() as monkeypatch:
53
+        monkeypatch.setattr(msg, 'translation', gettext.NullTranslations())
54
+        yield
55
+
56
+
57
+@pytest.mark.usefixtures('use_debug_translations')
58
+class TestL10nMachineryWithDebugTranslations:
59
+
60
+    error_codes = tuple(
61
+        sorted(errno.errorcode, key=errno.errorcode.__getitem__)
62
+    )
63
+    known_fields_error_messages = tuple(
64
+        e
65
+        for e in sorted(msg.ErrMsgTemplate, key=str)
66
+        if e.value.fields() == ['error', 'filename']
67
+    )
68
+    no_fields_messages = tuple(
69
+        e
70
+        for e in all_translatable_strings_enum_values
71
+        if not e.value.fields()
72
+    )
73
+
74
+    @hypothesis.given(value=strategies.text(max_size=100))
75
+    @hypothesis.example('{')
76
+    def test_100_debug_translation_get_str(self, value: str) -> None:
77
+        translated = msg.translation.gettext(value)
78
+        assert translated == value
79
+
80
+    @hypothesis.given(value=strategies.sampled_from(all_translatable_strings))
81
+    def test_100a_debug_translation_get_ts(
82
+        self,
83
+        value: msg.TranslatableString,
84
+    ) -> None:
85
+        ts_name = str(all_translatable_strings_dict[value])
86
+        context = value.l10n_context
87
+        singular = value.singular
88
+        translated = msg.translation.pgettext(context, singular)
89
+        assert translated.startswith(ts_name)
90
+        suffix = translated.removeprefix(ts_name)
91
+        assert not suffix or suffix.startswith('(')
92
+
93
+    @hypothesis.given(
94
+        value=strategies.sampled_from(all_translatable_strings_enum_values)
95
+    )
96
+    def test_100b_debug_translation_get_enum(
97
+        self,
98
+        value: msg.MsgTemplate,
99
+    ) -> None:
100
+        ts_name = str(value)
101
+        inner_value = cast('msg.TranslatableString', value.value)
102
+        context = inner_value.l10n_context
103
+        singular = inner_value.singular
104
+        translated = msg.translation.pgettext(context, singular)
105
+        assert translated.startswith(ts_name)
106
+        suffix = translated.removeprefix(ts_name)
107
+        assert not suffix or suffix.startswith('(')
108
+
109
+    @hypothesis.given(value=strategies.text(max_size=100))
110
+    @hypothesis.example('{')
111
+    def test_100c_debug_translation_get_ts_str(self, value: str) -> None:
112
+        translated = msg.TranslatedString.constant(value)
113
+        assert str(translated) == value
114
+
115
+    @hypothesis.given(
116
+        values=strategies.lists(
117
+            strategies.sampled_from(no_fields_messages),
118
+            min_size=2,
119
+            max_size=2,
120
+            unique=True,
121
+        )
122
+    )
123
+    def test_101_translated_strings_operations(
124
+        self,
125
+        values: list[msg.MsgTemplate],
126
+    ) -> None:
127
+        assert len(values) == 2
128
+        ts0 = msg.TranslatedString(values[0])
129
+        ts1 = msg.TranslatedString(values[0])
130
+        ts2 = msg.TranslatedString(values[1])
131
+        assert ts0 == ts1
132
+        assert ts0 != ts2
133
+        assert ts1 != ts2
134
+        strings = {ts0}
135
+        strings.add(ts1)
136
+        assert len(strings) == 1
137
+        strings.add(ts2)
138
+        assert len(strings) == 2
139
+
140
+    @hypothesis.given(
141
+        value=strategies.sampled_from(known_fields_error_messages),
142
+        errnos=strategies.lists(
143
+            strategies.sampled_from(error_codes),
144
+            min_size=2,
145
+            max_size=2,
146
+            unique=True,
147
+        ),
148
+    )
149
+    def test_101a_translated_strings_operations_interpolated(
150
+        self,
151
+        value: msg.ErrMsgTemplate,
152
+        errnos: list[int],
153
+    ) -> None:
154
+        assert len(errnos) == 2
155
+        error1, error2 = [os.strerror(c) for c in errnos]
156
+        ts1 = msg.TranslatedString(
157
+            value, error=error1, filename=None
158
+        ).maybe_without_filename()
159
+        ts2 = msg.TranslatedString(
160
+            value, error=error2, filename=None
161
+        ).maybe_without_filename()
162
+        assert str(ts1) != str(ts2)
163
+        assert ts1 != ts2
164
+        assert len({ts1, ts2}) == 2
165
+
166
+    @hypothesis.given(
167
+        value=strategies.sampled_from(known_fields_error_messages),
168
+        errno_=strategies.sampled_from(error_codes),
169
+    )
170
+    def test_101b_translated_strings_operations_interpolated(
171
+        self,
172
+        value: msg.ErrMsgTemplate,
173
+        errno_: int,
174
+    ) -> None:
175
+        error = os.strerror(errno_)
176
+        # The debug translations specifically do *not* differ in output
177
+        # when the filename is trimmed.  So we need to request some
178
+        # other predictable, non-debug output.
179
+        #
180
+        # Also, because of the class-scoped fixture, and because
181
+        # hypothesis interferes with a function-scoped fixture, we also
182
+        # need to do our own manual monkeypatching here, separately, for
183
+        # each hypothesis iteration.
184
+        with monkeypatched_null_translations():
185
+            ts0 = msg.TranslatedString(value, error=error, filename=None)
186
+            ts1 = ts0.maybe_without_filename()
187
+            assert str(ts0) != str(ts1)
188
+            assert ts0 != ts1
189
+            assert len({ts0, ts1}) == 2
190
+
191
+    @pytest.mark.parametrize('s', ['{spam}', '{spam}abc', '{', '}', '{{{'])
192
+    def test_102_translated_strings_suppressed_interpolation_fail(
193
+        self,
194
+        s: str,
195
+    ) -> None:
196
+        with monkeypatched_null_translations():
197
+            ts1 = msg.TranslatedString(s)
198
+            with pytest.raises((KeyError, ValueError)) as excinfo:
199
+                str(ts1)
200
+            if '{spam}' in s:
201
+                assert isinstance(excinfo.value, KeyError)
202
+                assert excinfo.value.args[0] == 'spam'
203
+            else:
204
+                assert isinstance(excinfo.value, ValueError)
205
+                assert excinfo.value.args[0].startswith('Single ')
206
+                assert excinfo.value.args[0].endswith(
207
+                    ' encountered in format string'
208
+                )
209
+            ts2 = msg.TranslatedString(s, spam='eggs')
210
+            try:
211
+                assert str(ts2) == s.replace('{spam}', 'eggs')
212
+            except ValueError as exc:
213
+                assert exc.args[0].startswith('Single ')  # noqa: PT017
214
+                assert exc.args[0].endswith(  # noqa: PT017
215
+                    ' encountered in format string'
216
+                )
217
+
218
+    @hypothesis.given(
219
+        s=strategies.text(
220
+            strategies.sampled_from(string.ascii_lowercase + '{}'),
221
+            min_size=1,
222
+            max_size=20,
223
+        )
224
+    )
225
+    def test_102a_translated_strings_suppressed_interpolation_str(
226
+        self,
227
+        s: str,
228
+    ) -> None:
229
+        with monkeypatched_null_translations():
230
+            ts = msg.TranslatedString.constant(s)
231
+            try:
232
+                assert str(ts) == s
233
+            except ValueError as exc:  # pragma: no cover
234
+                # Not a test error (= test author's fault), but
235
+                # a regression (= code under test is at fault).
236
+                err_msg = 'Interpolation attempted'
237
+                raise AssertionError(err_msg) from exc
238
+
239
+    @hypothesis.given(
240
+        s=strategies.text(
241
+            strategies.sampled_from(string.ascii_lowercase + '{}'),
242
+            min_size=1,
243
+            max_size=20,
244
+        )
245
+    )
246
+    def test_102b_translated_strings_suppressed_interpolation_ts_manual(
247
+        self,
248
+        s: str,
249
+    ) -> None:
250
+        with monkeypatched_null_translations():
251
+            ts_inner = msg.TranslatableString(
252
+                '',
253
+                '{spam}' + s,
254
+                flags=frozenset({'no-python-brace-format'}),
255
+            )
256
+            ts = msg.TranslatedString(ts_inner, spam='eggs')
257
+            try:
258
+                assert str(ts) == '{spam}' + s
259
+            except ValueError as exc:  # pragma: no cover
260
+                # Not a test error (= test author's fault), but
261
+                # a regression (= code under test is at fault).
262
+                err_msg = 'Interpolation attempted'
263
+                raise AssertionError(err_msg) from exc
0 264