summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSylvain Viollon <thefunny@gmail.com>2018-10-19 08:36:22 +0200
committerGitHub <noreply@github.com>2018-10-19 08:36:22 +0200
commit6e699ade2b38f3ed387fa8765c486757014c76ad (patch)
tree272dbffda393b12839b3fb7088a4e65ef479cc9c
parenteaa6190adc828b157727f4be7ca7ef6a00f3d73c (diff)
parent6c9f3eaa735887c160c6d91916759ac950702479 (diff)
downloadzope-i18n-6e699ade2b38f3ed387fa8765c486757014c76ad.tar.gz
Merge pull request #34 from minddistrict/CB-556-FR-translation
Add support for pluralization
-rw-r--r--CHANGES.rst4
-rw-r--r--setup.py2
-rw-r--r--src/zope/i18n/__init__.py21
-rw-r--r--src/zope/i18n/format.py8
-rw-r--r--src/zope/i18n/gettextmessagecatalog.py55
-rw-r--r--src/zope/i18n/interfaces/__init__.py32
-rw-r--r--src/zope/i18n/negotiator.py4
-rw-r--r--src/zope/i18n/simpletranslationdomain.py8
-rw-r--r--src/zope/i18n/tests/de-default.mobin347 -> 483 bytes
-rw-r--r--src/zope/i18n/tests/de-default.po6
-rw-r--r--src/zope/i18n/tests/en-alt.mobin318 -> 361 bytes
-rw-r--r--src/zope/i18n/tests/en-alt.po5
-rw-r--r--src/zope/i18n/tests/en-default.mobin343 -> 932 bytes
-rw-r--r--src/zope/i18n/tests/en-default.po31
-rw-r--r--src/zope/i18n/tests/pl-default.mobin0 -> 570 bytes
-rw-r--r--src/zope/i18n/tests/pl-default.po22
-rw-r--r--src/zope/i18n/tests/test_imessagecatalog.py4
-rw-r--r--src/zope/i18n/tests/test_plurals.py183
-rw-r--r--src/zope/i18n/translationdomain.py46
-rw-r--r--src/zope/i18n/zcml.py2
20 files changed, 396 insertions, 37 deletions
diff --git a/CHANGES.rst b/CHANGES.rst
index cbd6bd4..18238e6 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -5,7 +5,9 @@
4.5 (unreleased)
================
-- Nothing changed yet.
+- Add support for pluralization. ``translate()`` now takes the
+ additional optional arguments ``msgid_plural``, ``default_plural``
+ and ``number`` in order to support it.
4.4 (2018-10-05)
diff --git a/setup.py b/setup.py
index eac0e7d..853f686 100644
--- a/setup.py
+++ b/setup.py
@@ -101,7 +101,7 @@ setup(
'pytz',
'zope.deprecation',
'zope.schema',
- 'zope.i18nmessageid',
+ 'zope.i18nmessageid >= 4.3',
'zope.component',
],
extras_require={
diff --git a/src/zope/i18n/__init__.py b/src/zope/i18n/__init__.py
index 3088074..5f7d775 100644
--- a/src/zope/i18n/__init__.py
+++ b/src/zope/i18n/__init__.py
@@ -39,6 +39,7 @@ class _FallbackNegotiator(object):
def getLanguage(self, _allowed, _context):
return None
+
_fallback_negotiator = _FallbackNegotiator()
@@ -79,8 +80,10 @@ def negotiate(context):
negotiator = queryUtility(INegotiator, default=_fallback_negotiator)
return negotiator.getLanguage(ALLOWED_LANGUAGES, context)
+
def translate(msgid, domain=None, mapping=None, context=None,
- target_language=None, default=None):
+ target_language=None, default=None, msgid_plural=None,
+ default_plural=None, number=None):
"""Translate text.
First setup some test components:
@@ -160,6 +163,9 @@ def translate(msgid, domain=None, mapping=None, context=None,
domain = msgid.domain
default = msgid.default
mapping = msgid.mapping
+ msgid_plural = msgid.msgid_plural
+ default_plural = msgid.default_plural
+ number = msgid.number
if default is None:
default = text_type(msgid)
@@ -181,7 +187,10 @@ def translate(msgid, domain=None, mapping=None, context=None,
if target_language is None and context is not None:
target_language = negotiate(context)
- return util.translate(msgid, mapping, context, target_language, default)
+ return util.translate(
+ msgid, mapping, context, target_language, default,
+ msgid_plural, default_plural, number)
+
def interpolate(text, mapping=None):
"""Insert the data passed from mapping into the text.
@@ -197,15 +206,15 @@ def interpolate(text, mapping=None):
Interpolation variables can be used more than once in the text:
- >>> print(interpolate(u"This is $name version ${version}. ${name} $version!",
- ... mapping))
+ >>> print(interpolate(
+ ... u"This is $name version ${version}. ${name} $version!", mapping))
This is Zope version 3. Zope 3!
In case if the variable wasn't found in the mapping or '$$' form
was used no substitution will happens:
- >>> print(interpolate(u"This is $name $version. $unknown $$name $${version}.",
- ... mapping))
+ >>> print(interpolate(
+ ... u"This is $name $version. $unknown $$name $${version}.", mapping))
This is Zope 3. $unknown $$name $${version}.
>>> print(interpolate(u"This is ${name}"))
diff --git a/src/zope/i18n/format.py b/src/zope/i18n/format.py
index ee2e239..96f5ef3 100644
--- a/src/zope/i18n/format.py
+++ b/src/zope/i18n/format.py
@@ -33,6 +33,7 @@ try:
except NameError:
pass # Py3
+
def roundHalfUp(n):
"""Works like round() in python2.x
@@ -42,18 +43,20 @@ def roundHalfUp(n):
"""
return math.floor(n + math.copysign(0.5, n))
+
def _findFormattingCharacterInPattern(char, pattern):
return [entry for entry in pattern
if isinstance(entry, tuple) and entry[0] == char]
+
class DateTimeParseError(Exception):
"""Error is raised when parsing of datetime failed."""
+
@implementer(IDateTimeFormat)
class DateTimeFormat(object):
__doc__ = IDateTimeFormat.__doc__
-
_DATETIMECHARS = "aGyMdEDFwWhHmsSkKz"
calendar = None
@@ -486,11 +489,11 @@ class NumberFormat(object):
return text_type(text)
-
DEFAULT = 0
IN_QUOTE = 1
IN_DATETIMEFIELD = 2
+
class DateTimePatternParseError(Exception):
"""DateTime Pattern Parse Error"""
@@ -760,6 +763,7 @@ SUFFIX = 7
PADDING4 = 8
GROUPING = 9
+
class NumberPatternParseError(Exception):
"""Number Pattern Parse Error"""
diff --git a/src/zope/i18n/gettextmessagecatalog.py b/src/zope/i18n/gettextmessagecatalog.py
index 307e5be..8e1272b 100644
--- a/src/zope/i18n/gettextmessagecatalog.py
+++ b/src/zope/i18n/gettextmessagecatalog.py
@@ -14,6 +14,7 @@
"""A simple implementation of a Message Catalog.
"""
+from functools import wraps
from gettext import GNUTranslations
from zope.i18n.interfaces import IGlobalMessageCatalog
from zope.interface import implementer
@@ -22,7 +23,31 @@ from zope.interface import implementer
class _KeyErrorRaisingFallback(object):
def ugettext(self, message):
raise KeyError(message)
+
+ def ungettext(self, singular, plural, n):
+ raise KeyError(singular)
+
gettext = ugettext
+ ngettext = ungettext
+
+
+def plural_formatting(func):
+ """This decorator interpolates the possible formatting marker.
+ This interpolation marker is usally present for plurals.
+ Example: `There are %d apples` or `They have %s pies.`
+
+ Please note that the interpolation can be done, alternatively,
+ using the mapping. This is only present as a conveniance.
+ """
+ @wraps(func)
+ def pformat(catalog, singular, plural, n, *args, **kwargs):
+ msg = func(catalog, singular, plural, n, *args, **kwargs)
+ try:
+ return msg % n
+ except TypeError:
+ # The message cannot be formatted : return it "raw".
+ return msg
+ return pformat
@implementer(IGlobalMessageCatalog)
@@ -33,13 +58,20 @@ class GettextMessageCatalog(object):
def __init__(self, language, domain, path_to_file):
"""Initialize the message catalog"""
- self.language = language.decode('utf-8') if isinstance(language, bytes) else language
- self.domain = domain.decode("utf-8") if isinstance(domain, bytes) else domain
+ self.language = (
+ language.decode('utf-8') if isinstance(language, bytes)
+ else language)
+ self.domain = (
+ domain.decode("utf-8") if isinstance(domain, bytes)
+ else domain)
self._path_to_file = path_to_file
self.reload()
catalog = self._catalog
catalog.add_fallback(_KeyErrorRaisingFallback())
- self._gettext = catalog.gettext if str is not bytes else catalog.ugettext
+ self._gettext = (
+ catalog.gettext if str is not bytes else catalog.ugettext)
+ self._ngettext = (
+ catalog.ngettext if str is not bytes else catalog.ungettext)
def reload(self):
'See IMessageCatalog'
@@ -50,6 +82,23 @@ class GettextMessageCatalog(object):
'See IMessageCatalog'
return self._gettext(id)
+ @plural_formatting
+ def getPluralMessage(self, singular, plural, n):
+ 'See IMessageCatalog'
+ return self._ngettext(singular, plural, n)
+
+ @plural_formatting
+ def queryPluralMessage(self, singular, plural, n, dft1=None, dft2=None):
+ 'See IMessageCatalog'
+ try:
+ return self._ngettext(singular, plural, n)
+ except KeyError:
+ # Here, we use the catalog plural function to determine
+ # if `n` triggers a plural form or not.
+ if self._catalog.plural(n):
+ return dft2
+ return dft1
+
def queryMessage(self, id, default=None):
'See IMessageCatalog'
try:
diff --git a/src/zope/i18n/interfaces/__init__.py b/src/zope/i18n/interfaces/__init__.py
index 397c8f5..11d8c21 100644
--- a/src/zope/i18n/interfaces/__init__.py
+++ b/src/zope/i18n/interfaces/__init__.py
@@ -60,13 +60,30 @@ class IMessageCatalog(Interface):
An exception is raised if the message id is not found.
"""
-
+
def queryMessage(msgid, default=None):
"""Look for the appropriate text for the given message id.
If the message id is not found, default is returned.
"""
+ def getPluralMessage(self, singular, plural, n):
+ """Get the appropriate text for the given message id and the
+ plural id.
+
+ An exception is raised if nothing was found.
+ """
+
+ def queryPluralMessage(singular, plural, n, dft1=None, dft2=None):
+ """Look for the appropriate text for the given message id and the
+ plural id.
+
+ If `n` is evaluated as a singular and the id is not found,
+ `dft1` is returned.
+ If `n` is evaluated as a plural and the plural id is not found,
+ `dft2` is returned.
+ """
+
language = TextLine(
title=u"Language",
description=u"The language the catalog translates to.",
@@ -118,6 +135,10 @@ class ITranslationDomain(Interface):
target_language -- The language to translate to.
+ msgid_plural -- The id of the plural message that should be translated.
+
+ number -- The number of items linked to the plural of the message.
+
context -- An object that provides contextual information for
determining client language preferences. It must implement
or have an adapter that implements IUserPreferredLanguages.
@@ -133,7 +154,8 @@ class ITranslationDomain(Interface):
required=True)
def translate(msgid, mapping=None, context=None, target_language=None,
- default=None):
+ default=None, msgid_plural=None, default_plural=None,
+ number=None):
"""Return the translation for the message referred to by msgid.
Return the default if no translation is found.
@@ -147,6 +169,7 @@ class ITranslationDomain(Interface):
"""
+
class IFallbackTranslationDomainFactory(Interface):
"""Factory for creating fallback translation domains
@@ -158,6 +181,7 @@ class IFallbackTranslationDomainFactory(Interface):
"""Return a fallback translation domain for the given domain id.
"""
+
class ITranslator(Interface):
"""A collaborative object which contains the domain, context, and locale.
@@ -165,7 +189,8 @@ class ITranslator(Interface):
the domain, context, and target language.
"""
- def translate(msgid, mapping=None, default=None):
+ def translate(msgid, mapping=None, default=None,
+ msgid_plural=None, default_plural=None, number=None):
"""Translate the source msgid using the given mapping.
See ITranslationService for details.
@@ -212,6 +237,7 @@ class IUserPreferredLanguages(Interface):
languages first.
"""
+
class IModifiableUserPreferredLanguages(IUserPreferredLanguages):
def setPreferredLanguages(languages):
diff --git a/src/zope/i18n/negotiator.py b/src/zope/i18n/negotiator.py
index 66417c0..3c5fa17 100644
--- a/src/zope/i18n/negotiator.py
+++ b/src/zope/i18n/negotiator.py
@@ -14,16 +14,17 @@
"""Language Negotiator
"""
from zope.interface import implementer
-
from zope.i18n.interfaces import INegotiator
from zope.i18n.interfaces import IUserPreferredLanguages
+
def normalize_lang(lang):
lang = lang.strip().lower()
lang = lang.replace('_', '-')
lang = lang.replace(' ', '')
return lang
+
def normalize_langs(langs):
# Make a mapping from normalized->original so we keep can match
# the normalized lang and return the original string.
@@ -32,6 +33,7 @@ def normalize_langs(langs):
n_langs[normalize_lang(l)] = l
return n_langs
+
@implementer(INegotiator)
class Negotiator(object):
diff --git a/src/zope/i18n/simpletranslationdomain.py b/src/zope/i18n/simpletranslationdomain.py
index b50fb68..b093db8 100644
--- a/src/zope/i18n/simpletranslationdomain.py
+++ b/src/zope/i18n/simpletranslationdomain.py
@@ -18,8 +18,10 @@ from zope.component import getUtility
from zope.i18n.interfaces import ITranslationDomain, INegotiator
from zope.i18n import interpolate
+
text_type = str if bytes is not str else unicode
+
@implementer(ITranslationDomain)
class SimpleTranslationDomain(object):
"""This is the simplest implementation of the ITranslationDomain I
@@ -39,12 +41,14 @@ class SimpleTranslationDomain(object):
def __init__(self, domain, messages=None):
"""Initializes the object. No arguments are needed."""
- self.domain = domain.decode("utf-8") if isinstance(domain, bytes) else domain
+ self.domain = (
+ domain.decode("utf-8") if isinstance(domain, bytes) else domain)
self.messages = messages if messages is not None else {}
assert self.messages is not None
def translate(self, msgid, mapping=None, context=None,
- target_language=None, default=None):
+ target_language=None, default=None, msgid_plural=None,
+ default_plural=None, number=None):
'''See interface ITranslationDomain'''
# Find out what the target language should be
if target_language is None and context is not None:
diff --git a/src/zope/i18n/tests/de-default.mo b/src/zope/i18n/tests/de-default.mo
index 392a0ff..f49b8da 100644
--- a/src/zope/i18n/tests/de-default.mo
+++ b/src/zope/i18n/tests/de-default.mo
Binary files differ
diff --git a/src/zope/i18n/tests/de-default.po b/src/zope/i18n/tests/de-default.po
index 99a5f8d..7d3b2f6 100644
--- a/src/zope/i18n/tests/de-default.po
+++ b/src/zope/i18n/tests/de-default.po
@@ -6,9 +6,15 @@ msgstr ""
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=ISO-8859-1\n"
"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=n != 1;\n"
msgid "short_greeting"
msgstr "Hallo!"
msgid "greeting"
msgstr "Hallo $name, wie geht es Dir?"
+
+msgid "There is one file."
+msgid_plural "There are %d files."
+msgstr[0] "Es gibt eine Datei."
+msgstr[1] "Es gibt %d Dateien."
diff --git a/src/zope/i18n/tests/en-alt.mo b/src/zope/i18n/tests/en-alt.mo
index e08bb59..2e7a16a 100644
--- a/src/zope/i18n/tests/en-alt.mo
+++ b/src/zope/i18n/tests/en-alt.mo
Binary files differ
diff --git a/src/zope/i18n/tests/en-alt.po b/src/zope/i18n/tests/en-alt.po
index de55bbc..eb439fb 100644
--- a/src/zope/i18n/tests/en-alt.po
+++ b/src/zope/i18n/tests/en-alt.po
@@ -12,3 +12,8 @@ msgstr "Hey!"
msgid "special"
msgstr "Wow"
+
+msgid "apple"
+msgid_plural "apple"
+msgstr[0] "orange"
+msgstr[1] "oranges"
diff --git a/src/zope/i18n/tests/en-default.mo b/src/zope/i18n/tests/en-default.mo
index 4164029..0dc0588 100644
--- a/src/zope/i18n/tests/en-default.mo
+++ b/src/zope/i18n/tests/en-default.mo
Binary files differ
diff --git a/src/zope/i18n/tests/en-default.po b/src/zope/i18n/tests/en-default.po
index a3d843b..2432498 100644
--- a/src/zope/i18n/tests/en-default.po
+++ b/src/zope/i18n/tests/en-default.po
@@ -6,9 +6,40 @@ msgstr ""
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=ISO-8859-1\n"
"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=n != 1;\n"
msgid "short_greeting"
msgstr "Hello!"
msgid "greeting"
msgstr "Hello $name, how are you?"
+
+msgid "There is one file."
+msgid_plural "There are %d files."
+msgstr[0] "There is one file."
+msgstr[1] "There are %d files."
+
+msgid "The item is rated 1/5 star."
+msgid_plural "The item is rated %s/5 stars."
+msgstr[0] "The item is rated 1/5 star."
+msgstr[1] "The item is rated %s/5 stars."
+
+msgid "There is %d chance."
+msgid_plural "There are %f chances."
+msgstr[0] "There is %d chance."
+msgstr[1] "There are %f chances."
+
+msgid "There is %d ${type}."
+msgid_plural "There are %d ${type}."
+msgstr[0] "There is %d ${type}."
+msgstr[1] "There are %d ${type}."
+
+msgid "apple"
+msgid_plural "apples"
+msgstr[0] "apple"
+msgstr[1] "apples"
+
+msgid "banana"
+msgid_plural "bananas"
+msgstr[0] "banana"
+msgstr[1] "bananas"
diff --git a/src/zope/i18n/tests/pl-default.mo b/src/zope/i18n/tests/pl-default.mo
new file mode 100644
index 0000000..37b73fe
--- /dev/null
+++ b/src/zope/i18n/tests/pl-default.mo
Binary files differ
diff --git a/src/zope/i18n/tests/pl-default.po b/src/zope/i18n/tests/pl-default.po
new file mode 100644
index 0000000..3951a9c
--- /dev/null
+++ b/src/zope/i18n/tests/pl-default.po
@@ -0,0 +1,22 @@
+msgid ""
+msgstr ""
+"Project-Id-Version: Zope 3\n"
+"PO-Revision-Date: 2018-09-04 11:05+0100\n"
+"Last-Translator: Zope 3 contributors\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=utf-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=3; plural=n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2;\n"
+
+
+msgid "short_greeting"
+msgstr "Cześć !"
+
+msgid "greeting"
+msgstr "Cześć $name, jak się masz?"
+
+msgid "There is one file."
+msgid_plural "There are %d files."
+msgstr[0] "Istnieje %d plik."
+msgstr[1] "Istnieją %d pliki."
+msgstr[2] "Istnieją %d plików."
diff --git a/src/zope/i18n/tests/test_imessagecatalog.py b/src/zope/i18n/tests/test_imessagecatalog.py
index 2856c67..803d4f0 100644
--- a/src/zope/i18n/tests/test_imessagecatalog.py
+++ b/src/zope/i18n/tests/test_imessagecatalog.py
@@ -43,24 +43,20 @@ class TestIMessageCatalog(unittest.TestCase):
self.assertEqual(catalog.getMessage('short_greeting'), 'Hello!')
self.assertRaises(KeyError, catalog.getMessage, 'foo')
-
def testQueryMessage(self):
catalog = self._catalog
self.assertEqual(catalog.queryMessage('short_greeting'), 'Hello!')
self.assertEqual(catalog.queryMessage('foo'), None)
self.assertEqual(catalog.queryMessage('foo', 'bar'), 'bar')
-
def testGetLanguage(self):
catalog = self._catalog
self.assertEqual(catalog.language, 'en')
-
def testGetDomain(self):
catalog = self._catalog
self.assertEqual(catalog.domain, 'default')
-
def testGetIdentifier(self):
catalog = self._catalog
self.assertEqual(catalog.getIdentifier(), self._getUniqueIndentifier())
diff --git a/src/zope/i18n/tests/test_plurals.py b/src/zope/i18n/tests/test_plurals.py
new file mode 100644
index 0000000..4f7813e
--- /dev/null
+++ b/src/zope/i18n/tests/test_plurals.py
@@ -0,0 +1,183 @@
+# -*- coding: utf-8 -*-
+##############################################################################
+#
+# Copyright (c) 2001, 2002 Zope Foundation and Contributors.
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL). A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+##############################################################################
+"""Test a gettext implementation of a Message Catalog.
+"""
+import os
+import unittest
+from zope.i18n import tests
+from zope.i18n.translationdomain import TranslationDomain
+from zope.i18n.gettextmessagecatalog import GettextMessageCatalog
+from zope.i18nmessageid import MessageFactory
+
+
+class TestPlurals(unittest.TestCase):
+
+ def _getMessageCatalog(self, locale, variant="default"):
+ path = os.path.dirname(tests.__file__)
+ self._path = os.path.join(path, '%s-%s.mo' % (locale, variant))
+ catalog = GettextMessageCatalog(locale, variant, self._path)
+ return catalog
+
+ def _getTranslationDomain(self, locale, variant="default"):
+ path = os.path.dirname(tests.__file__)
+ self._path = os.path.join(path, '%s-%s.mo' % (locale, variant))
+ catalog = GettextMessageCatalog(locale, variant, self._path)
+ domain = TranslationDomain('default')
+ domain.addCatalog(catalog)
+ return domain
+
+ def test_GermanPlurals(self):
+ """Germanic languages such as english and german share the plural
+ rule. We test the german here.
+ """
+ catalog = self._getMessageCatalog('de')
+ self.assertEqual(catalog.language, 'de')
+
+ self.assertEqual(catalog.getPluralMessage(
+ 'There is one file.', 'There are %d files.', 1),
+ 'Es gibt eine Datei.')
+ self.assertEqual(catalog.getPluralMessage(
+ 'There is one file.', 'There are %d files.', 3),
+ 'Es gibt 3 Dateien.')
+ self.assertEqual(catalog.getPluralMessage(
+ 'There is one file.', 'There are %d files.', 0),
+ 'Es gibt 0 Dateien.')
+
+ # Unknown id
+ self.assertRaises(KeyError, catalog.getPluralMessage,
+ 'There are %d files.', 'bar', 6)
+
+ # Query without default values
+ self.assertEqual(catalog.queryPluralMessage(
+ 'There is one file.', 'There are %d files.', 1),
+ 'Es gibt eine Datei.')
+ self.assertEqual(catalog.queryPluralMessage(
+ 'There is one file.', 'There are %d files.', 3),
+ 'Es gibt 3 Dateien.')
+
+ # Query with default values
+ self.assertEqual(catalog.queryPluralMessage(
+ 'There are %d files.', 'There is one file.', 1,
+ 'Es gibt 1 Datei.', 'Es gibt %d Dateien !', ),
+ 'Es gibt 1 Datei.')
+ self.assertEqual(catalog.queryPluralMessage(
+ 'There are %d files.', 'There is one file.', 3,
+ 'Es gibt 1 Datei.', 'Es gibt %d Dateien !', ),
+ 'Es gibt 3 Dateien !')
+
+ def test_PolishPlurals(self):
+ """Polish has a complex rule for plurals. It makes for a good
+ test subject.
+ """
+ catalog = self._getMessageCatalog('pl')
+ self.assertEqual(catalog.language, 'pl')
+
+ self.assertEqual(catalog.getPluralMessage(
+ 'There is one file.', 'There are %d files.', 0),
+ u"Istnieją 0 plików.")
+ self.assertEqual(catalog.getPluralMessage(
+ 'There is one file.', 'There are %d files.', 1),
+ u"Istnieje 1 plik.")
+ self.assertEqual(catalog.getPluralMessage(
+ 'There is one file.', 'There are %d files.', 3),
+ u"Istnieją 3 pliki.")
+ self.assertEqual(catalog.getPluralMessage(
+ 'There is one file.', 'There are %d files.', 17),
+ u"Istnieją 17 plików.")
+ self.assertEqual(catalog.getPluralMessage(
+ 'There is one file.', 'There are %d files.', 23),
+ u"Istnieją 23 pliki.")
+ self.assertEqual(catalog.getPluralMessage(
+ 'There is one file.', 'There are %d files.', 28),
+ u"Istnieją 28 plików.")
+
+ def test_floater(self):
+ """Test with the number being a float.
+ We can use %f or %s to make sure it works.
+ """
+ catalog = self._getMessageCatalog('en')
+ self.assertEqual(catalog.language, 'en')
+
+ # It's cast to integer because of the %d in the translation string.
+ self.assertEqual(catalog.getPluralMessage(
+ 'There is one file.', 'There are %d files.', 1.0),
+ 'There is one file.')
+ self.assertEqual(catalog.getPluralMessage(
+ 'There is one file.', 'There are %d files.', 3.5),
+ 'There are 3 files.')
+
+ # It's cast to a string because of the %s in the translation string.
+ self.assertEqual(catalog.getPluralMessage(
+ 'The item is rated 1/5 star.',
+ 'The item is rated %s/5 stars.', 3.5),
+ 'The item is rated 3.5/5 stars.')
+
+ # It's cast either to an int or a float because of the %s in
+ # the translation string.
+ self.assertEqual(catalog.getPluralMessage(
+ 'There is %d chance.',
+ 'There are %f chances.', 1.5),
+ 'There are 1.500000 chances.')
+ self.assertEqual(catalog.getPluralMessage(
+ 'There is %d chance.',
+ 'There are %f chances.', 3.5),
+ 'There are 3.500000 chances.')
+
+ def test_recursive_translation(self):
+ domain = self._getTranslationDomain('en')
+ factory = MessageFactory('default')
+ translate = domain.translate
+
+ # Singular
+ banana = factory('banana', msgid_plural='bananas', number=1)
+ phrase = factory('There is %d ${type}.',
+ msgid_plural='There are %d ${type}.',
+ number=1, mapping={'type': banana})
+ self.assertEqual(
+ translate(phrase, target_language="en"),
+ 'There is 1 banana.')
+
+ # Plural
+ apple = factory('apple', msgid_plural='apples', number=10)
+ phrase = factory('There is %d ${type}.',
+ msgid_plural='There are %d ${type}.',
+ number=10, mapping={'type': apple})
+ self.assertEqual(
+ translate(phrase, target_language="en"),
+ 'There are 10 apples.')
+
+ # Straight translation with translatable mapping
+ apple = factory('apple', msgid_plural='apples', number=75)
+ self.assertEqual(
+ translate(msgid='There is %d ${type}.',
+ msgid_plural='There are %d ${type}.',
+ mapping={'type': apple},
+ target_language="en", number=75),
+ 'There are 75 apples.')
+
+ # Add another catalog, to test the domain's catalogs iteration
+ # We add this catalog in first position, to resolve the translations
+ # there first.
+ alt_en = self._getMessageCatalog('en', variant="alt")
+ domain._data[alt_en.getIdentifier()] = alt_en
+ domain._catalogs[alt_en.language].insert(0, alt_en.getIdentifier())
+
+ apple = factory('apple', msgid_plural='apples', number=42)
+ self.assertEqual(
+ translate(msgid='There is %d ${type}.',
+ msgid_plural='There are %d ${type}.',
+ mapping={'type': apple},
+ target_language="de", number=42),
+ 'There are 42 oranges.')
diff --git a/src/zope/i18n/translationdomain.py b/src/zope/i18n/translationdomain.py
index 684fa9b..c7f064e 100644
--- a/src/zope/i18n/translationdomain.py
+++ b/src/zope/i18n/translationdomain.py
@@ -34,11 +34,13 @@ LANGUAGE_FALLBACKS = ['en']
text_type = str if bytes is not str else unicode
+
@zope.interface.implementer(ITranslationDomain)
class TranslationDomain(object):
def __init__(self, domain, fallbacks=None):
- self.domain = domain.decode("utf-8") if isinstance(domain, bytes) else domain
+ self.domain = (
+ domain.decode("utf-8") if isinstance(domain, bytes) else domain)
# _catalogs maps (language, domain) to IMessageCatalog instances
self._catalogs = {}
# _data maps IMessageCatalog.getIdentifier() to IMessageCatalog
@@ -63,7 +65,8 @@ class TranslationDomain(object):
self._fallbacks = fallbacks
def translate(self, msgid, mapping=None, context=None,
- target_language=None, default=None):
+ target_language=None, default=None,
+ msgid_plural=None, default_plural=None, number=None):
"""See zope.i18n.interfaces.ITranslationDomain"""
# if the msgid is empty, let's save a lot of calculations and return
# an empty string.
@@ -78,37 +81,43 @@ class TranslationDomain(object):
target_language = negotiator.getLanguage(langs, context)
return self._recursive_translate(
- msgid, mapping, target_language, default, context)
+ msgid, mapping, target_language, default, context,
+ msgid_plural, default_plural, number)
def _recursive_translate(self, msgid, mapping, target_language, default,
- context, seen=None):
+ context, msgid_plural, default_plural, number,
+ seen=None):
"""Recursively translate msg."""
# MessageID attributes override arguments
if isinstance(msgid, Message):
if msgid.domain != self.domain:
- return translate(msgid, msgid.domain, mapping, context,
- target_language, default)
+ return translate(
+ msgid, msgid.domain, mapping, context, target_language,
+ default, msgid_plural, default_plural, number)
default = msgid.default
mapping = msgid.mapping
+ msgid_plural = msgid.msgid_plural
+ default_plural = msgid.default_plural
+ number = msgid.number
# Recursively translate mappings, if they are translatable
if (mapping is not None
and Message in (type(m) for m in mapping.values())):
if seen is None:
seen = set()
- seen.add(msgid)
+ seen.add((msgid, msgid_plural))
mapping = mapping.copy()
for key, value in mapping.items():
if isinstance(value, Message):
# TODO Why isn't there an IMessage interface?
# https://bugs.launchpad.net/zope3/+bug/220122
- if value in seen:
+ if (value, value.msgid_plural) in seen:
raise ValueError(
"Circular reference in mappings detected: %s" %
value)
mapping[key] = self._recursive_translate(
- value, mapping, target_language,
- default, context, seen)
+ value, mapping, target_language, default, context,
+ msgid_plural, default_plural, number, seen)
if default is None:
default = text_type(msgid)
@@ -128,12 +137,23 @@ class TranslationDomain(object):
# single catalog. More importantly, it is extremely helpful
# when testing and the test language is used, because it
# allows the test language to get the default.
- text = self._data[catalog_names[0]].queryMessage(
- msgid, default)
+ if msgid_plural is not None:
+ # This is a plural
+ text = self._data[catalog_names[0]].queryPluralMessage(
+ msgid, msgid_plural, number, default, default_plural)
+ else:
+ text = self._data[catalog_names[0]].queryMessage(
+ msgid, default)
else:
for name in catalog_names:
catalog = self._data[name]
- s = catalog.queryMessage(msgid)
+ if msgid_plural is not None:
+ # This is a plural
+ s = catalog.queryPluralMessage(
+ msgid, msgid_plural, number,
+ default, default_plural)
+ else:
+ s = catalog.queryMessage(msgid)
if s is not None:
text = s
break
diff --git a/src/zope/i18n/zcml.py b/src/zope/i18n/zcml.py
index 39a4a29..4df9ccb 100644
--- a/src/zope/i18n/zcml.py
+++ b/src/zope/i18n/zcml.py
@@ -100,7 +100,7 @@ def registerTranslations(_context, directory, domain='*'):
loaded = True
domain_file = os.path.basename(domain_path)
name = domain_file[:-3]
- if not name in domains:
+ if name not in domains:
domains[name] = {}
domains[name][language] = domain_path
if loaded: