I’ve built a website with Flask and Python and it’s only published in English. As the website will be mainly visited by people in China and other countries, I want to make the website a multilingual one, at least bilingual: Chinese and English.

So I started to build the multilingual website with Flask-Babel. Here are the steps.


Existing project structure

├── venv
│   ├── bin
│   └── include
├── webapp
│   ├── __init__.py
│   ├── config.py
│   ├── routes.py
│   ├── static
│   │   ├── favicon.ico
│   │   └── styles.css
│   └── templates
│       ├── about.html
│       ├── layout.html
│       └── index.html
├── app.py
└── requirements.txt

Install Flask-Babel

The flask extension we need to install is Flask-Babel.

(venv) $ pip install Flask-Babel

Flask-Babel is easy to use, just like others, we need to add the following lines in the __init__.py file to initialize it:

# ...
from flask_babel import Babel
app = Flask(__name__)
# ...
babel = Babel(app)

As we are going to translate the website into Chinese, let us add a configuration variable in the config.py file:

class Config(object):
    # ...
    LANGUAGES = ['en_US', 'zh_CN']

en_US and zh_CN are the language codes of English and Chinese respectively, with the _US and _CN suffixes to indicate the country code. We can use two-letter codes like en and zh as well.

The Babel instance has a localeselector function that is called when a request is made to the website and will return the language code of the user’s browser. The accept_languages object works with the Accept-Language header in the request that specifies the client language and locale perferences. best_match will return the best matches from the list of available languages.

In the __init__.py:

from flask import request

# ...

@babel.localeselector
def get_locale():
    return request.accept_languages.best_match(app.config['LANGUAGES'])

Marking Texts need to be translated

Text in the Python Source Code

As we have literal strings in the Python source code, like in the flash() statement, we need to mark them as translatable by wrapping them in the _() function. The _() function is a shortcut for gettext().

# ...
flash('Hello, world!')
# ...

Change the flash() statement to:

from flask_babel import _ # from flask_babel import gettext as _
# ...
flash(_('Hello, world!'))

📝 Note: Don’t to forget to import the _() function from flask_babel. The _() function is a shortcut for gettext().

Texts in the Templates

For the text in the templates, the _() function is also available so the text can be marked as translatable in the similar way. For example we have a template about.html:

<h1>About Us</h1>

Change it to:

<h1>{{ _('About Us') }}</h1>

📝 Note: {{ ... }} should be added to enforce the _() function to be called instead of being treated as a literal string.


Extracting Texts

Once all the texts are marked as translatable with _(), we can translate them. As mentioned in the tutorial of Flask-Babel, the way is to use the pybabel to extract the texts from the source code and put them in the messages.pot file.

First of all, we need to have some config, create babel.cfg file next to app.py:

[python: webapp/**.py]
[jinja2: webapp/templates/**.html]
extensions=jinja2.ext.autoescape,jinja2.ext.with_

The first two lines should indicate the source files of source code and template html files respectively. The extensions line indicates two extensions provided by the Jinja2 template engine that help Flask-Babel properly parse template files.

Then extract the texts from the source code and put them in the messages.pot file:

(venv) $ pybabel extract -F babel.cfg -k _l -o messages.pot .
extracting messages from webapp/__init__.py (extensions="jinja2.ext.autoescape,jinja2.ext.with_")
extracting messages from webapp/config.py (extensions="jinja2.ext.autoescape,jinja2.ext.with_")
extracting messages from webapp/routes.py (extensions="jinja2.ext.autoescape,jinja2.ext.with_")
writing PO template file to messages.pot

📝 Note: The -o option indicates the output file. The -k option indicates the keyword to be used to mark the texts. If we use lazy_gettext() (imported as _l()) to mark the texts, the -k option should be _l. The _() function is by default.


Creating a language translation

Now let’s create the language translation for the languages we want to translate to. For this time, we want to translate the website into Chinese:

(venv) $ pybabel init -i messages.pot -d webapp/translations -l zh
creating catalog webapp/translations/zh/LC_MESSAGES/messages.po based on messages.pot

📝 Note: The -d option indicates the directory where the translations are stored.

If we want to translate to other languages, just repeate the above steps seperately.

Following is the messages.po that are created:

# Chinese translations for PROJECT.
# Copyright (C) 2022 ORGANIZATION
# This file is distributed under the same license as the PROJECT project.
# FIRST AUTHOR <EMAIL@ADDRESS>, 2022.
#
msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2022-04-29 14:30-0700\n"
"PO-Revision-Date: 2022-04-29 14:32-0700\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: zh\n"
"Language-Team: zh <LL@li.org>\n"
"Plural-Forms: nplurals=1; plural=0;\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.10.1\n"

#: webapp/routes.py:29
msgid "Hello, world!"
msgstr ""

📝 Note: After the header, the first line begins with msgid is the original text extracted by _() or _l(), the second line begins with msgstr is the translation.

We can translate the texts in the messages.po file to the languages we want to translate to. We can do it manually or using the translation editor like poedit.


Compile the translations

When our translation work is done, we need to compile the translation for use:

(venv) $ pybabel compile -d webapp/translations
compiling catalog webapp/translations/zh/LC_MESSAGES/messages.po to webapp/translations/zh/LC_MESSAGES/messages.mo

This action will create the messages.mo file in the webapp/translations/zh/LC_MESSAGES directory.

Now it’s time to test our translation work. One way is to adjust the language preference in the browser. For example, if we want to translate the website into Chinese, we can set the language preference to Simplified Chinese (zh-CN) in the browser’s language settings.

The other way is to force the language by making the localeselector function return the language we want to translate to. In the __init__.py file, make a change:

@babel.localeselector
def get_locale():
    # return request.accept_languages.best_match(app.config['LANGUAGES'])
    return 'zh_CN'

Restart the server and we will see the website in Chinese.


Updating the translations

If the website has changed or we want to update the translations, we can use the pybabel to update:

First, we need to re-extract the messages.pot file and then use the pybabel update to update the translations. This update process is kind of intelligent merge, only the changes will be updated.

(venv) $ pybabel extract -F babel.cfg -k _l -o messages.pot .
(venv) $ pybabel update -i messages.pot -d webapp/translations

Now we can exam the messages.po file in the webapp/translations/zh/LC_MESSAGES directory to edit the translations. After we finish the editing, we can compile the translations:

(venv) $ pybabel compile -d webapp/translations

Let the users decide the language

The flask app has user login feature so we decided to let the users can decide the language they want to use after they logged in, if they don’t like the language that the browser locale chosed for them.

The language that the user selected will be stored in the session dictionary. So we will first check if the session['language'] is set. If it is, I will use it. Otherwise, we can use the browser locale.

So in the __init__.py file, make a change:

@babel.localeselector
def get_locale():
    try:
        language = session['language']
    except KeyError:
        language = None
        
    if language is not None:
        return language
    else:
        return request.accept_languages.best_match(app.config['LANGUAGES'])

Then in the routes.py file, add a new route:

@app.route("/language/<language>")
def set_language(language):
    session["language"] = language
    return redirect(request.referrer or url_for("index"))

📝 Note: Very straight forward, get the language from the URL and set it to the session. Then redirect to the page that the user came from or to the index page if the user didn’t come from any page. Please note that if the user logged out, the session will be cleared and the language will be set to the browser locale next time the user visit the website.