diff --git a/rdiffweb/filter_authentication.py b/rdiffweb/filter_authentication.py
index 8064f6b3db0f742dd754ad53a0ac0e157ef41d2a..fef8b0f186004cbe12ed3b0090b8f7a87bffc77b 100644
--- a/rdiffweb/filter_authentication.py
+++ b/rdiffweb/filter_authentication.py
@@ -159,7 +159,7 @@ class AuthFormTool(BaseAuth):
# Add welcome message to params. Try to load translated message.
params["welcome_msg"] = app.cfg.get_config("WelcomeMsg")
if hasattr(cherrypy.response, 'i18n'):
- lang = cherrypy.response.i18n._lang
+ lang = cherrypy.response.i18n.locale.language
params["welcome_msg"] = app.cfg.get_config("WelcomeMsg[%s]" % (lang), params["welcome_msg"])
return main_page._compile_template("login.html", **params).encode("utf-8")
diff --git a/rdiffweb/i18n.py b/rdiffweb/i18n.py
index 34af04d3215037d09356c43bc37354d59d8904c4..97480c7afc10f3a902b005a13ef60088928048f2 100644
--- a/rdiffweb/i18n.py
+++ b/rdiffweb/i18n.py
@@ -15,43 +15,115 @@
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
-"""
-This module is used to load the appropriate translation for rdiffweb
-application. Current implementation uses the HTTP Accept-Language header
-to determine the best translation to be used for the current user. When
-not found, this module default to use the `DefaultLanguage`as define by
-the configuration of rdiffweb.
-This module allows to switch the domain and language of use when processing a
-request. This it mostly used to handle plugin translation.
+"""Internationalization and Localization for CherryPy
+
+**Tested with CherryPy 3.1.2**
+
+This tool provides locales and loads translations based on the
+HTTP-ACCEPT-LANGUAGE header. If no header is send or the given language
+is not supported by the application, it falls back to
+`tools.I18nTool.default`. Set `default` to the native language used in your
+code for strings, so you must not provide a .mo file for it.
+
+The tool uses `babel`_ for localization and
+handling translations. Within your Python code you can use four functions
+defined in this module and the loaded locale provided as
+`cherrypy.response.i18n.locale`.
+
+Example::
+
+ from i18n_tool import ugettext as _, ungettext
+
+ class MyController(object):
+ @cherrypy.expose
+ def index(self):
+ loc = cherrypy.response.i18n.locale
+ s1 = _(u'Translateable string')
+ s2 = ungettext(u'There is one string.',
+ u'There are more strings.', 2)
+ return u'
'.join([s1, s2, loc.display_name])
+
+If you have code (e.g. database models) that is executed before the response
+object is available, use the *_lazy functions to mark the strings
+translateable. They will be translated later on, when the text is used (and
+hopefully the response object is available then).
+
+Example::
+
+ from i18n_tool import ugettext_lazy
+
+ class Model:
+ def __init__(self):
+ name = ugettext_lazy(u'Name of the model')
+
+For your templates read the documentation of your template engine how to
+integrate babel with it. I think `Genshi`_ and
+`Jinja 2//LC_MESSAGES/.mo
+
+That's it.
+
+:License: BSD
+:Author: Thorsten Weimann
+:Date: 2010-02-08
"""
+
from __future__ import unicode_literals
import cherrypy
-import copy
-import gettext
-import logging
-import os
-import pkg_resources
+from babel.core import Locale, UnknownLocaleError
+from babel.support import Translations
+
+try:
+ # Python 2.6 and above
+ from collections import namedtuple
+ Lang = namedtuple('Lang', 'locale trans')
+except ImportError:
+ # Python 2.5
+ class Lang(object):
-_logger = logging.getLogger(__name__)
+ def __init__(self, locale, trans):
+ self.locale = locale
+ self.trans = trans
# Cache for Translations and Locale objects
-_translations = {}
+_languages = {}
+
+# Exception
+class ImproperlyConfigured(Exception):
+ """Raised if no known locale were found."""
+
+# Public translation functions
def ugettext(message):
- """
- Standard translation function. You can use it in all your exposed
+ """Standard translation function. You can use it in all your exposed
methods and everywhere where the response object is available.
:parameters:
@@ -63,7 +135,7 @@ def ugettext(message):
"""
if not hasattr(cherrypy.response, "i18n"):
return message
- return cherrypy.response.i18n.ugettext(message)
+ return cherrypy.response.i18n.trans.ugettext(message)
def ungettext(singular, plural, num):
@@ -83,169 +155,102 @@ def ungettext(singular, plural, num):
"""
if not hasattr(cherrypy.response, "i18n"):
return singular
- return cherrypy.response.i18n.ungettext(singular, plural, num)
+ return cherrypy.response.i18n.trans.ungettext(singular, plural, num)
-def _find(domain, localedirs, languages):
- """
- Replacement for gettext.find() to search in multiple directory. This
- function return tuples for each mo file found: (lang, translation).
- """
- # now normalize and expand the languages
- nelangs = []
- for lang in languages:
- for nelang in gettext._expand_lang(lang):
- if nelang not in nelangs:
- nelangs.append(nelang)
- # select a language
- result = []
- for lang in nelangs:
- for localedir in localedirs:
- mofile = os.path.join(localedir, lang, 'LC_MESSAGES', '%s.mo' % domain)
- if os.path.exists(mofile):
- entry = (lang, mofile)
- result.append(entry)
- return result
-
-
-def get_accept_languages():
- """
- Return ordered list of accepted languages for the current request.
+def load_translation(langs, dirname, domain):
+ """Loads the first existing translations for known locale and saves the
+ `Lang` object in a global cache for faster lookup on the next request.
- The `DefaultLanguage` get the lowest priority.
- """
- # Determine default language.
- default = "en_US"
- if cherrypy.request.app:
- app = cherrypy.request.app
- default = app.cfg.get_config("DefaultLanguage", default)
-
- # Determine the language to be used according to accept-language.
- langs = list()
- for l in cherrypy.request.headers.elements('Accept-Language'):
- l = l.value.replace('-', '_')
- if l not in langs:
- langs.append(l)
-
- if default not in langs:
- langs.append(default)
-
- return langs
-
-
-def get_current_lang():
- """
- Return the lang being currently served to the user.
- """
- if not hasattr(cherrypy.response, "i18n"):
- return "en"
- return cherrypy.response.i18n._lang
-
-
-def get_localedirs():
- """
- Return a list of locales directory where to search for mo files. This
- include locales from plugins.
- """
- localesdirs = [
- pkg_resources.resource_filename(__package__, 'locales') # @UndefinedVariable
- ]
- if cherrypy.request.app:
- app = cherrypy.request.app
- # Get more directory from app plugins.
- for p in app.plugins.get_all_plugins():
- if p.get_localesdir():
- localesdirs.append(p.get_localesdir())
- return localesdirs
-
-
-def get_translation(domain="messages"):
- """
- Return a translation object for the given `domain`. This method is similar
- to gettext.translation. The localdir is determine using package and plugins
- path, the language to look at is determine using the HTTP Accept-Language.
- """
- # Search for an appropriate translation.
- return _translation(domain, get_localedirs(), get_accept_languages())
-
-
-def load_translation():
- """
- Main function which will be invoked during the request by `I18nTool`.
- The translation object will be saved as `cherrypy.response.i18n`.
- """
- # Store the translation into the cherrypy context.
- cherrypy.response.i18n = get_translation()
-
-
-def _translation(domain, localedirs=None, languages=None):
- """
- Replacement for gettext.translation(). Return the best matching translation
- object for the given `domain`. This method search in localesdirs for a
- translation file (.mo) matching the `languages`.
-
- Return a null translation object if a translation matching `languages`
- can't be found.
+ :parameters:
+ langs : List
+ List of languages as returned by `parse_accept_language_header`.
+ dirname : String
+ Directory of the translations (`tools.I18nTool.mo_dir`).
+ Might be a list of directories.
+ domain : String
+ Gettext domain of the catalog (`tools.I18nTool.domain`).
+
+ :returns: Lang object with two attributes (Lang.trans = the translations
+ object, Lang.locale = the corresponding Locale object).
+ :rtype: Lang
+ :raises: ImproperlyConfigured if no locale where known.
"""
+ if not isinstance(dirname, (list, tuple)):
+ dirname = [dirname]
+ locale = None
+ trans = None
+ for lang in langs:
+ short = lang[:2].lower()
+ try:
+ locale = Locale.parse(lang)
+ if (domain, short) in _languages:
+ return _languages[(domain, short)]
+ # Get all translation from all directories.
+ trans = None
+ for d in dirname:
+ t = Translations.load(d, short, domain)
+ if not isinstance(t, Translations):
+ continue
+ if trans:
+ trans.add_fallback(t)
+ else:
+ trans = t
+ except (ValueError, UnknownLocaleError):
+ continue
+ # If the translation was found, exit loop
+ if isinstance(trans, Translations):
+ break
+ if locale is None:
+ raise ImproperlyConfigured('Default locale not known.')
+ _languages[(domain, short)] = res = Lang(locale, trans)
+ return res
+
+
+def get_lang(mo_dir, default, domain):
+ """Main function which will be invoked during the request by `I18nTool`.
+ If the SessionTool is on and has a lang key, this language get the
+ highest priority. Default language get the lowest priority.
+ The `Lang` object will be saved as `cherrypy.response.i18n` and the
+ language string will also saved as `cherrypy.session['_lang_']` (if
+ SessionTool is on).
- # Use our internal find function to lookup for translation.
- mofiles = _find(domain, localedirs, languages)
-
- # Lookup the mo files.
- result = None
- for lang, mofile in mofiles:
- # Search the cache to avoid parsing the same file again.
- key = os.path.abspath(mofile)
- t = _translations.get(key)
- if t is None:
- with open(mofile, 'rb') as fp:
- t = _translations.setdefault(key, gettext.GNUTranslations(fp))
- # Copy the translation object to allow setting fallbacks. All other
- # instance data is shared with the cached object.
- t = copy.copy(t)
- if result is None:
- t._lang = lang
- result = t
- else:
- result.add_fallback(t)
-
- # Add null translation as fallback
- if result is None:
- t = gettext.NullTranslations()
- t._lang = "en_US"
- result = t
-
- # For py2/py3 compatibility (patch ugettext).
- if not hasattr(result, 'ugettext'):
- result.ugettext = result.gettext
- if not hasattr(result, 'ungettext'):
- result.ungettext = result.ngettext
- return result
-
-
-def _set_content_lang():
+ :parameters:
+ mo_dir : String
+ `tools.I18nTool.mo_dir`
+ default : String
+ `tools.I18nTool.default`
+ domain : String
+ `tools.I18nTool.domain`
"""
- Sets the Content-Language response header (if not already set) to the
+ langs = [x.value.replace('-', '_') for x in
+ cherrypy.request.headers.elements('Accept-Language')]
+ sessions_on = cherrypy.request.config.get('tools.sessions.on', False)
+ if sessions_on and cherrypy.session.get('_lang_', ''): # @UndefinedVariable
+ langs.insert(0, cherrypy.session.get('_lang_', '__')) # @UndefinedVariable
+ langs.append(default)
+ loc = load_translation(langs, mo_dir, domain)
+ cherrypy.response.i18n = loc
+ if sessions_on:
+ cherrypy.session['_lang_'] = str(loc.locale) # @UndefinedVariable
+
+
+def set_lang():
+ """Sets the Content-Language response header (if not already set) to the
language of `cherrypy.response.i18n.locale`.
"""
- # Just to make it clear, this is to properly reply to the client telling
- # them the language used in the content.
- if ('Content-Language' not in cherrypy.response.headers and
- hasattr(cherrypy.response, 'i18n')):
- cherrypy.response.headers['Content-Language'] = cherrypy.response.i18n._lang
+ if 'Content-Language' not in cherrypy.response.headers:
+ cherrypy.response.headers['Content-Language'] = str(cherrypy.response.i18n.locale)
class I18nTool(cherrypy.Tool):
- """
- Tool to load the appropriate translation.
- """
+ """Tool to integrate babel translations in CherryPy."""
def __init__(self):
self._name = 'i18n'
self._point = 'before_handler'
- self.callable = load_translation
+ self.callable = get_lang
# Make sure, session tool (priority 50) is loaded before
- # Make sure to run before AuthFormTool (priority 70)
self._priority = 60
def _setup(self):
@@ -254,7 +259,7 @@ class I18nTool(cherrypy.Tool):
c.get('tools.staticfile.on', False):
return
cherrypy.Tool._setup(self)
- cherrypy.request.hooks.attach('before_finalize', _set_content_lang)
+ cherrypy.request.hooks.attach('before_finalize', set_lang)
cherrypy.tools.i18n = I18nTool()
diff --git a/rdiffweb/page_main.py b/rdiffweb/page_main.py
index 8042fe679dc60c4aa32cb2bf981f95732858b43b..c68e56192d7346587bff7f51b4033032de7cac59 100644
--- a/rdiffweb/page_main.py
+++ b/rdiffweb/page_main.py
@@ -25,8 +25,7 @@ from future.utils.surrogateescape import encodefilename
import logging
from rdiffweb.core import Component
-from rdiffweb.i18n import get_current_lang
-from rdiffweb.librdiff import RdiffRepo, AccessDeniedError, DoesNotExistError
+from rdiffweb.librdiff import RdiffRepo, DoesNotExistError
from rdiffweb.rdw_plugin import ITemplateFilterPlugin
@@ -123,8 +122,9 @@ class MainPage(Component):
This method should be used by subclasses to provide default template
value.
"""
+ loc = cherrypy.response.i18n.locale
parms = {
- "lang": get_current_lang(),
+ "lang": loc.language,
"version": self.app.get_version(),
"extra_head_templates": [],
}
diff --git a/rdiffweb/rdw_app.py b/rdiffweb/rdw_app.py
index d112ab22e7a5db99771274e730aad03f6496bc33..d54101f81a70eccfe6b7c5da19a48dfe6eb1a7d0 100644
--- a/rdiffweb/rdw_app.py
+++ b/rdiffweb/rdw_app.py
@@ -107,6 +107,9 @@ class RdiffwebApp(Application):
native_str('/'): {
'tools.authform.on': True,
'tools.i18n.on': True,
+ 'tools.i18n.default': 'en_US',
+ 'tools.i18n.mo_dir': self._localedirs(),
+ 'tools.i18n.domain': 'messages',
'tools.encode.on': True,
'tools.encode.encoding': 'utf-8',
'tools.gzip.on': True,
@@ -201,6 +204,20 @@ class RdiffwebApp(Application):
if tempdir:
os.environ["TMPDIR"] = tempdir
+ def _localedirs(self):
+ """
+ Return a list of locales directory where to search for mo files. This
+ include locales from plugins.
+ """
+ localesdirs = [
+ pkg_resources.resource_filename('rdiffweb', 'locales') # @UndefinedVariable
+ ]
+ # Get more directory from app plugins.
+ for p in self.plugins.get_all_plugins():
+ if p.get_localesdir():
+ localesdirs.append(p.get_localesdir())
+ return localesdirs
+
def _setup_session_storage(self, config):
# Configure session storage.
session_storage = self.cfg.get_config("SessionStorage")
diff --git a/rdiffweb/rdw_templating.py b/rdiffweb/rdw_templating.py
index 570107f4d4d4e75ce5cb373020ff19c17d3f70a8..34550258634f6ee7d01711118b91f08d0d2cf2f4 100755
--- a/rdiffweb/rdw_templating.py
+++ b/rdiffweb/rdw_templating.py
@@ -24,7 +24,6 @@ from builtins import object
from builtins import str
from io import StringIO
from jinja2 import Environment, PackageLoader
-from jinja2.ext import _make_new_gettext, _make_new_ngettext
from jinja2.loaders import ChoiceLoader, FileSystemLoader
import logging
import time
@@ -217,16 +216,6 @@ def url_for_status_entry(date, repo=None):
return ''.join(url)
-def _get_translation(domain):
- """
- Used in templates to load a different translation domain.
- """
- t = i18n.get_translation(domain)
- t.ugettext = _make_new_gettext(t.ugettext)
- t.ungettext = _make_new_ngettext(t.ungettext)
- return t
-
-
class TemplateManager(object):
"""
Uses to generate HTML page from template using Jinja2 templating.
@@ -261,8 +250,6 @@ class TemplateManager(object):
self.jinja_env.globals['url_for_restore'] = url_for_restore
self.jinja_env.globals['url_for_settings'] = url_for_settings
self.jinja_env.globals['url_for_status_entry'] = url_for_status_entry
- self.jinja_env.globals['load_translation'] = _get_translation
- self.jinja_env.globals['get_translation'] = _get_translation
def add_templatesdir(self, templates_dir):
"""
diff --git a/rdiffweb/tests/test_i18n.py b/rdiffweb/tests/test_i18n.py
index 5631674712caaa47c0692b3aeb4c9034b1c206c2..8d76425afc0a946f5cec10e91abccf167476b623 100644
--- a/rdiffweb/tests/test_i18n.py
+++ b/rdiffweb/tests/test_i18n.py
@@ -25,6 +25,7 @@ Module used to test the i18n tools. Check if translation are properly loaded.
from __future__ import unicode_literals
+from cherrypy import _cpconfig
import cherrypy
import gettext
import pkg_resources
@@ -38,37 +39,46 @@ class Test(unittest.TestCase):
def setUp(self):
self.mo_dir = pkg_resources.resource_filename('rdiffweb', 'locales') # @UndefinedVariable
+ cherrypy.request.config = _cpconfig.Config()
def tearDown(self):
pass
def test_load_translation(self):
# Load default translation
- t = i18n.get_translation()
+ i18n.get_lang(self.mo_dir, 'en_US', 'messages')
+ t = cherrypy.response.i18n.trans
+ l = cherrypy.response.i18n.locale
self.assertIsInstance(t, gettext.GNUTranslations)
- self.assertEqual("en", t._lang)
+ self.assertEqual("en", l.language)
def test_load_translation_with_accept_language_fr(self):
# Mock a header
cherrypy.request.headers["Accept-Language"] = "fr_CA,fr,en_en_US"
# Load default translation
- t = i18n.get_translation()
+ i18n.get_lang(self.mo_dir, 'en_US', 'messages')
+ t = cherrypy.response.i18n.trans
+ l = cherrypy.response.i18n.locale
self.assertIsInstance(t, gettext.GNUTranslations)
- self.assertEqual("fr", t._lang)
+ self.assertEqual("fr", l.language)
def test_load_translation_with_accept_language_unknown(self):
# Mock a header
cherrypy.request.headers["Accept-Language"] = "br_CA"
# Load default translation
- t = i18n.get_translation()
+ i18n.get_lang(self.mo_dir, 'en_US', 'messages')
+ t = cherrypy.response.i18n.trans
+ l = cherrypy.response.i18n.locale
self.assertIsInstance(t, gettext.GNUTranslations)
- self.assertEqual("en", t._lang)
+ self.assertEqual("en", l.language)
def test_translation_with_fr(self):
# Get trans
- t = i18n._translation("messages", [self.mo_dir], ["fr"])
+ i18n.get_lang(self.mo_dir, 'fr', 'messages')
+ t = cherrypy.response.i18n.trans
+ l = cherrypy.response.i18n.locale
self.assertIsInstance(t, gettext.GNUTranslations)
- self.assertEqual("fr", t._lang)
+ self.assertEqual("fr", l.language)
# Test translation object
self.assertEqual("Modifier", t.gettext("Edit"))
# Check if the translation fallback
@@ -77,30 +87,38 @@ class Test(unittest.TestCase):
def test_translation_with_en(self):
# Get trans
- t = i18n._translation("messages", [self.mo_dir], ["en"])
+ i18n.get_lang(self.mo_dir, 'en', 'messages')
+ t = cherrypy.response.i18n.trans
+ l = cherrypy.response.i18n.locale
self.assertIsInstance(t, gettext.GNUTranslations)
- self.assertEqual("en", t._lang)
+ self.assertEqual("en", l.language)
pass
def test_translation_with_en_us(self):
# Get trans
- t = i18n._translation("messages", [self.mo_dir], ["en_US"])
+ i18n.get_lang(self.mo_dir, 'en_US', 'messages')
+ t = cherrypy.response.i18n.trans
+ l = cherrypy.response.i18n.locale
self.assertIsInstance(t, gettext.GNUTranslations)
- self.assertEqual("en", t._lang)
+ self.assertEqual("en", l.language)
pass
def test_translation_with_fr_ca(self):
# Get trans
- t = i18n._translation("messages", [self.mo_dir], ["fr_CA"])
+ i18n.get_lang(self.mo_dir, 'fr_CA', 'messages')
+ t = cherrypy.response.i18n.trans
+ l = cherrypy.response.i18n.locale
self.assertIsInstance(t, gettext.GNUTranslations)
- self.assertEqual("fr", t._lang)
+ self.assertEqual("fr", l.language)
pass
def test_translation_with_en_fr(self):
# Get trans
- t = i18n._translation("messages", [self.mo_dir], ["en", "fr"])
+ i18n.get_lang(self.mo_dir, 'en', 'messages')
+ t = cherrypy.response.i18n.trans
+ l = cherrypy.response.i18n.locale
self.assertIsInstance(t, gettext.GNUTranslations)
- self.assertEqual("en", t._lang)
+ self.assertEqual("en", l.language)
# Test translation object
self.assertEqual("Edit", t.gettext("Edit"))
# Check if the translation fallback
@@ -109,20 +127,15 @@ class Test(unittest.TestCase):
def test_translation_with_fr_en(self):
# Get trans
- t = i18n._translation("messages", [self.mo_dir], ["fr", "en"])
+ i18n.get_lang(self.mo_dir, 'fr', 'messages')
+ t = cherrypy.response.i18n.trans
+ l = cherrypy.response.i18n.locale
self.assertIsInstance(t, gettext.GNUTranslations)
- self.assertEqual("fr", t._lang)
+ self.assertEqual("fr", l.language)
# Test translation object
self.assertEqual("Modifier", t.gettext("Edit"))
pass
- def test_translation_with_unknown(self):
- # Get trans
- t = i18n._translation("messages", [self.mo_dir], ["br"])
- self.assertIsInstance(t, gettext.NullTranslations)
- self.assertEqual("en_US", t._lang)
- pass
-
class TestI18nWebCase(WebCase):
@@ -132,25 +145,25 @@ class TestI18nWebCase(WebCase):
# Query the page without login-in
self.getPage("/", headers=[("Accept-Language", "es")])
self.assertStatus('200 OK')
- self.assertHeaderItemValue("Content-Language", "en")
+ self.assertHeaderItemValue("Content-Language", "en_US")
self.assertInBody("Sign in")
def testLanguage_En(self):
self.getPage("/", headers=[("Accept-Language", "en-US,en;q=0.8")])
self.assertStatus('200 OK')
- self.assertHeaderItemValue("Content-Language", "en")
+ self.assertHeaderItemValue("Content-Language", "en_US")
self.assertInBody("Sign in")
def testLanguage_EnFr(self):
self.getPage("/", headers=[("Accept-Language", "en-US,en;q=0.8,fr-CA;q=0.8")])
self.assertStatus('200 OK')
- self.assertHeaderItemValue("Content-Language", "en")
+ self.assertHeaderItemValue("Content-Language", "en_US")
self.assertInBody("Sign in")
def testLanguage_Fr(self):
self.getPage("/", headers=[("Accept-Language", "fr-CA;q=0.8,fr;q=0.6")])
self.assertStatus('200 OK')
- self.assertHeaderItemValue("Content-Language", "fr")
+ self.assertHeaderItemValue("Content-Language", "fr_CA")
self.assertInBody("Se connecter")
diff --git a/setup.py b/setup.py
index 23d460a2ce395fe728346c9e8d44a0edc7554d55..b6a43ea2a9d27e7051e4fe3a3b96f1d458cc10f7 100644
--- a/setup.py
+++ b/setup.py
@@ -162,6 +162,7 @@ install_requires = [
"Jinja2>=2.6,<=2.8.1",
"future>=0.15.2",
"psutil>=2.1.1",
+ "babel>=0.9.6",
]
if PY2:
install_requires.extend(["pysqlite>=2.6.3"])