22.06.2010

repoze.bfg Language Negotiator

Since version 1.3 repoze.bfg offers internationalization and localization. For one of our projects (Flight Log) we have written a custom locale negotiator showing the page in the language defined in the users browser settings.

The way how to internationalize your application is fully documented in the amazing repoze.bfg documentation: http://docs.repoze.org/bfg/1.3/narr/i18n.html

Let's have a look at the test first:

from unittest import TestCase  

from webob.acceptparse import Accept  

from repoze.bfg.interfaces import ISettings  
from repoze.bfg.threadlocal import get_current_registry  
from repoze.bfg.testing import DummyRequest  

class TestLocaleNegotiator(TestCase):  

    def test_get_preferred_languages(self):  
        from flightlog.locale_negotiator import get_preferred_languages  

        # No header  
        request = DummyRequest()  
        langs = get_preferred_languages(request)  
        self.assertEquals([], langs)  

        # Empty header  
        request = DummyRequest()  
        setattr(request, 'accept_language', Accept('Accept-Language', ''))  
        langs = get_preferred_languages(request)  
        self.assertEquals([], langs)  

        request = DummyRequest()  
        setattr(request, 'accept_language', Accept('Accept-Language', 'de'))  
        langs = get_preferred_languages(request)  
        self.assertEquals(['de'], langs)  

    def test_locale_negotiator(self):  
        from flightlog.locale_negotiator import locale_negotiator  

        registry = get_current_registry()  
        settings = {'available_languages' : 'en de', 'default_locale_name' : 'de'}  
        registry.registerUtility(settings, ISettings)  

        # No header  
        request = DummyRequest()  
        lang = locale_negotiator(request)  
        self.assertEquals('de', lang)  

        # Single accepted language  
        request = DummyRequest()  
        setattr(request, 'accept_language', Accept('Accept-Language', 'en'))  
        lang = locale_negotiator(request)  
        self.assertEquals('en', lang)  

        # List of languages  
        request = DummyRequest()  
        setattr(request, 'accept_language', Accept('Accept-Language', 'de, en'))  
        lang = locale_negotiator(request)  
        self.assertEquals('de', lang)  

        # Qualities  
        request = DummyRequest()  
        setattr(request, 'accept_language', Accept('Accept-Language', 'da, en-gb;q=0.8, en;q=0.7'))  
        lang = locale_negotiator(request)  
        self.assertEquals('en', lang)  

        # Malformed quality  
        request = DummyRequest()  
        setattr(request, 'accept_language', Accept('Accept-Language', 'da, en-gb;q=0.8.0, en;q=0.7'))  
        lang = locale_negotiator(request)  
        self.assertEquals('en', lang)  

Next the language negotiator (this code has mostly been copied from zope.i18n):

from repoze.bfg.settings import get_settings  

def get_preferred_languages(request):  

    # Not availabe in DummyRequest during testing  
    if not hasattr(request, 'accept_language') or not hasattr(request.accept_language, 'header_value'):  
        return []  

    accept_langs = request.accept_language.header_value.split(',')  

    # Normalize lang strings  
    accept_langs = [normalize_lang(l) for l in accept_langs]  
    # Then filter out empty ones  
    accept_langs = [l for l in accept_langs if l]  

    accepts = []  
    for index, lang in enumerate(accept_langs):  
        l = lang.split(';', 2)  

        # If not supplied, quality defaults to 1...  
        quality = 1.0  

        if len(l) == 2:  
            q = l[1]  
            if q.startswith('q='):  
                q = q.split('=', 2)[1]  
                try:  
                    quality = float(q)  
                except ValueError:  
                    # malformed quality value, skip it.  
                    continue  

        if quality == 1.0:  
            # ... but we use 1.9 - 0.001 * position to  
            # keep the ordering between all items with  
            # 1.0 quality, which may include items with no quality  
            # defined, and items with quality defined as 1.  
            quality = 1.9 - (0.001 * index)  

        accepts.append((quality, l[0]))  

    # Filter langs with q=0, which means  
    # unwanted lang according to the spec  
    # See: http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.4  
    accepts = [acc for acc in accepts if acc[0]]  

    accepts.sort()  
    accepts.reverse()  

    return [lang for quality, lang in accepts]  

def normalize_lang(lang):  
    lang = lang.strip().lower()  
    lang = lang.replace('_', '-')  
    lang = lang.replace(' ', '')  
    return lang  

def normalize_langs(langs):  
    # Make a mapping from normalized->original so we keep can match  
    # the normalized lang and return the original string.  
    n_langs = {}  
    for l in langs:  
        n_langs[normalize_lang(l)] = l  
    return n_langs  

def locale_negotiator(request):  

    settings = get_settings()  
    available_languages = settings.get('available_languages', '').split()  
    preferred_languages = get_preferred_languages(request)  

    available_languages = normalize_langs(available_languages)  
    for lang in preferred_languages:  
        if lang in available_languages:  
            return available_languages.get(lang)  
        # If the user asked for a specific variation, but we don't  
        # have it available we may serve the most generic one,  
        # according to the spec (eg: user asks for ('en-us',  
        # 'de'), but we don't have 'en-us', then 'en' is preferred  
        # to 'de').  
        parts = lang.split('-')  
        if len(parts) > 1 and parts[0] in available_languages:  
            return available_languages.get(parts[0])  

    return settings.get('default_locale_name', 'en')  

Finally have to include it via ZCML in configure.zcml.

<configure xmlns="http://namespaces.repoze.org/bfg">  

  <!-- this must be included for the view declarations to work -->  
  <include package="repoze.bfg.includes" />  

  <localenegotiator negotiator=".locale_negotiator.locale_negotiator" />  

  <translationdir dir="flightlog:locale/" />  

</configure>