edgewall/trac

View on GitHub
trac/util/translation.py

Summary

Maintainability
D
2 days
Test Coverage
# -*- coding: utf-8 -*-
#
# Copyright (C) 2007-2023 Edgewall Software
# All rights reserved.
#
# This software is licensed as described in the file COPYING, which
# you should have received as part of this distribution. The terms
# are also available at https://trac.edgewall.org/wiki/TracLicense.
#
# This software consists of voluntary contributions made by many
# individuals. For the exact contribution history, see the revision
# history and logs, available at https://trac.edgewall.org/log/.

"""Utilities for text translation with gettext."""

import pkg_resources
import re

from trac.util.concurrency import ThreadLocal, threading
from trac.util.html import tag
from trac.util.text import cleandoc


__all__ = ['gettext', 'ngettext', 'gettext_noop', 'ngettext_noop',
           'tgettext', 'tgettext_noop', 'tngettext', 'tngettext_noop']


def safefmt(string, kwargs):
    if kwargs:
        try:
            return string % kwargs
        except KeyError:
            pass
    return string


def gettext_noop(string, **kwargs):
    return safefmt(string, kwargs)

def dgettext_noop(domain, string, **kwargs):
    return gettext_noop(string, **kwargs)

N_ = _noop = lambda string: string
cleandoc_ = cleandoc

def ngettext_noop(singular, plural, num, **kwargs):
    string = singular if num == 1 else plural
    kwargs.setdefault('num', num)
    return safefmt(string, kwargs)

def dngettext_noop(domain, singular, plural, num, **kwargs):
    return ngettext_noop(singular, plural, num, **kwargs)

_param_re = re.compile(r"%\((\w+)\)(?:s|[\d]*d|\d*.?\d*[fg])")
def _tag_kwargs(trans, kwargs):
    trans_elts = _param_re.split(trans)
    for i in range(1, len(trans_elts), 2):
        trans_elts[i] = kwargs.get(trans_elts[i], '???')
    return tag(*trans_elts)

def tgettext_noop(string, **kwargs):
    return _tag_kwargs(string, kwargs) if kwargs else string

def dtgettext_noop(domain, string, **kwargs):
    return tgettext_noop(string, **kwargs)

def tngettext_noop(singular, plural, num, **kwargs):
    string = singular if num == 1 else plural
    kwargs.setdefault('num', num)
    return _tag_kwargs(string, kwargs)

def dtngettext_noop(domain, singular, plural, num, **kwargs):
    return tngettext_noop(singular, plural, num, **kwargs)

def add_domain(domain, env_path, locale_dir):
    pass

def domain_functions(domain, *symbols):
    if symbols and not isinstance(symbols[0], str):
        symbols = symbols[0]
    _functions = {
      'gettext': s_gettext,
      '_': gettext_noop,
      'N_': _noop,
      'ngettext': ngettext_noop,
      'tgettext': tgettext_noop,
      'tag_': tgettext_noop,
      'tngettext': tngettext_noop,
      'tagn_': tngettext_noop,
      'add_domain': lambda env_path, locale_dir: None,
      }
    return [_functions[s] for s in symbols]


from gettext import NullTranslations

class NullTranslationsBabel(NullTranslations):
    """NullTranslations doesn't have the domain related methods."""

    def dugettext(self, domain, string):
        return self.gettext(string)

    def dungettext(self, domain, singular, plural, num):
        return self.ngettext(singular, plural, num)

has_babel = False

