Michael Merickel
2018-10-26 9c086aac7c53399506eb68f29b296ebbfb8e29d4
commit | author | age
7534ba 1 import gettext
CM 2 import os
3
0c1c39 4 from translationstring import (
CM 5     Translator,
6     Pluralizer,
0c29cf 7     TranslationString,  # API
MM 8     TranslationStringFactory,  # API
9 )
7534ba 10
bc37a5 11 from pyramid.compat import PY2
330164 12 from pyramid.decorator import reify
0c1c39 13
CM 14 from pyramid.interfaces import (
15     ILocalizer,
16     ITranslationDirectories,
17     ILocaleNegotiator,
0c29cf 18 )
7534ba 19
b60bdb 20 from pyramid.threadlocal import get_current_registry
7534ba 21
25c64c 22 TranslationString = TranslationString  # PyFlakes
JA 23 TranslationStringFactory = TranslationStringFactory  # PyFlakes
24
bff312 25 DEFAULT_PLURAL = lambda n: int(n != 1)
25c64c 26
0c29cf 27
7534ba 28 class Localizer(object):
CM 29     """
30     An object providing translation and pluralizations related to
2033ee 31     the current request's locale name.  A
SP 32     :class:`pyramid.i18n.Localizer` object is created using the
33     :func:`pyramid.i18n.get_localizer` function.
34     """
0c29cf 35
7534ba 36     def __init__(self, locale_name, translations):
CM 37         self.locale_name = locale_name
38         self.translations = translations
39         self.pluralizer = None
40         self.translator = None
41
42     def translate(self, tstring, domain=None, mapping=None):
43         """
44         Translate a :term:`translation string` to the current language
45         and interpolate any *replacement markers* in the result.  The
46         ``translate`` method accepts three arguments: ``tstring``
47         (required), ``domain`` (optional) and ``mapping`` (optional).
48         When called, it will translate the ``tstring`` translation
49         string to a ``unicode`` object using the current locale.  If
50         the current locale could not be determined, the result of
51         interpolation of the default value is returned.  The optional
52         ``domain`` argument can be used to specify or override the
53         domain of the ``tstring`` (useful when ``tstring`` is a normal
54         string rather than a translation string).  The optional
55         ``mapping`` argument can specify or override the ``tstring``
56         interpolation mapping, useful when the ``tstring`` argument is
57         a simple string instead of a translation string.
58
59         Example::
60
c81aad 61            from pyramid.18n import TranslationString
7534ba 62            ts = TranslationString('Add ${item}', domain='mypackage',
CM 63                                   mapping={'item':'Item'})
64            translated = localizer.translate(ts)
65
66         Example::
67
68            translated = localizer.translate('Add ${item}', domain='mypackage',
69                                             mapping={'item':'Item'})
70
71         """
72         if self.translator is None:
73             self.translator = Translator(self.translations)
74         return self.translator(tstring, domain=domain, mapping=mapping)
75
76     def pluralize(self, singular, plural, n, domain=None, mapping=None):
77         """
78         Return a Unicode string translation by using two
79         :term:`message identifier` objects as a singular/plural pair
80         and an ``n`` value representing the number that appears in the
81         message using gettext plural forms support.  The ``singular``
2da03f 82         and ``plural`` objects should be unicode strings. There is no
MW 83         reason to use translation string objects as arguments as all
84         metadata is ignored.
a54bc1 85
2da03f 86         ``n`` represents the number of elements. ``domain`` is the
MW 87         translation domain to use to do the pluralization, and ``mapping``
88         is the interpolation mapping that should be used on the result. If
89         the ``domain`` is not supplied, a default domain is used (usually
90         ``messages``).
a54bc1 91
7534ba 92         Example::
CM 93
94            num = 1
95            translated = localizer.pluralize('Add ${num} item',
96                                             'Add ${num} items',
97                                             num,
98                                             mapping={'num':num})
99
2da03f 100         If using the gettext plural support, which is required for
MW 101         languages that have pluralisation rules other than n != 1, the
102         ``singular`` argument must be the message_id defined in the
103         translation file. The plural argument is not used in this case.
104
105         Example::
106
107            num = 1
108            translated = localizer.pluralize('item_plural',
109                                             '',
110                                             num,
111                                             mapping={'num':num})
112
a54bc1 113
7534ba 114         """
CM 115         if self.pluralizer is None:
116             self.pluralizer = Pluralizer(self.translations)
0c29cf 117         return self.pluralizer(
MM 118             singular, plural, n, domain=domain, mapping=mapping
119         )
7534ba 120
CM 121
122 def default_locale_negotiator(request):
12cb6d 123     """ The default :term:`locale negotiator`.  Returns a locale name
CM 124     or ``None``.
125
126     - First, the negotiator looks for the ``_LOCALE_`` attribute of
b5dc7f 127       the request object (possibly set by a view or a listener for an
c3aae1 128       :term:`event`). If the attribute exists and it is not ``None``,
AH 129       its value will be used.
a54bc1 130
12cb6d 131     - Then it looks for the ``request.params['_LOCALE_']`` value.
CM 132
133     - Then it looks for the ``request.cookies['_LOCALE_']`` value.
134
135     - Finally, the negotiator returns ``None`` if the locale could not
136       be determined via any of the previous checks (when a locale
137       negotiator returns ``None``, it signifies that the
b5dc7f 138       :term:`default locale name` should be used.)
12cb6d 139     """
CM 140     name = '_LOCALE_'
141     locale_name = getattr(request, name, None)
7534ba 142     if locale_name is None:
12cb6d 143         locale_name = request.params.get(name)
CM 144         if locale_name is None:
145             locale_name = request.cookies.get(name)
7534ba 146     return locale_name
CM 147
0c29cf 148
7534ba 149 def negotiate_locale_name(request):
CM 150     """ Negotiate and return the :term:`locale name` associated with
330164 151     the current request."""
7534ba 152     try:
CM 153         registry = request.registry
154     except AttributeError:
155         registry = get_current_registry()
0c29cf 156     negotiator = registry.queryUtility(
MM 157         ILocaleNegotiator, default=default_locale_negotiator
158     )
12cb6d 159     locale_name = negotiator(request)
CM 160
161     if locale_name is None:
265ea8 162         settings = registry.settings or {}
7534ba 163         locale_name = settings.get('default_locale_name', 'en')
12cb6d 164
7534ba 165     return locale_name
CM 166
0c29cf 167
7534ba 168 def get_locale_name(request):
2033ee 169     """
SP 170     .. deprecated:: 1.5
171         Use :attr:`pyramid.request.Request.locale_name` directly instead.
172         Return the :term:`locale name` associated with the current request.
173     """
330164 174     return request.locale_name
0c29cf 175
7534ba 176
86bbe8 177 def make_localizer(current_locale_name, translation_directories):
CL 178     """ Create a :class:`pyramid.i18n.Localizer` object
a54bc1 179     corresponding to the provided locale name from the
86bbe8 180     translations found in the list of translation directories."""
251682 181     translations = Translations()
CL 182     translations._catalog = {}
f3e62c 183
58ca44 184     locales_to_try = []
f3e62c 185     if '_' in current_locale_name:
58ca44 186         locales_to_try = [current_locale_name.split('_')[0]]
CM 187     locales_to_try.append(current_locale_name)
188
189     # intent: order locales left to right in least specific to most specific,
190     # e.g. ['de', 'de_DE'].  This services the intent of creating a
191     # translations object that returns a "more specific" translation for a
192     # region, but will fall back to a "less specific" translation for the
193     # locale if necessary.  Ordering from least specific to most specific
194     # allows us to call translations.add in the below loop to get this
195     # behavior.
f3e62c 196
86bbe8 197     for tdir in translation_directories:
58ca44 198         locale_dirs = []
CM 199         for lname in locales_to_try:
200             ldir = os.path.realpath(os.path.join(tdir, lname))
201             if os.path.isdir(ldir):
202                 locale_dirs.append(ldir)
203
204         for locale_dir in locale_dirs:
251682 205             messages_dir = os.path.join(locale_dir, 'LC_MESSAGES')
CL 206             if not os.path.isdir(os.path.realpath(messages_dir)):
207                 continue
208             for mofile in os.listdir(messages_dir):
0c29cf 209                 mopath = os.path.realpath(os.path.join(messages_dir, mofile))
251682 210                 if mofile.endswith('.mo') and os.path.isfile(mopath):
fccffe 211                     with open(mopath, 'rb') as mofp:
CM 212                         domain = mofile[:-3]
213                         dtrans = Translations(mofp, domain)
214                         translations.add(dtrans)
251682 215
0c29cf 216     return Localizer(
MM 217         locale_name=current_locale_name, translations=translations
218     )
219
251682 220
7534ba 221 def get_localizer(request):
2033ee 222     """
SP 223     .. deprecated:: 1.5
224         Use the :attr:`pyramid.request.Request.localizer` attribute directly
225         instead.  Retrieve a :class:`pyramid.i18n.Localizer` object
226         corresponding to the current request's locale name.
227     """
330164 228     return request.localizer
0c29cf 229
9c8d43 230
7534ba 231 class Translations(gettext.GNUTranslations, object):
CM 232     """An extended translation catalog class (ripped off from Babel) """
233
234     DEFAULT_DOMAIN = 'messages'
235
236     def __init__(self, fileobj=None, domain=DEFAULT_DOMAIN):
237         """Initialize the translations catalog.
238
239         :param fileobj: the file-like object the translation should be read
240                         from
241         """
5b5cd6 242         # germanic plural by default; self.plural will be overwritten by
CM 243         # GNUTranslations._parse (called as a side effect if fileobj is
244         # passed to GNUTranslations.__init__) with a "real" self.plural for
245         # this domain; see https://github.com/Pylons/pyramid/issues/235
bff312 246         # It is only overridden the first time a new message file is found
MW 247         # for a given domain, so all message files must have matching plural
248         # rules if they are in the same domain. We keep track of if we have
249         # overridden so we can special case the default domain, which is always
250         # instantiated before a message file is read.
251         # See also https://github.com/Pylons/pyramid/pull/2102
252         self.plural = DEFAULT_PLURAL
7534ba 253         gettext.GNUTranslations.__init__(self, fp=fileobj)
8e606d 254         self.files = list(filter(None, [getattr(fileobj, 'name', None)]))
7534ba 255         self.domain = domain
CM 256         self._domains = {}
257
258     @classmethod
259     def load(cls, dirname=None, locales=None, domain=DEFAULT_DOMAIN):
260         """Load translations from the given directory.
261
262         :param dirname: the directory containing the ``MO`` files
263         :param locales: the list of locales in order of preference (items in
264                         this list can be either `Locale` objects or locale
265                         strings)
266         :param domain: the message domain
267         :return: the loaded catalog, or a ``NullTranslations`` instance if no
268                  matching translations were found
269         :rtype: `Translations`
270         """
271         if locales is not None:
272             if not isinstance(locales, (list, tuple)):
273                 locales = [locales]
274             locales = [str(l) for l in locales]
275         if not domain:
276             domain = cls.DEFAULT_DOMAIN
277         filename = gettext.find(domain, dirname, locales)
278         if not filename:
279             return gettext.NullTranslations()
fccffe 280         with open(filename, 'rb') as fp:
CM 281             return cls(fileobj=fp, domain=domain)
7534ba 282
CM 283     def __repr__(self):
0c29cf 284         return '<%s: "%s">' % (
MM 285             type(self).__name__,
286             self._info.get('project-id-version'),
287         )
7534ba 288
CM 289     def add(self, translations, merge=True):
290         """Add the given translations to the catalog.
291
292         If the domain of the translations is different than that of the
293         current catalog, they are added as a catalog that is only accessible
294         by the various ``d*gettext`` functions.
295
296         :param translations: the `Translations` instance with the messages to
297                              add
298         :param merge: whether translations for message domains that have
299                       already been added should be merged with the existing
300                       translations
301         :return: the `Translations` instance (``self``) so that `merge` calls
302                  can be easily chained
303         :rtype: `Translations`
304         """
305         domain = getattr(translations, 'domain', self.DEFAULT_DOMAIN)
bff312 306         if domain == self.DEFAULT_DOMAIN and self.plural is DEFAULT_PLURAL:
MW 307             self.plural = translations.plural
308
7534ba 309         if merge and domain == self.domain:
CM 310             return self.merge(translations)
311
312         existing = self._domains.get(domain)
313         if merge and existing is not None:
314             existing.merge(translations)
315         else:
316             translations.add_fallback(self)
317             self._domains[domain] = translations
318
319         return self
320
321     def merge(self, translations):
322         """Merge the given translations into the catalog.
323
324         Message translations in the specified catalog override any messages
325         with the same identifier in the existing catalog.
326
327         :param translations: the `Translations` instance with the messages to
328                              merge
329         :return: the `Translations` instance (``self``) so that `merge` calls
330                  can be easily chained
331         :rtype: `Translations`
332         """
333         if isinstance(translations, gettext.GNUTranslations):
334             self._catalog.update(translations._catalog)
335             if isinstance(translations, Translations):
336                 self.files.extend(translations.files)
337
338         return self
339
340     def dgettext(self, domain, message):
341         """Like ``gettext()``, but look the message up in the specified
342         domain.
343         """
344         return self._domains.get(domain, self).gettext(message)
0c29cf 345
7534ba 346     def ldgettext(self, domain, message):
a54bc1 347         """Like ``lgettext()``, but look the message up in the specified
7534ba 348         domain.
0c29cf 349         """
7534ba 350         return self._domains.get(domain, self).lgettext(message)
0c29cf 351
7534ba 352     def dugettext(self, domain, message):
CM 353         """Like ``ugettext()``, but look the message up in the specified
354         domain.
355         """
bc37a5 356         if PY2:
884807 357             return self._domains.get(domain, self).ugettext(message)
bc37a5 358         else:
MM 359             return self._domains.get(domain, self).gettext(message)
0c29cf 360
7534ba 361     def dngettext(self, domain, singular, plural, num):
CM 362         """Like ``ngettext()``, but look the message up in the specified
363         domain.
364         """
365         return self._domains.get(domain, self).ngettext(singular, plural, num)
0c29cf 366
7534ba 367     def ldngettext(self, domain, singular, plural, num):
CM 368         """Like ``lngettext()``, but look the message up in the specified
369         domain.
370         """
371         return self._domains.get(domain, self).lngettext(singular, plural, num)
0c29cf 372
7534ba 373     def dungettext(self, domain, singular, plural, num):
CM 374         """Like ``ungettext()`` but look the message up in the specified
375         domain.
376         """
bc37a5 377         if PY2:
MM 378             return self._domains.get(domain, self).ungettext(
0c29cf 379                 singular, plural, num
MM 380             )
cf4ad5 381         else:
bc37a5 382             return self._domains.get(domain, self).ngettext(
0c29cf 383                 singular, plural, num
MM 384             )
385
7534ba 386
330164 387 class LocalizerRequestMixin(object):
CM 388     @reify
389     def localizer(self):
390         """ Convenience property to return a localizer """
391         registry = self.registry
392
393         current_locale_name = self.locale_name
394         localizer = registry.queryUtility(ILocalizer, name=current_locale_name)
395
396         if localizer is None:
397             # no localizer utility registered yet
398             tdirs = registry.queryUtility(ITranslationDirectories, default=[])
399             localizer = make_localizer(current_locale_name, tdirs)
400
0c29cf 401             registry.registerUtility(
MM 402                 localizer, ILocalizer, name=current_locale_name
403             )
330164 404
CM 405         return localizer
406
407     @reify
408     def locale_name(self):
409         locale_name = negotiate_locale_name(self)
410         return locale_name