Introduction
In the world of software, no project is ever truly complete and “is it Easter” is no exception. After having the site live for a few weeks, I thought about what more I could do with it. There is still more I want to learn about web development and I decided to make the app support multiple languages. Which turned out to be straightforward and relatively easy. Exactly why I like working with Python.
One of my goals while working on this feature was only using internally stored translations. I did not want to use an online API that does auto machine translations on the fly. Pretty much every browser has this built in so I wanted to support true, application level, bundled translations.
Flask-Babel
I used Flask-Babel to add
translation strings to the app and was pleasantly surprised when I found it also
handles proper date and number formatting. The US is unique in using Month /
Day / Year format and Flask-Babel will properly format dates from the python
Date object for the selected locale.
Initialization
Once flask_babel was included I created the object and initialized it
as part of the app object.
from flask_babel import Babel
babel = Babel()
...
babel.init_app(app, locale_selector=get_locale)
I used later initialization instead of adding the app in the constructor because I wanted to attach a selector object for the locale.
def get_locale():
return request.accept_languages.best_match([ 'en', 'nl' ])
The locale function just lists the languages I’m supporting and instructs the application to use the best match. The default language is English for all languages that aren’t Dutch. The base strings are all in English so that get used if a match isn’t found.
Translation Strings
Previously, I had a mix of strings in the Jina2 templates and in the Python code. I standardized on having all strings in the templates. Which turned out to make it easer to understand the templates.
Translatable strings are enclosed with _('') to tell babel the string
are translatable. For example:
{{ _('Easter is on') }} {{ easter_date|dateformat(format='long', rebase=False) }}
Additionally, Date objects passed into the template are handled by the dateformat
function that’s added to Jina2 by Flask-Babel. I have rebase set to False because
I don’t need automatic timezone conversion. I handled timezone previously when determining
if it is or isn’t Easter in the Python code.
Creating Translation Files
Now that all of the code is in place, I can start translating the app.
Config file
The first step to creating translations is making a babel.cfg to instruct
pybabel where to look for translatable strings.
[python: **.py]
[jinja2: **/templates/**.j2]
Generate List of Translatable Strings
Next, tell pybabel to read the files and generate a messages.pot file
with a list of all translatable strings.
❯ pybabel extract -F babel.cfg -o messages.pot app
Failure
When I first ran this I got a nasty and unintuitive error.
❯ pybabel extract -F babel.cfg -o messages.pot app
extracting messages from app/__init__.py
extracting messages from app/routes/__init__.py
extracting messages from app/routes/bunny_picture.py
extracting messages from app/routes/favicon.py
extracting messages from app/routes/index.py
extracting messages from app/routes/error/e404.py
extracting messages from app/routes/error/e500.py
extracting messages from app/templates/404.j2
Traceback (most recent call last):
File "/Users/john/git/isiteaster/.venv/bin/pybabel", line 8, in <module>
sys.exit(main())
^^^^^^
File "/Users/john/git/isiteaster/.venv/lib/python3.12/site-packages/babel/messages/frontend.py", line 979, in main
return CommandLineInterface().run(sys.argv)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/john/git/isiteaster/.venv/lib/python3.12/site-packages/babel/messages/frontend.py", line 905, in run
return cmdinst.run()
^^^^^^^^^^^^^
File "/Users/john/git/isiteaster/.venv/lib/python3.12/site-packages/babel/messages/frontend.py", line 516, in run
for filename, lineno, message, comments, context in extracted:
File "/Users/john/git/isiteaster/.venv/lib/python3.12/site-packages/babel/messages/extract.py", line 215, in extract_from_dir
yield from check_and_call_extract_file(
File "/Users/john/git/isiteaster/.venv/lib/python3.12/site-packages/babel/messages/extract.py", line 279, in check_and_call_extract_file
for message_tuple in extract_from_file(
^^^^^^^^^^^^^^^^^^
File "/Users/john/git/isiteaster/.venv/lib/python3.12/site-packages/babel/messages/extract.py", line 321, in extract_from_file
return list(extract(method, fileobj, keywords, comment_tags,
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/john/git/isiteaster/.venv/lib/python3.12/site-packages/babel/messages/extract.py", line 445, in extract
raise ValueError(f"Unknown extraction method {method!r}")
ValueError: Unknown extraction method 'jinja2'
Solution
After a lot of Googling that lead to dead ends I figured out the solution. Install setuptools. I’m not using
setuptools with this project, so it wasn’t in my virtual environment. I have no idea why but after installing
it, pybabel works fine.
❯ pip install setuptools
It Works!
Now running gives proper output and generates the messages.pot file.
❯ pybabel extract -F babel.cfg -o messages.pot app
extracting messages from app/__init__.py
extracting messages from app/routes/__init__.py
extracting messages from app/routes/bunny_picture.py
extracting messages from app/routes/favicon.py
extracting messages from app/routes/index.py
extracting messages from app/routes/error/e404.py
extracting messages from app/routes/error/e500.py
extracting messages from app/templates/404.j2
extracting messages from app/templates/500.j2
extracting messages from app/templates/base.j2
extracting messages from app/templates/index.j2
extracting messages from app/templates/partials/footer.j2
extracting messages from app/templates/partials/head.j2
extracting messages from app/utils/__init__.py
extracting messages from app/utils/cli.py
extracting messages from app/utils/crawler_detect.py
extracting messages from app/utils/easter.py
extracting messages from app/utils/locale.py
extracting messages from app/utils/tz.py
writing PO template file to messages.pot
The messages.pot file is used to generate the language specific messages.po
files which will contain all translations for a given language.
Initializing a Language
To support a new language (or the first) you need to initialize it.
❯ pybabel init -i messages.pot -d app/translations -l <LANG_CODE>
Where <LANG_CODE> is the language code you’re creating translations for.
This creates the following directories, app/translations/<LANG_CODE>/LC_MESSAGES/.
Additionally, it create a messages.po file which is where translations are stored.
Do this for every language you want to support
Don’t forget to edit the messages.po file(s) with the translation strings!
Compiling Translations
Flask-Babel doesn’t use the messages.po, instead it uses a complied version called
messages.mo.
❯ pybabel compile -d app/translations
Updating Translations
Over time new strings will inevitably be added to your application. When that happens
you need to generate a new messages.pot. From that have the new strings added
to each messages.po. Update the messages.po. Finally compile the new translations.
❯ pybabel extract -F babel.cfg -o messages.pot app
❯ pybabel update -i messages.pot -d app/translations
❯ pybabel compile -d app/translations
Using messages.mo with Flask-Babel
Flask-Babel looks for translations in a directory named translations in
the same directory as the main app. In the case of “is it Easter”, I have the
app source in a directory called app. At least right now it’s called app.
Flask-Babel knows what languages there are, based on the directory path
under translations of <LANG_CODE>/LC_MESSAGES/. Flask-Babel automatically
looks for all messages.mo files and will load them at startup.
Other than creating the complied translation files in the correct directory, Flask-Babel transparently loads and uses the translations.
Testing Translations
Once I had my translations written and complied, I needed to see how they displayed.
I could temporally hard code the locale returned by get_locale() but that is
cumbersome and doesn’t allow me to easily flip between multiple languages.
Instead I used Google Chrome’s “Developer Tools” and set a Sensor location with the location and language code for what I wanted to test.
In the developer tools.
- Click the three verticals dots.
- Choose “More tools ->”
- Choose “Sensors”
- In the Sensor area, choose the location.
- If the location is not listed
- Click “Manage” -> “Add Location…”
- Be sure to add the “Locale” which is the 2 character language code
Now to test a language.
- Choose the location
- Reload the page
You’ll see it switch between languages. Assuming you have a translation and it’s listed
in the get_locale() function as supported by the application.
Languages in “is it Easter”
Besides English, I’m only supporting Dutch in “is it Easter” because it’s the only other language I know well enough to have comprehensible translations. Machine translations are really good these days but I don’t want to go that route in order to support more languages. Especially when this is a web site that is pretty much never going to be used. Much less by people who don’t speak English. The URL is an English phrase after all.
Also, the more translations I add, the more work it is to maintain. I don’t want future enhancements to take up more time updating translation files than whatever it is that I’m adding. This is a for fun project and I want to keep it that way.
Conclusion
It was a lot easier to add multilingual support to the application. There are quite a few setup steps but it’s a straight forward process. Overall, this project came out much better than I expected. It also made me look at my string handling and really clean up parts of the application.
Comments
Nothing yet. Say the first thing.
Sign in to join the conversation.