From e3e48b00127c09eafe6439d980a82fc5c591b673 Mon Sep 17 00:00:00 2001 From: Claude Paroz Date: Tue, 10 Mar 2020 15:56:32 +0100 Subject: [PATCH] Fixed #30439 -- Added support for different plural forms for a language. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thanks to Michal Čihař for review. --- django/utils/translation/trans_real.py | 75 +++++++++++++++++- docs/releases/2.2.12.txt | 5 +- docs/releases/3.0.5.txt | 3 +- docs/topics/i18n/translation.txt | 9 +-- .../other/locale/fr/LC_MESSAGES/django.mo | Bin 528 -> 580 bytes .../other/locale/fr/LC_MESSAGES/django.po | 13 ++- tests/i18n/tests.py | 16 ++++ 7 files changed, 106 insertions(+), 15 deletions(-) diff --git a/django/utils/translation/trans_real.py b/django/utils/translation/trans_real.py index d852675360..f8089dca54 100644 --- a/django/utils/translation/trans_real.py +++ b/django/utils/translation/trans_real.py @@ -58,6 +58,63 @@ def reset_cache(**kwargs): get_supported_language_variant.cache_clear() +class TranslationCatalog: + """ + Simulate a dict for DjangoTranslation._catalog so as multiple catalogs + with different plural equations are kept separate. + """ + def __init__(self, trans=None): + self._catalogs = [trans._catalog.copy()] if trans else [{}] + self._plurals = [trans.plural] if trans else [lambda n: int(n != 1)] + + def __getitem__(self, key): + for cat in self._catalogs: + try: + return cat[key] + except KeyError: + pass + raise KeyError(key) + + def __setitem__(self, key, value): + self._catalogs[0][key] = value + + def __contains__(self, key): + return any(key in cat for cat in self._catalogs) + + def items(self): + for cat in self._catalogs: + yield from cat.items() + + def keys(self): + for cat in self._catalogs: + yield from cat.keys() + + def update(self, trans): + # Merge if plural function is the same, else prepend. + for cat, plural in zip(self._catalogs, self._plurals): + if trans.plural.__code__ == plural.__code__: + cat.update(trans._catalog) + break + else: + self._catalogs.insert(0, trans._catalog) + self._plurals.insert(0, trans.plural) + + def get(self, key, default=None): + missing = object() + for cat in self._catalogs: + result = cat.get(key, missing) + if result is not missing: + return result + return default + + def plural(self, msgid, num): + for cat, plural in zip(self._catalogs, self._plurals): + tmsg = cat.get((msgid, plural(num))) + if tmsg is not None: + return tmsg + raise KeyError + + class DjangoTranslation(gettext_module.GNUTranslations): """ Set up the GNUTranslations context with regard to output charset. @@ -104,7 +161,7 @@ class DjangoTranslation(gettext_module.GNUTranslations): self._add_fallback(localedirs) if self._catalog is None: # No catalogs found for this language, set an empty catalog. - self._catalog = {} + self._catalog = TranslationCatalog() def __repr__(self): return "" % self.__language @@ -175,9 +232,9 @@ class DjangoTranslation(gettext_module.GNUTranslations): # Take plural and _info from first catalog found (generally Django's). self.plural = other.plural self._info = other._info.copy() - self._catalog = other._catalog.copy() + self._catalog = TranslationCatalog(other) else: - self._catalog.update(other._catalog) + self._catalog.update(other) if other._fallback: self.add_fallback(other._fallback) @@ -189,6 +246,18 @@ class DjangoTranslation(gettext_module.GNUTranslations): """Return the translation language name.""" return self.__to_language + def ngettext(self, msgid1, msgid2, n): + try: + tmsg = self._catalog.plural(msgid1, n) + except KeyError: + if self._fallback: + return self._fallback.ngettext(msgid1, msgid2, n) + if n == 1: + tmsg = msgid1 + else: + tmsg = msgid2 + return tmsg + def translation(language): """ diff --git a/docs/releases/2.2.12.txt b/docs/releases/2.2.12.txt index ca443a72ab..8585b12e7d 100644 --- a/docs/releases/2.2.12.txt +++ b/docs/releases/2.2.12.txt @@ -4,9 +4,10 @@ Django 2.2.12 release notes *Expected April 1, 2020* -Django 2.2.12 fixes several bugs in 2.2.11. +Django 2.2.12 fixes a bug in 2.2.11. Bugfixes ======== -* ... +* Added the ability to handle ``.po`` files containing different plural + equations for the same language (:ticket:`30439`). diff --git a/docs/releases/3.0.5.txt b/docs/releases/3.0.5.txt index e1235e060f..43b7bfe08f 100644 --- a/docs/releases/3.0.5.txt +++ b/docs/releases/3.0.5.txt @@ -9,4 +9,5 @@ Django 3.0.5 fixes several bugs in 3.0.4. Bugfixes ======== -* ... +* Added the ability to handle ``.po`` files containing different plural + equations for the same language (:ticket:`30439`). diff --git a/docs/topics/i18n/translation.txt b/docs/topics/i18n/translation.txt index 04aa124a49..21615eeae6 100644 --- a/docs/topics/i18n/translation.txt +++ b/docs/topics/i18n/translation.txt @@ -277,14 +277,9 @@ In a case like this, consider something like the following:: a format specification for argument 'name', as in 'msgstr[0]', doesn't exist in 'msgid' -.. note:: Plural form and po files +.. versionchanged: 2.2.12 - Django does not support custom plural equations in po files. As all - translation catalogs are merged, only the plural form for the main Django po - file (in ``django/conf/locale//LC_MESSAGES/django.po``) is - considered. Plural forms in all other po files are ignored. Therefore, you - should not use different plural equations in your project or application po - files. + Added support for different plural equations in ``.po`` files. .. _contextual-markers: diff --git a/tests/i18n/other/locale/fr/LC_MESSAGES/django.mo b/tests/i18n/other/locale/fr/LC_MESSAGES/django.mo index 478338bc886a2445f9a1c7004b49f4bac719d5fb..d86cae8f9136356dbf515856dd342248083670f4 100644 GIT binary patch delta 225 zcmbQha)hP+o)F7a1|VPoVi_Q|0b*7ljsap2C;(znAT9)AF(7USVvxFdK&->az_1%g zs{ru}AX^eh^D;r?)qylnh=Cc1L1u#hP>6v+HASH~GcUa~C$R{~W>+Z4DJ@FOnV672 z@yA&&V{0hiRwK{W)=ovl0}%rg5W@h-%!zIJY9YGLMX8A;nfZCTE{P?nRtiQ2h6cI@M!JS33WnxZ#>Uz} izQM##7le(h6$)}nixP8eHS!b`Z50ePxhC^4SpWboK^w;a diff --git a/tests/i18n/other/locale/fr/LC_MESSAGES/django.po b/tests/i18n/other/locale/fr/LC_MESSAGES/django.po index dafb6139ae..7626e5f6d5 100644 --- a/tests/i18n/other/locale/fr/LC_MESSAGES/django.po +++ b/tests/i18n/other/locale/fr/LC_MESSAGES/django.po @@ -14,7 +14,10 @@ msgstr "" "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" +"Plural-Forms: nplurals=3; plural=(n==1 ? 0 : n==0 ? 1 : 2);\n" + +# Plural form is purposefully different from the normal French plural to test +# multiple plural forms for one language. #: template.html:3 # Note: Intentional: variable name is translated. @@ -24,4 +27,10 @@ msgstr "Mon nom est %(personne)s." #: template.html:3 # Note: Intentional: the variable name is badly formatted (missing 's' at the end) msgid "My other name is %(person)s." -msgstr "Mon autre nom est %(person)." \ No newline at end of file +msgstr "Mon autre nom est %(person)." + +msgid "%d singular" +msgid_plural "%d plural" +msgstr[0] "%d singulier" +msgstr[1] "%d pluriel1" +msgstr[2] "%d pluriel2" diff --git a/tests/i18n/tests.py b/tests/i18n/tests.py index ac813b3439..cce32ea86c 100644 --- a/tests/i18n/tests.py +++ b/tests/i18n/tests.py @@ -125,6 +125,22 @@ class TranslationTests(SimpleTestCase): self.assertEqual(g('%d year', '%d years', 1) % 1, '1 year') self.assertEqual(g('%d year', '%d years', 2) % 2, '2 years') + @override_settings(LOCALE_PATHS=extended_locale_paths) + @translation.override('fr') + def test_multiple_plurals_per_language(self): + """ + Normally, French has 2 plurals. As other/locale/fr/LC_MESSAGES/django.po + has a different plural equation with 3 plurals, this tests if those + plural are honored. + """ + self.assertEqual(ngettext("%d singular", "%d plural", 0) % 0, "0 pluriel1") + self.assertEqual(ngettext("%d singular", "%d plural", 1) % 1, "1 singulier") + self.assertEqual(ngettext("%d singular", "%d plural", 2) % 2, "2 pluriel2") + french = trans_real.catalog() + # Internal _catalog can query subcatalogs (from different po files). + self.assertEqual(french._catalog[('%d singular', 0)], '%d singulier') + self.assertEqual(french._catalog[('%d hour', 0)], '%d heure') + def test_override(self): activate('de') try: