trac/util/translation.py
# -*- 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,
}