From 409bfe5796840ebecae3ad32786c451b1337cbea Mon Sep 17 00:00:00 2001 From: Maximilian Haye Date: Tue, 4 Mar 2025 17:44:57 +0100 Subject: [PATCH] refactor: rethink i18n function names --- examples/i18n/locale/de.po | 53 +++++++++--------- examples/i18n/locale/en.po | 57 ++++++++++---------- examples/i18n/locale/local.i18n.pot | 57 ++++++++++---------- examples/i18n/python/local/i18n/__init__.py | 40 ++++++++------ examples/i18n/templates/formulation.xhtml.j2 | 13 +++-- questionpy/_ui.py | 22 ++++++++ questionpy/i18n.py | 52 +++++++++++++----- questionpy_sdk/commands/i18n/_extract.py | 27 +++++++++- 8 files changed, 202 insertions(+), 119 deletions(-) diff --git a/examples/i18n/locale/de.po b/examples/i18n/locale/de.po index c61d473..4fa23e0 100644 --- a/examples/i18n/locale/de.po +++ b/examples/i18n/locale/de.po @@ -8,8 +8,8 @@ msgid "" msgstr "" "Project-Id-Version: @local/i18n 0.1.0\n" "Report-Msgid-Bugs-To: Jane Doe \n" -"POT-Creation-Date: 2025-02-24 15:42+0100\n" -"PO-Revision-Date: 2025-02-24 15:56+0100\n" +"POT-Creation-Date: 2025-03-04 17:05+0100\n" +"PO-Revision-Date: 2025-03-04 17:40+0100\n" "Last-Translator: \n" "Language-Team: de \n" "Language: de\n" @@ -20,29 +20,35 @@ msgstr "" "Generated-By: Babel 2.17.0\n" "X-Generator: Poedit 3.5\n" -#: python/local/i18n/__init__.py:14 +#: python/local/i18n/__init__.py:11 +msgid "I'm marked using a no-op function and later dynamically translated." +msgstr "" +"Ich bin mit einer no-op-Funktion markiert und werde später dynamisch " +"übersetzt." + +#: python/local/i18n/__init__.py:15 msgid "Status of the file" msgstr "Status der Datei" -#: python/local/i18n/__init__.py:14 +#: python/local/i18n/__init__.py:15 msgctxt "File" msgid "Opened" msgstr "Geöffnet" -#: python/local/i18n/__init__.py:16 +#: python/local/i18n/__init__.py:17 msgid "Status of the amusement park" msgstr "Status des Freizeitparks" -#: python/local/i18n/__init__.py:16 +#: python/local/i18n/__init__.py:17 msgctxt "Amusement Park" msgid "Opened" msgstr "Eröffnet" -#: python/local/i18n/__init__.py:20 +#: python/local/i18n/__init__.py:21 msgid "npgettext combines pluralization support and contextualization." msgstr "npgettext erlaubt sowohl Pluralisierung als auch Kontextualisierung." -#: python/local/i18n/__init__.py:21 +#: python/local/i18n/__init__.py:22 #, python-brace-format msgctxt "Home Depot" msgid "There is a light." @@ -50,7 +56,7 @@ msgid_plural "There are {} lights!" msgstr[0] "Da ist eine Lampe." msgstr[1] "Da sind {} Lampen!" -#: python/local/i18n/__init__.py:23 +#: python/local/i18n/__init__.py:24 #, python-brace-format msgctxt "Picard" msgid "There is a light." @@ -58,24 +64,24 @@ msgid_plural "There are {} lights!" msgstr[0] "Da ist ein Licht." msgstr[1] "Da sind {} Lichter!" -#: python/local/i18n/__init__.py:27 +#: python/local/i18n/__init__.py:28 msgid "First Option" msgstr "Erste Option" -#: python/local/i18n/__init__.py:28 +#: python/local/i18n/__init__.py:29 msgid "Second Option" msgstr "Zweite Option" -#: python/local/i18n/__init__.py:29 +#: python/local/i18n/__init__.py:30 msgid "Third Option" msgstr "Dritte Option" #. This comment will be shown in the .pot file. -#: python/local/i18n/__init__.py:35 +#: python/local/i18n/__init__.py:36 msgid "This is a localized FormModel." msgstr "Das ist ein lokalisiertes FormModel." -#: python/local/i18n/__init__.py:36 +#: python/local/i18n/__init__.py:37 msgid "" "The FormModel is defined before any initialization, so message translation " "needs to be deferred!" @@ -83,38 +89,34 @@ msgstr "" "Das FormModel wird vor jeder Initialisierung definiert, deshalb müssen " "Übersetzungen später geschehen!" -#: python/local/i18n/__init__.py:40 +#: python/local/i18n/__init__.py:41 msgid "This is a text_input, and its label is localized!" msgstr "Das ist ein text_input, und sein Label ist lokalisiert!" -#: python/local/i18n/__init__.py:42 +#: python/local/i18n/__init__.py:43 msgid "Even this placeholder is localized!" msgstr "Selbst dieser Platzhalter ist lokalisiert!" -#: python/local/i18n/__init__.py:43 +#: python/local/i18n/__init__.py:44 msgid "Now you can get help in your language!" msgstr "Jetzt kannst du in deiner Sprache Hilfe erhalten!" -#: python/local/i18n/__init__.py:47 +#: python/local/i18n/__init__.py:48 #, python-brace-format msgid "Choose exactly one option." msgid_plural "Choose exactly {} options." msgstr[0] "Wähle genau eine Option." msgstr[1] "Wähle genau {} Optionen." -#: python/local/i18n/__init__.py:50 +#: python/local/i18n/__init__.py:51 msgid "This section shows contextualized messages." msgstr "Dieser Abschnitt zeigt kontextualisierte Übersetzungen." -#: python/local/i18n/__init__.py:63 +#: python/local/i18n/__init__.py:64 msgid "Programmatic translation!" msgstr "Programmatische Übersetzung!" #. This comment will be shown in the .pot file. -#: templates/formulation.xhtml.j2:4 -msgid "This string should be localized!" -msgstr "Diese Zeichenkette sollte lokalisiert sein!" - #: templates/formulation.xhtml.j2:5 msgid "One Thing" msgid_plural "More Things" @@ -144,3 +146,6 @@ msgid "One Thing" msgid_plural "More Things" msgstr[0] "Eine Sache in Kontext 2" msgstr[1] "Mehrere Sachen in Kontext 2" + +#~ msgid "This string should be localized!" +#~ msgstr "Diese Zeichenkette sollte lokalisiert sein!" diff --git a/examples/i18n/locale/en.po b/examples/i18n/locale/en.po index bdfcf1a..65c1116 100644 --- a/examples/i18n/locale/en.po +++ b/examples/i18n/locale/en.po @@ -8,40 +8,45 @@ msgid "" msgstr "" "Project-Id-Version: @local/i18n 0.1.0\n" "Report-Msgid-Bugs-To: Jane Doe \n" -"POT-Creation-Date: 2025-02-24 15:42+0100\n" -"PO-Revision-Date: 2025-02-24 15:34+0100\n" -"Last-Translator: FULL NAME \n" -"Language: en\n" +"POT-Creation-Date: 2025-03-04 17:05+0100\n" +"PO-Revision-Date: 2025-03-04 17:15+0100\n" +"Last-Translator: \n" "Language-Team: en \n" -"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"Language: en\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" "Generated-By: Babel 2.17.0\n" +"X-Generator: Poedit 3.5\n" -#: python/local/i18n/__init__.py:14 +#: python/local/i18n/__init__.py:11 +msgid "I'm marked using a no-op function and later dynamically translated." +msgstr "" + +#: python/local/i18n/__init__.py:15 msgid "Status of the file" msgstr "" -#: python/local/i18n/__init__.py:14 +#: python/local/i18n/__init__.py:15 msgctxt "File" msgid "Opened" msgstr "" -#: python/local/i18n/__init__.py:16 +#: python/local/i18n/__init__.py:17 msgid "Status of the amusement park" msgstr "" -#: python/local/i18n/__init__.py:16 +#: python/local/i18n/__init__.py:17 msgctxt "Amusement Park" msgid "Opened" msgstr "" -#: python/local/i18n/__init__.py:20 +#: python/local/i18n/__init__.py:21 msgid "npgettext combines pluralization support and contextualization." msgstr "" -#: python/local/i18n/__init__.py:21 +#: python/local/i18n/__init__.py:22 #, python-brace-format msgctxt "Home Depot" msgid "There is a light." @@ -49,7 +54,7 @@ msgid_plural "There are {} lights!" msgstr[0] "" msgstr[1] "" -#: python/local/i18n/__init__.py:23 +#: python/local/i18n/__init__.py:24 #, python-brace-format msgctxt "Picard" msgid "There is a light." @@ -57,61 +62,57 @@ msgid_plural "There are {} lights!" msgstr[0] "" msgstr[1] "" -#: python/local/i18n/__init__.py:27 +#: python/local/i18n/__init__.py:28 msgid "First Option" msgstr "" -#: python/local/i18n/__init__.py:28 +#: python/local/i18n/__init__.py:29 msgid "Second Option" msgstr "" -#: python/local/i18n/__init__.py:29 +#: python/local/i18n/__init__.py:30 msgid "Third Option" msgstr "" #. This comment will be shown in the .pot file. -#: python/local/i18n/__init__.py:35 +#: python/local/i18n/__init__.py:36 msgid "This is a localized FormModel." msgstr "" -#: python/local/i18n/__init__.py:36 +#: python/local/i18n/__init__.py:37 msgid "" "The FormModel is defined before any initialization, so message " "translation needs to be deferred!" msgstr "" -#: python/local/i18n/__init__.py:40 +#: python/local/i18n/__init__.py:41 msgid "This is a text_input, and its label is localized!" msgstr "" -#: python/local/i18n/__init__.py:42 +#: python/local/i18n/__init__.py:43 msgid "Even this placeholder is localized!" msgstr "" -#: python/local/i18n/__init__.py:43 +#: python/local/i18n/__init__.py:44 msgid "Now you can get help in your language!" msgstr "" -#: python/local/i18n/__init__.py:47 +#: python/local/i18n/__init__.py:48 #, python-brace-format msgid "Choose exactly one option." msgid_plural "Choose exactly {} options." msgstr[0] "" msgstr[1] "" -#: python/local/i18n/__init__.py:50 +#: python/local/i18n/__init__.py:51 msgid "This section shows contextualized messages." msgstr "" -#: python/local/i18n/__init__.py:63 +#: python/local/i18n/__init__.py:64 msgid "Programmatic translation!" msgstr "" #. This comment will be shown in the .pot file. -#: templates/formulation.xhtml.j2:4 -msgid "This string should be localized!" -msgstr "" - #: templates/formulation.xhtml.j2:5 msgid "One Thing" msgid_plural "More Things" @@ -129,7 +130,6 @@ msgid "My Message" msgstr "" #: templates/formulation.xhtml.j2:10 templates/formulation.xhtml.j2:11 -#, fuzzy msgctxt "Context 1" msgid "One Thing" msgid_plural "More Things" @@ -137,7 +137,6 @@ msgstr[0] "" msgstr[1] "" #: templates/formulation.xhtml.j2:12 templates/formulation.xhtml.j2:13 -#, fuzzy msgctxt "Context 2" msgid "One Thing" msgid_plural "More Things" diff --git a/examples/i18n/locale/local.i18n.pot b/examples/i18n/locale/local.i18n.pot index ffe69b2..ae0bd22 100644 --- a/examples/i18n/locale/local.i18n.pot +++ b/examples/i18n/locale/local.i18n.pot @@ -9,7 +9,7 @@ msgid "" msgstr "" "Project-Id-Version: @local/i18n 0.1.0\n" "Report-Msgid-Bugs-To: Jane Doe \n" -"POT-Creation-Date: 2025-02-24 15:42+0100\n" +"POT-Creation-Date: 2025-03-04 17:37+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,29 +18,33 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.17.0\n" -#: python/local/i18n/__init__.py:14 +#: python/local/i18n/__init__.py:11 +msgid "I'm marked using a no-op function and later dynamically translated." +msgstr "" + +#: python/local/i18n/__init__.py:15 msgid "Status of the file" msgstr "" -#: python/local/i18n/__init__.py:14 +#: python/local/i18n/__init__.py:15 msgctxt "File" msgid "Opened" msgstr "" -#: python/local/i18n/__init__.py:16 +#: python/local/i18n/__init__.py:17 msgid "Status of the amusement park" msgstr "" -#: python/local/i18n/__init__.py:16 +#: python/local/i18n/__init__.py:17 msgctxt "Amusement Park" msgid "Opened" msgstr "" -#: python/local/i18n/__init__.py:20 +#: python/local/i18n/__init__.py:21 msgid "npgettext combines pluralization support and contextualization." msgstr "" -#: python/local/i18n/__init__.py:21 +#: python/local/i18n/__init__.py:22 #, python-brace-format msgctxt "Home Depot" msgid "There is a light." @@ -48,7 +52,7 @@ msgid_plural "There are {} lights!" msgstr[0] "" msgstr[1] "" -#: python/local/i18n/__init__.py:23 +#: python/local/i18n/__init__.py:24 #, python-brace-format msgctxt "Picard" msgid "There is a light." @@ -56,85 +60,80 @@ msgid_plural "There are {} lights!" msgstr[0] "" msgstr[1] "" -#: python/local/i18n/__init__.py:27 +#: python/local/i18n/__init__.py:28 msgid "First Option" msgstr "" -#: python/local/i18n/__init__.py:28 +#: python/local/i18n/__init__.py:29 msgid "Second Option" msgstr "" -#: python/local/i18n/__init__.py:29 +#: python/local/i18n/__init__.py:30 msgid "Third Option" msgstr "" #. This comment will be shown in the .pot file. -#: python/local/i18n/__init__.py:35 +#: python/local/i18n/__init__.py:36 msgid "This is a localized FormModel." msgstr "" -#: python/local/i18n/__init__.py:36 +#: python/local/i18n/__init__.py:37 msgid "" "The FormModel is defined before any initialization, so message " "translation needs to be deferred!" msgstr "" -#: python/local/i18n/__init__.py:40 +#: python/local/i18n/__init__.py:41 msgid "This is a text_input, and its label is localized!" msgstr "" -#: python/local/i18n/__init__.py:42 +#: python/local/i18n/__init__.py:43 msgid "Even this placeholder is localized!" msgstr "" -#: python/local/i18n/__init__.py:43 +#: python/local/i18n/__init__.py:44 msgid "Now you can get help in your language!" msgstr "" -#: python/local/i18n/__init__.py:47 +#: python/local/i18n/__init__.py:48 #, python-brace-format msgid "Choose exactly one option." msgid_plural "Choose exactly {} options." msgstr[0] "" msgstr[1] "" -#: python/local/i18n/__init__.py:50 +#: python/local/i18n/__init__.py:51 msgid "This section shows contextualized messages." msgstr "" -#: python/local/i18n/__init__.py:63 +#: python/local/i18n/__init__.py:64 msgid "Programmatic translation!" msgstr "" #. This comment will be shown in the .pot file. -#: templates/formulation.xhtml.j2:4 +#: templates/formulation.xhtml.j2:3 msgid "This string should be localized!" msgstr "" -#: templates/formulation.xhtml.j2:5 +#: templates/formulation.xhtml.j2:4 msgid "One Thing" msgid_plural "More Things" msgstr[0] "" msgstr[1] "" -#: templates/formulation.xhtml.j2:7 +#: templates/formulation.xhtml.j2:6 msgctxt "Context 1" msgid "My Message" msgstr "" -#: templates/formulation.xhtml.j2:7 -msgctxt "Context 2" -msgid "My Message" -msgstr "" - -#: templates/formulation.xhtml.j2:10 templates/formulation.xhtml.j2:11 +#: templates/formulation.xhtml.j2:9 msgctxt "Context 1" msgid "One Thing" msgid_plural "More Things" msgstr[0] "" msgstr[1] "" -#: templates/formulation.xhtml.j2:12 templates/formulation.xhtml.j2:13 +#: templates/formulation.xhtml.j2:12 msgctxt "Context 2" msgid "One Thing" msgid_plural "More Things" diff --git a/examples/i18n/python/local/i18n/__init__.py b/examples/i18n/python/local/i18n/__init__.py index 309166f..8489438 100644 --- a/examples/i18n/python/local/i18n/__init__.py +++ b/examples/i18n/python/local/i18n/__init__.py @@ -6,47 +6,49 @@ from questionpy_common import TranslatableString from questionpy_common.elements import StaticTextElement -_, _N = i18n.get_for(__package__) +__ = i18n.get_for(__package__) + +dynamic_message = __.gettext_noop("I'm marked using a no-op function and later dynamically translated.") class ContextSection(FormModel): - file_status: StaticTextElement = static_text(_("Status of the file"), _.pgettext("File", "Opened")) + file_status: StaticTextElement = static_text(__("Status of the file"), __.pgettext("File", "Opened")) amusement_park_status: StaticTextElement = static_text( - _("Status of the amusement park"), _.pgettext("Amusement Park", "Opened") + __("Status of the amusement park"), __.pgettext("Amusement Park", "Opened") ) home_depot: bool = checkbox( - left_label=_("npgettext combines pluralization support and contextualization."), - right_label=_.npgettext("Home Depot", "There is a light.", "There are {} lights!", 74).format(74), + left_label=__("npgettext combines pluralization support and contextualization."), + right_label=__.npgettext("Home Depot", "There is a light.", "There are {} lights!", 74).format(74), ) - picard: bool = checkbox(right_label=_.npgettext("Picard", "There is a light.", "There are {} lights!", 4).format(4)) + picard: bool = checkbox(right_label=__.np("Picard", "There is a light.", "There are {} lights!", 4).format(4)) class MyOptions(OptionEnum): - FIRST = option(_("First Option")) - SECOND = option(_("Second Option")) - THIRD = option(_("Third Option")) + FIRST = option(__("First Option")) + SECOND = option(__("Second Option")) + THIRD = option(__("Third Option")) class I18NModel(FormModel): txt: StaticTextElement = static_text( # TRANSLATORS: This comment will be shown in the .pot file. - _("This is a localized FormModel."), - _("The FormModel is defined before any initialization, so message translation needs to be deferred!"), + __("This is a localized FormModel."), + __("The FormModel is defined before any initialization, so message translation needs to be deferred!"), ) input: str = text_input( - _("This is a text_input, and its label is localized!"), + __("This is a text_input, and its label is localized!"), required=True, - placeholder=_("Even this placeholder is localized!"), - help=_("Now you can get help in your language!"), + placeholder=__("Even this placeholder is localized!"), + help=__("Now you can get help in your language!"), ) radio_group: MyOptions = radio_group( - _.ngettext("Choose exactly one option.", "Choose exactly {} options.", 1).format(1), MyOptions, required=True + __.ngettext("Choose exactly one option.", "Choose exactly {} options.", 1).format(1), MyOptions, required=True ) - context: ContextSection = section(_("This section shows contextualized messages."), ContextSection) + context: ContextSection = section(__("This section shows contextualized messages."), ContextSection) class I18NAttempt(Attempt): @@ -59,7 +61,11 @@ def formulation(self) -> str: @property def general_feedback(self) -> str | TranslatableString | None: - return "
" + _("Programmatic translation!", defer=False) + "
" + return "
" + __("Programmatic translation!", defer=False) + "
" + + @property + def specific_feedback(self) -> str | TranslatableString | None: + return "
" + __(dynamic_message, defer=False) + "
" class I18NQuestion(Question): diff --git a/examples/i18n/templates/formulation.xhtml.j2 b/examples/i18n/templates/formulation.xhtml.j2 index fda323a..6281a1a 100644 --- a/examples/i18n/templates/formulation.xhtml.j2 +++ b/examples/i18n/templates/formulation.xhtml.j2 @@ -1,15 +1,14 @@ -
+
{# TRANSLATORS: This comment will be shown in the .pot file. #} -

gettext: {{ _("This string should be localized!") }}

-

ngettext: {{ ngettext("One Thing", "More Things", 1) }}, {{ ngettext("One Thing", "More Things", 99) }} +

gettext: {{ __("This string should be localized!") }}

+

ngettext: {{ ngettext("One Thing", "More Things", 1) }}, {{ __.n("One Thing", "More Things", 99) }}

-

pgettext: {{ pgettext("Context 1", "My Message") }}, {{ pgettext("Context 2", "My Message") }}

+

pgettext: {{ pgettext("Context 1", "My Message") }}, {{ __.p("Context 2", "My Message") }}

npgettext

  • {{ npgettext("Context 1", "One Thing", "More Things", 1) }}
  • -
  • {{ npgettext("Context 1", "One Thing", "More Things", 2) }}
  • -
  • {{ npgettext("Context 2", "One Thing", "More Things", 1) }}
  • +
  • {{ __.npgettext("Context 1", "One Thing", "More Things", 2) }}
  • +
  • {{ __.np("Context 2", "One Thing", "More Things", 1) }}
  • {{ npgettext("Context 2", "One Thing", "More Things", 2) }}
diff --git a/questionpy/_ui.py b/questionpy/_ui.py index 9c177cc..a45a917 100644 --- a/questionpy/_ui.py +++ b/questionpy/_ui.py @@ -59,6 +59,27 @@ def _get_loader(package: Package) -> jinja2.BaseLoader | None: return _TraversableTemplateLoader(templates_directory) +def _jinja_call_proxy(name: str) -> staticmethod: + # Inspired by jinja2.ext._gettext_alias. + @jinja2.pass_context + def function(__context: jinja2.runtime.Context, /, *args: object, **kwargs: object) -> object: + return __context.call(__context.resolve(name), *args, **kwargs) + + return staticmethod(function) + + +class _Jinja2Gettext: + """Provides an interface compatible with both our preferred Python convention and the Jinja extension's defaults. + + We proxy to Jinja's functions instead of our own because Jinja has built-in formatting. + """ + + __call__ = gettext = _jinja_call_proxy("gettext") + n = ngettext = _jinja_call_proxy("ngettext") + p = pgettext = _jinja_call_proxy("pgettext") + np = npgettext = _jinja_call_proxy("npgettext") + + def create_jinja2_environment(attempt: "Attempt", question: "Question") -> jinja2.Environment: """Creates a Jinja2 environment with sensible default configuration. @@ -96,5 +117,6 @@ def create_jinja2_environment(attempt: "Attempt", question: "Question") -> jinja translations = i18n.get_translations_of_package(package) env.add_extension("jinja2.ext.i18n") env.install_gettext_translations(translations, newstyle=True) # type: ignore[attr-defined] + env.globals["__"] = _Jinja2Gettext() return env diff --git a/questionpy/i18n.py b/questionpy/i18n.py index ea1d73f..b38b378 100644 --- a/questionpy/i18n.py +++ b/questionpy/i18n.py @@ -76,7 +76,9 @@ class _DomainState: request_state: _RequestState | None = None -class Gettext: +class Gettext: # noqa: PLR0904 (ruff seems to count each overload separately) + """Container for gettext-family functions. Usually called `__`. See [i18n.get_for][questionpy.i18n.get_for].""" + def __init__(self, package: Package, domain: GettextDomain, domain_state: _DomainState) -> None: self._package = package self._domain = domain @@ -194,6 +196,35 @@ def npgettext( default_message, lambda trans: trans.npgettext(context, singular, plural, n), defer=defer ) + gettext = __call__ + """Alias of [`__(message)`][questionpy.i18n.Gettext.__call__].""" + n = ngettext + """Alias of [`__.ngettext`][questionpy.i18n.Gettext.ngettext].""" + p = pgettext + """Alias of [`__.pgettext`][questionpy.i18n.Gettext.pgettext].""" + np = npgettext + """Alias of [`__.npgettext`][questionpy.i18n.Gettext.npgettext].""" + + @staticmethod + def gettext_noop(message: str, /) -> str: + """Mark a regular message for translation.""" + return message + + @staticmethod + def ngettext_noop(singular: str, plural: str, n: int, /) -> tuple[str, str, int]: + """Mark a pluralizable message for translation.""" + return singular, plural, n + + @staticmethod + def pgettext_noop(context: str, message: str, /) -> tuple[str, str]: + """Mark a contextualized message for translation.""" + return context, message + + @staticmethod + def npgettext_noop(context: str, singular: str, plural: str, n: int) -> tuple[str, str, str, int]: + """Mark a both pluralizable and contextualized message for translation.""" + return context, singular, plural, n + def _maybe_defer( self, default_message: str, getter: Callable[[NullTranslations], str], *, defer: bool | None ) -> str | TranslatableString: @@ -230,31 +261,28 @@ def get_primary_language(package: Package) -> Bcp47LanguageTag: return package.manifest.languages[0] -def get_for(module_name: str) -> tuple[Gettext, Callable[[str], str]]: +def get_for(module_name: str) -> Gettext: """Initializes i18n for the package owning the given Python module and returns the gettext-family functions. Args: module_name: The Python `__package__` or `__module__` whose domain should be used. - Returns: - _: (usually assigned to `_`) the main translation function. Compatible with the standard library's - [gettext.gettext][]. The other gettext-family functions are available as methods on `gettext`: `.ngettext`, - `.pgettext` and `.npgettext`. - _N: A function which just returns the passed-in message untranslated. Useful for marking a message as - translatable without translating it at that time. + Returns: The main translation function, usually assigned to `__`. Compatible with the standard library's + [gettext.gettext][]. The other gettext-family functions are available as methods on `gettext`: `.n`, `.p` and + `.np`. Example: ```py - _, _N = i18n.get_for(__package__) - print(_("I'm translated!")) - print(_.ngettext("One thing", "{} things", 2).format(2)) + __ = i18n.get_for(__package__) + print(__("I'm translated!")) + print(__.n("One thing", "{} things", 2).format(2)) ``` """ package = get_package_by_python_module(module_name) domain = domain_of(package.manifest) domain_state = _ensure_initialized(domain, package, get_qpy_environment()) - return Gettext(package, domain, domain_state), lambda message: message + return Gettext(package, domain, domain_state) def dgettext(domain: str, message: str, /) -> str: diff --git a/questionpy_sdk/commands/i18n/_extract.py b/questionpy_sdk/commands/i18n/_extract.py index ed79c1a..3ea5729 100644 --- a/questionpy_sdk/commands/i18n/_extract.py +++ b/questionpy_sdk/commands/i18n/_extract.py @@ -1,4 +1,5 @@ from pathlib import Path +from typing import Literal import babel.messages import babel.messages.pofile @@ -9,6 +10,30 @@ from questionpy_sdk.package.source import PackageSource _BABEL_MAPPING = [("python/**.py", "python"), ("templates/**.j2", "jinja2")] +# See Babel's DEFAULT_KEYWORDS for comparison. +_BABEL_KEYWORDS: dict[str, tuple[int | tuple[int, Literal["c"]], ...]] = { + # Full names + "gettext": (1,), + "ngettext": (1, 2), + "pgettext": ((1, "c"), 2), + "npgettext": ((1, "c"), 2, 3), + "dgettext": (2,), + "dngettext": (2, 3), + "dpgettext": ((2, "c"), 3), + "dnpgettext": ((2, "c"), 3, 4), + # Short names encouraged by us + "__": (1,), + "n": (1, 2), + "p": ((1, "c"), 2), + "np": ((1, "c"), 2, 3), + # No-op markers + "gettext_noop": (1,), + "ngettext_noop": (1, 2), + "pgettext_noop": ((1, "c"), 2), + "npgettext_noop": ((1, "c"), 2, 3), + # We don't encourage '_' in Python, but it's the default in Jinja's i18n extension so people may be used to it. + "_": (1,), +} @click.command @@ -38,7 +63,7 @@ def extract(package: Path, output: Path | None = None) -> None: ) for filename, lineno, message, comments, context in extract_from_dir( - package, _BABEL_MAPPING, comment_tags=("TRANSLATORS:",), strip_comment_tags=True + package, _BABEL_MAPPING, comment_tags=("TRANSLATORS:",), strip_comment_tags=True, keywords=_BABEL_KEYWORDS ): catalog.add(message, None, [(filename, lineno)], auto_comments=comments, context=context)