Move translation string preparation into the TranslatableString class
Marco Ricci

Marco Ricci commited on 2025-01-13 15:49:08
Zeige 1 geänderte Dateien mit 125 Einfügungen und 33 Löschungen.


Save for a few factory functions to make enum definitions more readable,
the normalization machinery for translatable strings belongs in their
class.  I originally coded this separately because I mistakenly believed
that `typing.NamedTuple` classes cannot contain methods of their own.
... ...
@@ -206,6 +206,30 @@ class TranslatableString(NamedTuple):
206 206
     translator_comments: str
207 207
     flags: frozenset[str]
208 208
 
209
+    @staticmethod
210
+    def _maybe_rewrap(
211
+        string: str,
212
+        /,
213
+        *,
214
+        fix_sentence_endings: bool = True,
215
+    ) -> str:
216
+        string = inspect.cleandoc(string)
217
+        if not any(s.strip() == '\b' for s in string.splitlines()):
218
+            string = '\n'.join(
219
+                textwrap.wrap(
220
+                    string,
221
+                    width=float('inf'),  # type: ignore[arg-type]
222
+                    fix_sentence_endings=fix_sentence_endings,
223
+                )
224
+            )
225
+        else:
226
+            string = ''.join(
227
+                s
228
+                for s in string.splitlines(True)  # noqa: FBT003
229
+                if s.strip() != '\b'
230
+            )
231
+        return string
232
+
209 233
     def maybe_without_filename(self) -> Self:
210 234
         """Return a new translatable string without the "filename" field.
211 235
 
... ...
@@ -226,6 +250,83 @@ class TranslatableString(NamedTuple):
226 250
             ret = ret._replace(plural=(c + d))
227 251
         return ret
228 252
 
253
+    def rewrapped(self) -> Self:
254
+        """Return a rewrapped version of self.
255
+
256
+        Normalizes all parts assumed to contain English prose.
257
+
258
+        """
259
+        msg = self._maybe_rewrap(self.singular, fix_sentence_endings=True)
260
+        plural = self._maybe_rewrap(self.plural, fix_sentence_endings=True)
261
+        context = self.l10n_context.strip()
262
+        comments = self._maybe_rewrap(
263
+            self.translator_comments, fix_sentence_endings=False
264
+        )
265
+        return self._replace(
266
+            singular=msg,
267
+            plural=plural,
268
+            l10n_context=context,
269
+            translator_comments=comments,
270
+        )
271
+
272
+    def with_comments(self, comments: str, /) -> Self:
273
+        """Add or replace the string's translator comments.
274
+
275
+        The comments are assumed to contain English prose, and will be
276
+        normalized.
277
+
278
+        Returns:
279
+            A new [`TranslatableString`][] with the specified comments.
280
+
281
+        """
282
+        if not comments.lstrip().startswith(  # pragma: no cover
283
+            'TRANSLATORS:'
284
+        ):
285
+            comments = 'TRANSLATORS: ' + comments.lstrip()
286
+        comments = self._maybe_rewrap(comments, fix_sentence_endings=False)
287
+        return self._replace(translator_comments=comments)
288
+
289
+    def validate_flags(self, *extra_flags: str) -> Self:
290
+        """Add all flags, then validate them against the string.
291
+
292
+        Returns:
293
+            A new [`TranslatableString`][] with the extra flags added,
294
+            and all flags validated.
295
+
296
+        Raises:
297
+            ValueError:
298
+                The flags failed to validate.  See the exact error
299
+                message for details.
300
+
301
+        """
302
+        all_flags = frozenset(
303
+            f.strip() for f in self.flags.union(extra_flags)
304
+        )
305
+        if '{' in self.singular and not bool(
306
+            all_flags & {'python-brace-format', 'no-python-brace-format'}
307
+        ):
308
+            msg = (
309
+                f'Missing flag for how to deal with brace character '
310
+                f'in {self.singular!r}'
311
+            )
312
+            raise ValueError(msg)
313
+        if '%' in self.singular and not bool(
314
+            all_flags & {'python-format', 'no-python-format'}
315
+        ):
316
+            msg = (
317
+                f'Missing flag for how to deal with percent character '
318
+                f'in {self.singular!r}'
319
+            )
320
+            raise ValueError(msg)
321
+        if (
322
+            all_flags & {'python-format', 'python-brace-format'}
323
+            and '%' not in self.singular
324
+            and '{' not in self.singular
325
+        ):
326
+            msg = f'Missing format string parameters in {self.singular!r}'
327
+            raise ValueError(msg)
328
+        return self._replace(flags=all_flags)
329
+
229 330
 
230 331
 def _prepare_translatable(
231 332
     msg: str,
... ...
@@ -235,45 +336,36 @@ def _prepare_translatable(
235 336
     *,
236 337
     flags: Iterable[str] = (),
237 338
 ) -> TranslatableString:
238
-    def maybe_rewrap(string: str) -> str:
239
-        string = inspect.cleandoc(string)
240
-        if not any(s.strip() == '\b' for s in string.splitlines()):
241
-            string = '\n'.join(
242
-                textwrap.wrap(
243
-                    string,
244
-                    width=float('inf'),  # type: ignore[arg-type]
245
-                    fix_sentence_endings=True,
339
+    return translatable(
340
+        context, msg, plural=plural, comments=comments, flags=flags
246 341
     )
247
-            )
248
-        else:
249
-            string = ''.join(
250
-                s
251
-                for s in string.splitlines(True)  # noqa: FBT003
252
-                if s.strip() != '\b'
253
-            )
254
-        return string
255 342
 
256
-    msg = maybe_rewrap(msg)
257
-    plural_msg = maybe_rewrap(plural_msg)
258
-    context = context.strip()
259
-    comments = inspect.cleandoc(comments)
343
+
344
+def translatable(
345
+    context: str,
346
+    single: str,
347
+    /,
348
+    flags: Iterable[str] = (),
349
+    plural: str = '',
350
+    comments: str = '',
351
+) -> TranslatableString:
352
+    """Return a [`TranslatableString`][] with validated parts.
353
+
354
+    This factory function is really only there to make the enum
355
+    definitions more readable.
356
+
357
+    """
260 358
     flags = (
261
-        frozenset(f.strip() for f in flags)
359
+        frozenset(flags)
262 360
         if not isinstance(flags, str)
263 361
         else frozenset({flags})
264 362
     )
265
-    assert '{' not in msg or bool(
266
-        flags & {'python-brace-format', 'no-python-brace-format'}
267
-    ), f'Missing flag for how to deal with brace in {msg!r}'
268
-    assert '%' not in msg or bool(
269
-        flags & {'python-format', 'no-python-format'}
270
-    ), f'Missing flag for how to deal with percent character in {msg!r}'
271
-    assert (
272
-        not flags & {'python-format', 'python-brace-format'}
273
-        or '%' in msg
274
-        or '{' in msg
275
-    ), f'Missing format string parameters in {msg!r}'
276
-    return TranslatableString(msg, plural_msg, context, comments, flags)
363
+    return (
364
+        TranslatableString(context, single, plural=plural, flags=flags)
365
+        .rewrapped()
366
+        .with_comments(comments)
367
+        .validate_flags()
368
+    )
277 369
 
278 370
 
279 371
 class TranslatedString:
280 372