From f9db4c5ad0a46b5ed314c72ec7ea7500faf0e2bb Mon Sep 17 00:00:00 2001 From: Ee Durbin Date: Thu, 17 Nov 2022 12:48:04 -0500 Subject: [PATCH] implement a policy to fallback to untranslated string when interpolation fails --- src/jinja2/defaults.py | 1 + src/jinja2/ext.py | 80 ++++++++++++++++++++++++++++++++---------- tests/test_ext.py | 17 +++++++++ 3 files changed, 80 insertions(+), 18 deletions(-) diff --git a/src/jinja2/defaults.py b/src/jinja2/defaults.py index 638cad3d2..0e09ab81a 100644 --- a/src/jinja2/defaults.py +++ b/src/jinja2/defaults.py @@ -45,4 +45,5 @@ "json.dumps_function": None, "json.dumps_kwargs": {"sort_keys": True}, "ext.i18n.trimmed": False, + "ext.i18n.newstyle_fallback_interpolation": False, } diff --git a/src/jinja2/ext.py b/src/jinja2/ext.py index d5550540c..4170fe128 100644 --- a/src/jinja2/ext.py +++ b/src/jinja2/ext.py @@ -167,21 +167,30 @@ def _gettext_alias( return __context.call(__context.resolve("gettext"), *args, **kwargs) -def _make_new_gettext(func: t.Callable[[str], str]) -> t.Callable[..., str]: +def _make_new_gettext( + func: t.Callable[[str], str], newstyle_fallback_interpolation: bool +) -> t.Callable[..., str]: @pass_context def gettext(__context: Context, __string: str, **variables: t.Any) -> str: rv = __context.call(func, __string) if __context.eval_ctx.autoescape: rv = Markup(rv) - # Always treat as a format string, even if there are no - # variables. This makes translation strings more consistent - # and predictable. This requires escaping - return rv % variables # type: ignore + try: + # Always treat as a format string, even if there are no + # variables. This makes translation strings more consistent + # and predictable. This requires escaping + return rv % variables # type: ignore + except KeyError: + if newstyle_fallback_interpolation: + return __string % variables + raise return gettext -def _make_new_ngettext(func: t.Callable[[str, str, int], str]) -> t.Callable[..., str]: +def _make_new_ngettext( + func: t.Callable[[str, str, int], str], newstyle_fallback_interpolation: bool +) -> t.Callable[..., str]: @pass_context def ngettext( __context: Context, @@ -194,13 +203,22 @@ def ngettext( rv = __context.call(func, __singular, __plural, __num) if __context.eval_ctx.autoescape: rv = Markup(rv) - # Always treat as a format string, see gettext comment above. - return rv % variables # type: ignore + try: + # Always treat as a format string, see gettext comment above. + return rv % variables # type: ignore + except KeyError: + if newstyle_fallback_interpolation: + if __num > 1: + return __singular % variables + return __plural % variables + raise return ngettext -def _make_new_pgettext(func: t.Callable[[str, str], str]) -> t.Callable[..., str]: +def _make_new_pgettext( + func: t.Callable[[str, str], str], newstyle_fallback_interpolation: bool +) -> t.Callable[..., str]: @pass_context def pgettext( __context: Context, __string_ctx: str, __string: str, **variables: t.Any @@ -211,14 +229,19 @@ def pgettext( if __context.eval_ctx.autoescape: rv = Markup(rv) - # Always treat as a format string, see gettext comment above. - return rv % variables # type: ignore + try: + # Always treat as a format string, see gettext comment above. + return rv % variables # type: ignore + except KeyError: + if newstyle_fallback_interpolation: + return __string % variables + raise return pgettext def _make_new_npgettext( - func: t.Callable[[str, str, str, int], str] + func: t.Callable[[str, str, str, int], str], newstyle_fallback_interpolation: bool ) -> t.Callable[..., str]: @pass_context def npgettext( @@ -236,8 +259,15 @@ def npgettext( if __context.eval_ctx.autoescape: rv = Markup(rv) - # Always treat as a format string, see gettext comment above. - return rv % variables # type: ignore + try: + # Always treat as a format string, see gettext comment above. + return rv % variables # type: ignore + except KeyError: + if newstyle_fallback_interpolation: + if __num > 1: + return __singular % variables + return __plural % variables + raise return npgettext @@ -320,17 +350,31 @@ def _install_callables( pgettext: t.Optional[t.Callable[[str, str], str]] = None, npgettext: t.Optional[t.Callable[[str, str, str, int], str]] = None, ) -> None: + newstyle_fallback_interpolation = self.environment.policies[ + "ext.i18n.newstyle_fallback_interpolation" + ] if newstyle is not None: self.environment.newstyle_gettext = newstyle # type: ignore if self.environment.newstyle_gettext: # type: ignore - gettext = _make_new_gettext(gettext) - ngettext = _make_new_ngettext(ngettext) + gettext = _make_new_gettext( + gettext, newstyle_fallback_interpolation=newstyle_fallback_interpolation + ) + ngettext = _make_new_ngettext( + ngettext, + newstyle_fallback_interpolation=newstyle_fallback_interpolation, + ) if pgettext is not None: - pgettext = _make_new_pgettext(pgettext) + pgettext = _make_new_pgettext( + pgettext, + newstyle_fallback_interpolation=newstyle_fallback_interpolation, + ) if npgettext is not None: - npgettext = _make_new_npgettext(npgettext) + npgettext = _make_new_npgettext( + npgettext, + newstyle_fallback_interpolation=newstyle_fallback_interpolation, + ) self.environment.globals.update( gettext=gettext, ngettext=ngettext, pgettext=pgettext, npgettext=npgettext diff --git a/tests/test_ext.py b/tests/test_ext.py index 2e842e0ab..88f63397c 100644 --- a/tests/test_ext.py +++ b/tests/test_ext.py @@ -52,6 +52,7 @@ "novars.html": "{% trans %}%(hello)s{% endtrans %}", "vars.html": "{% trans %}{{ foo }}%(foo)s{% endtrans %}", "explicitvars.html": '{% trans foo="42" %}%(foo)s{% endtrans %}', + "broken_interpolation.html": "{% trans %}Username: {{ username }}{% endtrans %}", } @@ -66,6 +67,7 @@ "Apple": {None: "Apfel", "fruit": "Apple"}, "%(num)s apple": {None: "%(num)s Apfel", "fruit": "%(num)s Apple"}, "%(num)s apples": {None: "%(num)s Äpfel", "fruit": "%(num)s Apples"}, + "Username: %(username)s": "Nutzername: %(user_name)", } } @@ -147,6 +149,14 @@ def npgettext(context, c, s, p, n): gettext, ngettext, newstyle=True, pgettext=pgettext, npgettext=npgettext ) +newstyle_i18n_env_fallback = Environment( + loader=DictLoader(newstyle_i18n_templates), extensions=["jinja2.ext.i18n"] +) +newstyle_i18n_env_fallback.policies["ext.i18n.newstyle_fallback_interpolation"] = True +newstyle_i18n_env_fallback.install_gettext_callables( # type: ignore + gettext, ngettext, newstyle=True, pgettext=pgettext, npgettext=npgettext +) + class ExampleExtension(Extension): tags = {"test"} @@ -610,6 +620,13 @@ def test_context_plural_block(self): assert tmpl.render(LANGUAGE="de", apples=1) == "1 Apple" assert tmpl.render(LANGUAGE="de", apples=5) == "5 Apples" + def test_broken_translation_interpolation(self): + tmpl = newstyle_i18n_env.get_template("broken_interpolation.html") + with pytest.raises(KeyError): + tmpl.render(LANGUAGE="de", username="NewUser") + tmpl = newstyle_i18n_env_fallback.get_template("broken_interpolation.html") + assert tmpl.render(LANGUAGE="de", username="NewUser") == "Username: NewUser" + class TestAutoEscape: def test_scoped_setting(self):