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"])