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