Properly support trimmed filenames in translation strings
Marco Ricci

Marco Ricci commited on 2025-01-13 14:50:03
Zeige 1 geänderte Dateien mit 58 Einfügungen und 19 Löschungen.


The debug translations object and the `.po` file writer now properly
support translation strings with the filename portion trimmed.
Effectively, these are separate translation strings, and as such they
need separate cache entries and separate `.po` entries.

To fully support this, the translatable string itself now knows how to
trim the filename replacement field, which the debug translations object
now honors.  The `.po` writer still needs the enum name to construct the
message ID, but now it takes an optional transformed string to use
instead of the enum value, if given.

One additional minor fix to the debug translator: some of the emitted
translated messages interpolate other translated messages (such as
metavars).  Thus when interpolating arguments, if the argument is
a translated string, then stringify it before interpolation.  This
avoids printing the `repr` of the inner translated string, which is
otherwise very common for debug translation output.
... ...
@@ -107,9 +107,11 @@ class DebugTranslations(gettext.NullTranslations):
107 107
         for enum_class in MSG_TEMPLATE_CLASSES:
108 108
             for member in enum_class.__members__.values():
109 109
                 value = cast('TranslatableString', member.value)
110
-                singular = value.singular
111
-                plural = value.plural
112
-                context = value.l10n_context
110
+                value2 = value.maybe_without_filename()
111
+                for v in {value, value2}:
112
+                    singular = v.singular
113
+                    plural = v.plural
114
+                    context = v.l10n_context
113 115
                     cache.setdefault((context, singular), member)
114 116
                     if plural:
115 117
                         cache.setdefault((context, plural), member)
... ...
@@ -204,6 +206,26 @@ class TranslatableString(NamedTuple):
204 206
     translator_comments: str
205 207
     flags: frozenset[str]
206 208
 
209
+    def maybe_without_filename(self) -> Self:
210
+        """Return a new translatable string without the "filename" field.
211
+
212
+        Only acts upon translatable strings containing the exact
213
+        contents `": {filename!r}"`.  The specified part will be
214
+        removed.  This is correct usage in English for messages like
215
+        `"Cannot open file: {error!s}: {filename!r}."`, but not
216
+        necessarily in other languages.
217
+
218
+        """
219
+        filename_str = ': {filename!r}'
220
+        ret = self
221
+        a, sep1, b = self.singular.partition(filename_str)
222
+        c, sep2, d = self.plural.partition(filename_str)
223
+        if sep1:
224
+            ret = ret._replace(singular=(a + b))
225
+        if sep2:
226
+            ret = ret._replace(plural=(c + d))
227
+        return ret
228
+
207 229
 
208 230
 def _prepare_translatable(
209 231
     msg: str,
... ...
@@ -300,27 +322,33 @@ class TranslatedString:
300 322
                 template = translation.pgettext(context, template)
301 323
             else:  # pragma: no cover
302 324
                 template = translation.gettext(template)
303
-            self._rendered = template.format(**self.kwargs)
325
+            kwargs = {
326
+                k: str(v) if isinstance(v, TranslatedString) else v
327
+                for k, v in self.kwargs.items()
328
+            }
329
+            self._rendered = template.format(**kwargs)
304 330
         return self._rendered
305 331
 
306 332
     def maybe_without_filename(self) -> Self:
333
+        """Return a new string without the "filename" field.
334
+
335
+        Only acts upon translated strings containing the exact contents
336
+        `": {filename!r}"`.  The specified part will be removed.  This
337
+        acts upon the string *before* translation, i.e., the string
338
+        without the filename will be used as a translation base.
339
+
340
+        """
341
+        new_template = (
342
+            self.template.maybe_without_filename()
343
+            if not isinstance(self.template, str)
344
+            else self.template
345
+        )
307 346
         if (
308
-            not isinstance(self.template, str)
347
+            not isinstance(new_template, str)
309 348
             and self.kwargs.get('filename') is None
310
-            and ': {filename!r}' in self.template.singular
349
+            and new_template != self.template
311 350
         ):
312
-            singular = ''.join(
313
-                self.template.singular.split(': {filename!r}', 1)
314
-            )
315
-            plural = (
316
-                ''.join(self.template.plural.split(': {filename!r}', 1))
317
-                if self.template.plural
318
-                else self.template.plural
319
-            )
320
-            return self.__class__(
321
-                self.template._replace(singular=singular, plural=plural),
322
-                self.kwargs,
323
-            )
351
+            return self.__class__(new_template, self.kwargs)
324 352
         return self
325 353
 
326 354
 
... ...
@@ -1878,11 +1906,21 @@ def _write_po_file(  # noqa: C901
1878 1906
             subdict.items(),
1879 1907
             key=lambda kv: str(kv[1]),
1880 1908
         ):
1909
+            value = cast('TranslatableString', enum_value.value)
1910
+            value2 = value.maybe_without_filename()
1881 1911
             fileobj.writelines(
1882 1912
                 _format_po_entry(
1883 1913
                     enum_value, is_debug_translation=not is_template
1884 1914
                 )
1885 1915
             )
1916
+            if value != value2:
1917
+                fileobj.writelines(
1918
+                    _format_po_entry(
1919
+                        enum_value,
1920
+                        is_debug_translation=not is_template,
1921
+                        transformed_string=value2,
1922
+                    )
1923
+                )
1886 1924
 
1887 1925
 
1888 1926
 def _format_po_info(
... ...
@@ -1921,9 +1959,10 @@ def _format_po_entry(
1921 1959
     /,
1922 1960
     *,
1923 1961
     is_debug_translation: bool = False,
1962
+    transformed_string: TranslatableString | None = None,
1924 1963
 ) -> tuple[str, ...]:  # pragma: no cover
1925 1964
     ret: list[str] = ['\n']
1926
-    ts = enum_value.value
1965
+    ts = transformed_string or cast('TranslatableString', enum_value.value)
1927 1966
     if ts.translator_comments:
1928 1967
         comments = ts.translator_comments.splitlines(False)  # noqa: FBT003
1929 1968
         comments.extend(['', f'Message-ID: {enum_value}'])
1930 1969