try:
    from babel import Locale, UnknownLocaleError
    from babel.support import LazyProxy, Translations

    class TranslationsProxy(object):
        """Delegate Translations calls to the currently active Translations.

        If there's none, wrap those calls in LazyProxy objects.

        Activation is controlled by `activate` and `deactivate` methods.
        However, if retrieving the locale information is costly, it's also
        possible to enable activation on demand only, by providing a callable
        to `make_activable`.
        """

        def __init__(self):
            self._current = ThreadLocal(args=None, translations=None)
            self._null_translations = NullTranslationsBabel()
            self._plugin_domains = {}
            self._plugin_domains_lock = threading.RLock()
            self._activate_failed = False

        # Public API

        def add_domain(self, domain, env_path, locales_dir):
            with self._plugin_domains_lock:
                domains = self._plugin_domains.setdefault(env_path, {})
                domains[domain] = locales_dir

        def make_activable(self, get_locale, env_path=None):
            self._current.args = (get_locale, env_path)

        def activate(self, locale, env_path=None):
            try:
                locale_dir = pkg_resources.resource_filename('trac', 'locale')
            except Exception:
                self._activate_failed = True
                return
            t = Translations.load(locale_dir, locale or 'en_US')
            if not isinstance(t, Translations):
                t = self._null_translations
            else:
                self._add(t, Translations.load(locale_dir, locale or 'en_US',
                                               'tracini'))
                if env_path:
                    with self._plugin_domains_lock:
                        domains = self._plugin_domains.get(env_path, {})
                        domains = list(domains.items())
                    for domain, dirname in domains:
                        self._add(t, Translations.load(dirname, locale,
                                                       domain))
            self._current.translations = t
            self._activate_failed = False

        def deactivate(self):
            self._current.args = None
            t, self._current.translations = self._current.translations, None
            return t

        def reactivate(self, t):
            if t:
                self._current.translations = t

        @property
        def active(self):
            return self._current.translations or self._null_translations

        @property
        def isactive(self):
            if self._current.args is not None:
                get_locale, env_path = self._current.args
                self._current.args = None
                self.activate(get_locale(), env_path)
            # FIXME: The following always returns True: either a translation is
            # active, or activation has failed.
            return self._current.translations is not None \
                   or self._activate_failed

        # Internal methods

        def _add(self, t, translations):
            if isinstance(translations, Translations):
                t.add(translations)

        # Delegated methods

        def __getattr__(self, name):
            return getattr(self.active, name)

        def gettext(self, string, **kwargs):
            def _gettext():
                return safefmt(self.active.gettext(string), kwargs)
            if not self.isactive:
                return LazyProxy(_gettext)
            return _gettext()

        def dgettext(self, domain, string, **kwargs):
            def _dgettext():
                return safefmt(self.active.dugettext(domain, string), kwargs)
            if not self.isactive:
                return LazyProxy(_dgettext)
            return _dgettext()

        def ngettext(self, singular, plural, num, **kwargs):
            kwargs = kwargs.copy()
            kwargs.setdefault('num', num)
            def _ngettext():
                trans = self.active.ngettext(singular, plural, num)
                return safefmt(trans, kwargs)
            if not self.isactive:
                return LazyProxy(_ngettext)
            return _ngettext()

        def dngettext(self, domain, singular, plural, num, **kwargs):
            kwargs = kwargs.copy()
            kwargs.setdefault('num', num)
            def _dngettext():
                trans = self.active.dungettext(domain, singular, plural, num)
                return safefmt(trans, kwargs)
            if not self.isactive:
                return LazyProxy(_dngettext)
            return _dngettext()

        def tgettext(self, string, **kwargs):
            def _tgettext():
                trans = self.active.gettext(string)
                return _tag_kwargs(trans, kwargs) if kwargs else trans
            if not self.isactive:
                return LazyProxy(_tgettext)
            return _tgettext()

        def dtgettext(self, domain, string, **kwargs):
            def _dtgettext():
                trans = self.active.dugettext(domain, string)
                return _tag_kwargs(trans, kwargs) if kwargs else trans
            if not self.isactive:
                return LazyProxy(_dtgettext)
            return _dtgettext()

        def tngettext(self, singular, plural, num, **kwargs):
            kwargs = kwargs.copy()
            kwargs.setdefault('num', num)
            def _tngettext():
                trans = self.active.ngettext(singular, plural, num)
                return _tag_kwargs(trans, kwargs)
            if not self.isactive:
                return LazyProxy(_tngettext)
            return _tngettext()

        def dtngettext(self, domain, singular, plural, num, **kwargs):
            kwargs = kwargs.copy()
            def _dtngettext():
                trans = self.active.dungettext(domain, singular, plural, num)
                if '%(num)' in trans:
                    kwargs.update(num=num)
                return _tag_kwargs(trans, kwargs) if kwargs else trans
            if not self.isactive:
                return LazyProxy(_dtngettext)
            return _dtngettext()


    translations = TranslationsProxy()

    def domain_functions(domain, *symbols):
        """Prepare partial instantiations of domain translation functions.

        :param domain: domain used for partial instantiation
        :param symbols: remaining parameters are the name of commonly used
                        translation function which will be bound to the domain

        Note: the symbols can also be given as an iterable in the 2nd argument.
        """
        if symbols and not isinstance(symbols[0], str):
            symbols = symbols[0]
        _functions = {
          'gettext': s_dgettext,
          '_': translations.dgettext,
          'ngettext': translations.dngettext,
          'tgettext': translations.dtgettext,
          'tag_': translations.dtgettext,
          'tngettext': translations.dtngettext,
          'tagn_': translations.dtngettext,
          'add_domain': translations.add_domain,
          }
        def wrapdomain(symbol):
            if symbol == 'N_':
                return _noop
            if symbol not in _functions:
                raise KeyError(symbol)
            return lambda *args, **kw: _functions[symbol](domain, *args, **kw)
        return [wrapdomain(s) for s in symbols]

    gettext = translations.gettext
    _ = gettext
    dgettext = translations.dgettext
    ngettext = translations.ngettext
    dngettext = translations.dngettext
    tgettext = translations.tgettext
    tag_ = tgettext
    dtgettext = translations.dtgettext
    tngettext = translations.tngettext
    tagn_ = tngettext
    dtngettext = translations.dtngettext

    def add_domain(domain, env_path, locale_dir):
        translations.add_domain(domain, env_path, locale_dir)

    def activate(locale, env_path=None):
        translations.activate(locale, env_path)

    def deactivate():
        """Deactivate translations.
        :return: the current Translations, if any
        """
        return translations.deactivate()

    def reactivate(t):
        """Reactivate previously deactivated translations.
        :param t: the Translations, as returned by `deactivate`
        """
        return translations.reactivate(t)

    def make_activable(get_locale, env_path=None):
        """Defer activation of translations.
        :param get_locale: a callable returning a Babel Locale object
        :param env_path: the environment to use for looking up catalogs
        """
        translations.make_activable(get_locale, env_path)

    def get_translations():
        return translations

    def get_available_locales():
        """Return a list of locale identifiers of the locales for which
        translations are available.
        """
        try:
            locales = [dirname for dirname
                       in pkg_resources.resource_listdir('trac', 'locale')
                       if '.' not in dirname
                       and pkg_resources.resource_exists(
                        'trac', 'locale/%s/LC_MESSAGES/messages.mo' % dirname)]
            return locales
        except Exception:
            return []

    def get_negotiated_locale(preferred_locales):
        def normalize(locale_ids):
            return [id.replace('-', '_') for id in locale_ids if id]
        available_locales = get_available_locales()
        if 'en_US' not in available_locales:
            available_locales.append('en_US')
        locale = Locale.negotiate(normalize(preferred_locales),
                                  normalize(available_locales))
        if locale and str(locale) not in available_locales:
            # The list of get_available_locales() must include locale
            # identifier from str(locale), but zh_* won't be included
            # after Babel 1.0. Avoid expanding zh_* to zh_Hans_CN and
            # zh_Hant_TW to clear "script" property of Locale
            # instance. See #11258.
            locale._data  # load localedata before clear script property
            locale.script = None
            assert str(locale) in available_locales
        return locale

    def get_locale_name(locale_id):
        """"Return locale name from locale identifier, or `None` if
        identifier is not valid.
        """
        try:
            locale = Locale.parse(locale_id)
        except (UnknownLocaleError, ValueError):
            return None
        else:
            return locale.display_name

    has_babel = True

