Skip to content

Commit 2b7fa77

Browse files
committed
Add support for t-string gettext translation
1 parent 68cfc1a commit 2b7fa77

File tree

2 files changed

+388
-10
lines changed

2 files changed

+388
-10
lines changed

Lib/gettext.py

Lines changed: 139 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@
4747
import operator
4848
import os
4949
import sys
50+
from functools import cache
51+
from string.templatelib import Interpolation, Template, convert
52+
from typing import Any
5053

5154

5255
__all__ = ['NullTranslations', 'GNUTranslations', 'Catalog',
@@ -291,11 +294,23 @@ def add_fallback(self, fallback):
291294
def gettext(self, message):
292295
if self._fallback:
293296
return self._fallback.gettext(message)
297+
if isinstance(message, Template):
298+
message, values = _template_to_format(message)
299+
return message.format(**values)
294300
return message
295301

296302
def ngettext(self, msgid1, msgid2, n):
297303
if self._fallback:
298304
return self._fallback.ngettext(msgid1, msgid2, n)
305+
msgid1_is_template = isinstance(msgid1, Template)
306+
msgid2_is_template = isinstance(msgid2, Template)
307+
if msgid1_is_template and msgid2_is_template:
308+
message, values = _template_to_format(
309+
msgid1 if n == 1 else msgid2
310+
)
311+
return message.format(**values)
312+
elif msgid1_is_template or msgid2_is_template:
313+
raise TypeError('msgids cannot mix strings and t-strings')
299314
n = _as_int2(n)
300315
if n == 1:
301316
return msgid1
@@ -305,11 +320,23 @@ def ngettext(self, msgid1, msgid2, n):
305320
def pgettext(self, context, message):
306321
if self._fallback:
307322
return self._fallback.pgettext(context, message)
323+
if isinstance(message, Template):
324+
message, values = _template_to_format(message)
325+
return message.format(**values)
308326
return message
309327

310328
def npgettext(self, context, msgid1, msgid2, n):
311329
if self._fallback:
312330
return self._fallback.npgettext(context, msgid1, msgid2, n)
331+
msgid1_is_template = isinstance(msgid1, Template)
332+
msgid2_is_template = isinstance(msgid2, Template)
333+
if msgid1_is_template and msgid2_is_template:
334+
message, values = _template_to_format(
335+
msgid1 if n == 1 else msgid2
336+
)
337+
return message.format(**values)
338+
elif msgid1_is_template or msgid2_is_template:
339+
raise TypeError('msgids cannot mix strings and t-strings')
313340
n = _as_int2(n)
314341
if n == 1:
315342
return msgid1
@@ -438,50 +465,104 @@ def _parse(self, fp):
438465

439466
def gettext(self, message):
440467
missing = object()
468+
orig_message = message
469+
t_values = None
470+
if isinstance(message, Template):
471+
message, t_values = _template_to_format(message)
441472
tmsg = self._catalog.get(message, missing)
442473
if tmsg is missing:
443474
tmsg = self._catalog.get((message, self.plural(1)), missing)
444475
if tmsg is not missing:
476+
if t_values is not None:
477+
return tmsg.format(**t_values)
445478
return tmsg
446479
if self._fallback:
447-
return self._fallback.gettext(message)
480+
return self._fallback.gettext(orig_message)
481+
if t_values is not None:
482+
return message.format(**t_values)
448483
return message
449484

450485
def ngettext(self, msgid1, msgid2, n):
486+
orig_msgid1 = msgid1
487+
orig_msgid2 = msgid2
488+
msgid1_is_template = isinstance(msgid1, Template)
489+
msgid2_is_template = isinstance(msgid2, Template)
490+
t_values1 = t_values2 = None
491+
if msgid1_is_template and msgid2_is_template:
492+
msgid1, t_values1 = _template_to_format(msgid1)
493+
msgid2, t_values2 = _template_to_format(msgid2)
494+
elif msgid1_is_template or msgid2_is_template:
495+
raise TypeError('msgids cannot mix strings and t-strings')
496+
plural = self.plural(n)
497+
t_values = t_values2 if plural else t_values1
451498
try:
452-
tmsg = self._catalog[(msgid1, self.plural(n))]
499+
tmsg = self._catalog[(msgid1, plural)]
453500
except KeyError:
454501
if self._fallback:
455-
return self._fallback.ngettext(msgid1, msgid2, n)
502+
return self._fallback.ngettext(orig_msgid1, orig_msgid2, n)
456503
if n == 1:
457-
tmsg = msgid1
504+
if t_values1 is not None:
505+
return msgid1.format(**t_values1)
506+
return msgid1
458507
else:
459-
tmsg = msgid2
508+
if t_values2 is not None:
509+
return msgid2.format(**t_values2)
510+
return msgid2
511+
if t_values is not None:
512+
return tmsg.format(**t_values)
460513
return tmsg
461514

462515
def pgettext(self, context, message):
516+
orig_message = message
517+
t_values = None
518+
if isinstance(message, Template):
519+
message, t_values = _template_to_format(message)
463520
ctxt_msg_id = self.CONTEXT % (context, message)
464521
missing = object()
465522
tmsg = self._catalog.get(ctxt_msg_id, missing)
466523
if tmsg is missing:
467524
tmsg = self._catalog.get((ctxt_msg_id, self.plural(1)), missing)
468525
if tmsg is not missing:
526+
if t_values is not None:
527+
return tmsg.format(**t_values)
469528
return tmsg
470529
if self._fallback:
471-
return self._fallback.pgettext(context, message)
530+
return self._fallback.pgettext(context, orig_message)
531+
if t_values is not None:
532+
return message.format(**t_values)
472533
return message
473534

474535
def npgettext(self, context, msgid1, msgid2, n):
536+
orig_msgid1 = msgid1
537+
orig_msgid2 = msgid2
538+
msgid1_is_template = isinstance(msgid1, Template)
539+
msgid2_is_template = isinstance(msgid2, Template)
540+
t_values1 = t_values2 = None
541+
if msgid1_is_template and msgid2_is_template:
542+
msgid1, t_values1 = _template_to_format(msgid1)
543+
msgid2, t_values2 = _template_to_format(msgid2)
544+
elif msgid1_is_template or msgid2_is_template:
545+
raise TypeError('msgids cannot mix strings and t-strings')
546+
plural = self.plural(n)
547+
t_values = t_values2 if plural else t_values1
475548
ctxt_msg_id = self.CONTEXT % (context, msgid1)
476549
try:
477-
tmsg = self._catalog[ctxt_msg_id, self.plural(n)]
550+
tmsg = self._catalog[ctxt_msg_id, plural]
478551
except KeyError:
479552
if self._fallback:
480-
return self._fallback.npgettext(context, msgid1, msgid2, n)
553+
return self._fallback.npgettext(
554+
context, orig_msgid1, orig_msgid2, n
555+
)
481556
if n == 1:
482-
tmsg = msgid1
557+
if t_values1 is not None:
558+
return msgid1.format(**t_values1)
559+
return msgid1
483560
else:
484-
tmsg = msgid2
561+
if t_values2 is not None:
562+
return msgid2.format(**t_values2)
563+
return msgid2
564+
if t_values is not None:
565+
return tmsg.format(**t_values)
485566
return tmsg
486567

487568

@@ -749,3 +830,51 @@ def _template_node_to_format(node: ast.TemplateStr) -> str:
749830
interpolation_format_names[name] = expr
750831
parts.append(f'{{{name}}}')
751832
return ''.join(parts)
833+
834+
835+
def _template_to_format(template: Template) -> tuple[str, dict[str, Any]]:
836+
"""Convert a template to a format string and its value dict.
837+
838+
This takes a :class:`~string.templatelib.Template`, and converts all the
839+
interpolations with format string placeholders derived from the original
840+
expression.
841+
842+
This fails with a :exc:`_NameTooComplexError` in case the expression is
843+
not suitable for conversion.
844+
"""
845+
parts = []
846+
interpolation_format_names = {}
847+
values = {}
848+
for item in template:
849+
match item:
850+
case str() as s:
851+
parts.append(s.replace('{', '{{').replace('}', '}}'))
852+
case Interpolation(value, expr, conversion, format_spec):
853+
value = convert(value, conversion)
854+
value = format(value, format_spec)
855+
name = _expr_to_format_field_name(expr)
856+
if (
857+
existing_expr := interpolation_format_names.get(name)
858+
) and existing_expr != expr:
859+
raise _NameTooComplexError(
860+
f'Interpolations of {existing_expr} and {expr} cannot '
861+
'be mixed in the same gettext call; assign one of '
862+
'them to a variable and use that instead'
863+
)
864+
interpolation_format_names[name] = expr
865+
values[name] = value
866+
parts.append(f'{{{name}}}')
867+
return ''.join(parts), values
868+
869+
870+
@cache
871+
def _expr_to_format_field_name(expr: str) -> str:
872+
# handle simple cases w/o the overhead of dealing with an ast
873+
if expr.isidentifier():
874+
return expr
875+
if all(x.isidentifier() for x in expr.split('.')):
876+
return '__'.join(expr.split('.'))
877+
expr_node = ast.parse(expr, mode='eval').body
878+
visitor = _ExtractNamesVisitor()
879+
visitor.visit(expr_node)
880+
return visitor.name

0 commit comments

Comments
 (0)