except ImportError: # fall back on 0.11 behavior, i18n functions are no-ops
    Locale = None
    gettext = _ = gettext_noop
    dgettext = dgettext_noop
    ngettext = ngettext_noop
    dngettext = dngettext_noop
    tgettext = tag_ = tgettext_noop
    dtgettext = dtgettext_noop
    tngettext = tagn_ = tngettext_noop
    dtngettext = dtngettext_noop

    translations = NullTranslationsBabel()

    def activate(locale, env_path=None):
        pass

    def deactivate():
        pass

    def reactivate(t):
        pass

    def make_activable(get_locale, env_path=None):
        pass

    def get_translations():
        return translations

    def get_available_locales():
        return []

    def get_negotiated_locale(preferred_locales):
        return None

    def get_locale_name(locale_id):
        return None

# White-space simplification of msgids

def s_dgettext(domain, msgid, **kwargs):
    """Retrieves translations for "squeezed" messages, in a domain.

    See `s_gettext` for additional details.

    """
    msgid = ' '.join(msgid.split())  # avoid to extract ' '
    return dgettext(domain, msgid, **kwargs)

def s_gettext(msgid, **kwargs):
    """Retrieves translations for "squeezed" messages (in default domain).

    Squeezed messages are text blocks in which white-space has been
    simplified during extraction (see `trac.dist.extract_html`). The
    catalog contain msgid with minimal whitespace.  As a consequence,
    the msgid have to be normalized as well at retrieval time
    (i.e. here).

    This typically happens for trans blocks and gettext functions in
    Jinja2 templates, as well as all the text extracted from legacy
    Genshi templates.

    """
    msgid = ' '.join(msgid.split())  # avoid to extract ' '
    return gettext(msgid, **kwargs)

# TODO (1.3.2) do the same for pluralize (ngettext/dngettext_noop)

functions = {
    '_': gettext,
    'dgettext': s_dgettext,
    'dngettext': dngettext,
    'gettext': s_gettext,
    'ngettext': ngettext,
    'tag_': tag_,
    'tagn_': tagn_,
    'dtgettext': dtgettext,
    'dtngettext': dtngettext,
}