Software localization

An I18n Walkthrough for Falcon Web Apps in Python

Want to build a multilingual application with the Falcon Python web framework? Follow this step-by-step tutorial to implement it painlessly.
Software localization blog category featured image | Phrase

Falcon is a high-performance web framework for building a REST API and micro-services in Python. Fast, reliable, and flexible, it can compile itself with Cython, providing an additional boost to the total number of requests per second.

Falcon can also be used for building web applications. Nevertheless, Falcon's official documentation does not include any guidelines on internationalization for those who want to build a multilingual web application That is exactly the gap this tutorial seeks to close.

Setup

Get the reference code for the demo app from our GitHub repository to get the ball rolling.

Folder structure

The folder structure for this tutorial is as follows:

├── locales

│   ├── en

│   └── de

├── templates

|   └── index.html

├── main.py

├── babel.cfg

└── requirements.txt

Installation via a package name

Activate the virtual environment and run the following command to install all the dependencies:

  • falcon
  • babel
  • jinja2
  • uvicorn
pip install falcon

pip install babel

pip install jinja2

pip install uvicorn

Installation via a requirement file

Alternatively, you can use the following requirement file:

asgiref==3.4.1

Babel==2.9.1

click==8.0.3

colorama==0.4.4

falcon==3.0.1

h11==0.12.0

importlib-metadata==4.8.1

Jinja2==3.0.2

MarkupSafe==2.0.1

pytz==2021.3

typing-extensions==3.10.0.2

uvicorn==0.15.0

zipp==3.6.0

The command for installing via a requirement file is as follows:

pip install -r requirements.txt

HTML files

This tutorial is based on the following webpage template provided by W3.CSS.

Create a new folder called templates and a new file called index.html inside it. To keep it simple, we will modify the code to a lite version that contains only the "About Us" section. Let us start with the following boilerplate:

<!DOCTYPE html>

<html>

<title>{{ _("Gourmet au Catering") }}</title>

<meta charset="UTF-8">

<meta name="viewport" content="width=device-width, initial-scale=1">

<link rel="stylesheet" href="https://www.w3schools.com/w3css/4/w3.css">

<style>

body {font-family: "Times New Roman", Georgia, Serif;}

h1, h2, h3, h4, h5, h6 {

  font-family: "Playfair Display";

  letter-spacing: 5px;

}

</style>

<body>

</body>

</html>

Next, define the navigation bar at the top of the webpage inside the <body> tag:

<div class="w3-top">

  <div class="w3-bar w3-white w3-padding w3-card" style="letter-spacing:4px;">

    <a href="#home" class="w3-bar-item w3-button">{{ _("Gourmet au Catering") }}</a>

    <!-- Right-sided navbar links. Hide them on small screens -->

    <div class="w3-right w3-hide-small">

      <a href="#about" class="w3-bar-item w3-button">{{ _("About Us") }}</a>

      <a href="#menu" class="w3-bar-item w3-button">{{ _("Menu") }}</a>

      <a href="#contact" class="w3-bar-item w3-button">{{ _("Contact") }}</a>

    </div>

  </div>

</div>

Finalize the HTML file with the following code for the "About Us" section below the above code:

<div class="w3-content" style="max-width:1100px">

  <!-- About Section -->

  <div class="w3-row w3-padding-64" id="about">

    <div class="w3-col m6 w3-padding-large w3-hide-small">

     <img src="https://www.w3schools.com/w3images/tablesetting2.jpg" class="w3-round w3-image w3-opacity-min" alt="Table Setting" width="600" height="750">

    </div>

    <div class="w3-col m6 w3-padding-large">

      <h1 class="w3-center">{{ _("About Us") }}</h1><br>

      <h5 class="w3-center">{{ _("Tradition since 1889") }}</h5>

      <p class="w3-large">{{ _("Description") }}</p>

      <h4><strong>{{ _("Upcoming Event") }}</strong></h4>

      <ul>

        <li><strong>{{ _("Date") }}:</strong> {{ event_date | date_filter(locale) }}</li>

        <li><strong>{{ _("Time") }}:</strong> {{ event_time | time_filter(locale) }}</li>

      </ul>

      <p><i>{{ event_attendee | num_filter(locale) }} {{ ngettext('attendee', 'attendees', event_attendee) }}</i></p>

    </div>

  </div>

</div>

The final user interface (UI) should look like this:

Demo app user interface | Phrase

Message catalog

String translation

You should notice that the underlying HTML content contains a number of variables that are enclosed by double curly brackets and wrapped around a _ function:

<p>{{ _("About Us") }}</p>

These are part of the Jinja2 syntax for string translation with gettext. _ is the alias form of gettext. This means that you can call via the following code as well:

<p>{{ gettext("About Us") }}</p>

đź—’ Note » If you are interested in localization with gettext, we suggest the following tutorials:

Internally, Jinja2 will overwrite the output based on the context that you have passed when rendering the template. In this case, we are using message catalogs as the translations files.

Fortunately, Babel comes with the command line tool for working with message catalogs:

  • extract—extract messages from source files and generate a POT file
  • init—create new message catalogs from a POT file
  • update—update existing message catalogs in a POT file
  • compile—compile message catalogs to MO files

Extract

Before that, let's create a configuration file called babel.cfg, which defines the extraction method. This is crucial if you intend to extract from HTML instead of Python files.

[python: **.py]

encoding = utf-8

[jinja2: **.html]

encoding = utf-8

extensions=jinja2.ext.autoescape,jinja2.ext.with_

Now, run the extract command to create a POT file. It will get all the strings that are passed to _ or gettext-related functions. When calling extract, you need to pass in the following arguments:

  • path to the configuration file (via -F arg)
  • output path (via -o arg)
  • directory path or files path

Create a new folder called locales and run the command below:

pybabel extract -F babel.cfg -o locales/messages.pot templates/

You should see the following output:

extracting messages from templates\index.html (encoding="utf-8", extensions="jinja2.ext.autoescape,jinja2.ext.with_")

writing PO template file to locales/messages.pot

It will generate messages.pot in the locales folder with the following content:

...

#: templates/index.html:19

msgid "Gourmet au Catering"

msgstr ""

#: templates/index.html:22 templates/index.html:39

msgid "About Us"

msgstr ""

#: templates/index.html:23

msgid "Menu"

msgstr ""

The messages.pot file represents the base translations file for all the locales. Please note that msgid is the unique identifier for each message to be translated. On the other hand, msgstr represents the translation text.

Leave the translation text empty as you will copy this file into individual folders for each of the supported locales later on.

Init

The next step is to execute the init command. It will generate a single folder with a copy of the translation file inside it on each run. It accepts the following arguments:

  • locale (-l)
  • input file (-i)
  • output directory (-d)

Let us say your application supports the following locales:

  • en (English)
  • de (German)

Run the command as follows for each of the locales that you intend to support:

pybabel init -l en -i locales/messages.pot -d locales

pybabel init -l de -i locales/messages.pot -d locales

Each command will generate a folder with a LC_MESSAGES subfolder inside it. You can find a file called messages.po, which represents the human-editable translation file for the locale. You should fill in the corresponding translation text here.

...

#: templates/index.html:19

msgid "Gourmet au Catering"

msgstr ""

#: templates/index.html:22 templates/index.html:39

msgid "About Us"

msgstr "Ăśber uns"

#: templates/index.html:23

msgid "Menu"

msgstr "MenĂĽ"

Also, if you leave the translation string empty, it will use the text from msgid instead as highlighted above.

You can find the translation files for English at the following link:

#: templates/index.html:19

msgid "Gourmet au Catering"

msgstr ""

#: templates/index.html:22 templates/index.html:39

msgid "About Us"

msgstr ""

#: templates/index.html:23

msgid "Menu"

msgstr ""

#: templates/index.html:24

msgid "Contact"

msgstr ""

#: templates/index.html:40

msgid "Tradition since 1889"

msgstr ""

#: templates/index.html:41

msgid "Description"

msgstr ""

"We offer full-service catering for any event, large or small. We "

"understand your needs and we will cater the food to satisfy the biggerst "

"criteria of them all, both look and taste. Do not hesitate to contact us."

#: templates/index.html:42

msgid "Upcoming Event"

msgstr ""

#: templates/index.html:44

msgid "Date"

msgstr ""

#: templates/index.html:45

msgid "Time"

msgstr ""

#: templates/index.html:47

#, fuzzy, python-format

msgid "attendee"

msgid_plural "attendees"

msgstr[0] ""

msgstr[1] ""

The German translation file is as follows:

#: templates/index.html:19

msgid "Gourmet au Catering"

msgstr ""

#: templates/index.html:22 templates/index.html:39

msgid "About Us"

msgstr "Ăśber uns"

#: templates/index.html:23

msgid "Menu"

msgstr "MenĂĽ"

#: templates/index.html:24

msgid "Contact"

msgstr "Kontakt"

#: templates/index.html:40

msgid "Tradition since 1889"

msgstr "Tradition seit 1889"

#: templates/index.html:41

msgid "Description"

msgstr ""

"Wir bieten Full-Service-Catering fĂĽr jede Veranstaltung, ob groĂź oder "

"klein. Wir verstehen Ihre BedĂĽrfnisse und werden die Speisen so "

"zubereiten, dass sie die höchsten Kriterien erfüllen, sowohl optisch als "

"auch geschmacklich. Zögern Sie nicht, uns zu kontaktieren."

#: templates/index.html:42

msgid "Upcoming Event"

msgstr "Kommende Veranstaltung"

#: templates/index.html:44

msgid "Date"

msgstr "Datum"

#: templates/index.html:45

msgid "Time"

msgstr "Zeit"

#: templates/index.html:47

#, fuzzy, python-format

msgid "attendee"

msgid_plural "attendees"

msgstr[0] "Teilnehmer"

msgstr[1] "Teilnehmer"

Update

This command is essential when adding new translations to your web application. You can simply use it to update the underlying content after extraction:

pybabel extract -F babel.cfg -o locales/messages.pot templates/

pybabel update -i locales/messages.pot -d locales

Any new translations will be added to all the messages.po file while preserving the existing translations.

Compile

Once you have finished adding in the translations, you have to compile the PO files into MO files. The latter represents the machine-readable format file for gettext. Run the following command on your terminal:

pybabel compile -d locales

You should see the following output:

compiling catalog locales\de\LC_MESSAGES\messages.po to locales\de\LC_MESSAGES\messages.mo

compiling catalog locales\en\LC_MESSAGES\messages.po to locales\en\LC_MESSAGES\messages.mo

It will generate a messages.mo file for each of the messages.po file that you have in the locales folder.

Basic messages

Let's recap the basic concepts for string translation. To mark a string for translation, you should wrap the translation string inside the _ function enclosed by two curly brackets:

<a href="#about">{{ _("About Us") }}</a>
The _ function will load the translation by its id. Consider the following language files:
...

#: templates/index.html:22 templates/index.html:39

msgid "About Us"

msgstr ""

...
...

#: templates/index.html:22 templates/index.html:39

msgid "About Us"

msgstr "Ăśber uns"

...

Given these, when our template renders, we will get "About Us" as the output for the English locale and "Ăśber uns" for the German one.

String interpolation

You can easily interpolate content via the % placeholder and the built-in format filter. Let us modify the Hello, Phrase! example to the following code:

<p>{{ _("Hello, %(user)s!")|format(user=name) }}</p>

After extraction, in the German (de) messages.po file, simply fill in the corresponding translation text while retaining the placeholder as follows:

#: templates/index.html:19

msgid "Hello, %(user)s!"

msgstr "Hallo, %(user)s!"

Given that the value of the name variable is Phrase, you should get the following output:

<p>Hallo, Phrase!</p>

For integer interpolation, you can use %(variable)d instead. Likewise, you should use %(variable)f if you intend to interpolate floating point. By default, it will parse to 6 decimal points. You can easily modify it to 2 decimal points via %(variable).2f. The syntax and rules are based on the printf-style string formatting in Python.

Pluralization

For pluralization, you should use the ngettext function instead of the usual gettext function or its alias _. ngettext takes in three important parameters:

  • msgid—text representing the msgid
  • mgsid_plural—text representing the msgid_plural
  • n—plural determiner

For example, you can use it the following way:

<p>{{ event_attendee }} {{ ngettext('attendee', 'attendees', event_attendee) }}</p>

In this case, Babel will generate the following code inside messages.po:

#: templates/index.html:47

#, fuzzy, python-format

msgid "attendee"

msgid_plural "attendees"

msgstr[0] ""

msgstr[1] ""

When using ngettext, you will get a list of msgstr as shown above.

The number of msgstr is based on the number of plural forms specified for the locale. Babel generates it automatically for you when running the init command. Simple pluralization, such as the example above, contains two plural forms with msgstr[0] indicating the text for the first plural form and msgstr[1] the second plural form.

You can easily change the plural formula inside the message.po file if you want to support more than two plural forms. Have a look at the following example:

# 2 plural forms

"Plural-Forms: nplurals=2; plural=(n != 1)\n"

# 3 plural forms

"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && "

"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2)\n"

# 6 plural forms

"Plural-Forms: nplurals=6; plural=(n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : "

"n%100>=3 && n%100<=10 ? 3 : n%100>=0 && n%100<=2 ? 4 : 5)\n"

Let us suppose that you work in Russian, which has 3 plural forms:

  • one: 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, ...
  • a few: 2~4, 22~24, 32~34, 42~44, 52~54, 62, 102, 1002, …
  • many: 0, 5~19, 100, 1000, 10000, 100000, 1000000, …

Assuming that you have an integer variable called num_day, you can add the following code to your HTML file:

<p>{{ ngettext('%(num)d day', '%(num)d days', num_day)|format(num=num_day) }}</p>

Please note that num is the name of the placeholder while num_day represents the actual variable that contains an integer. Fill in the corresponding translation in messages.po:

msgid "%(num)d day"

msgid_plural "%(num)d days"

msgstr[0] "%(num)d день"

msgstr[1] "%(num)d дня"

msgstr[2] "%(num)d дней"

Depending on the value of the num_day variable, the output should be as follows:

# num_day = 1 1 день # num_day = 2 2 дня # num_day = 5 5 дней

Custom filters

Jinja2 has its own support for custom filters—regular Python functions that modify the output text. You can use them to post-process the output text. For example, if you have a Python function that converts a string into uppercase called uppercase_filter, you can use the custom filters in your HTML file as follows:

<p>{{ "Hello, world!" | uppercase_filter }}</p>

It will call the uppercase_filter function and pass in the value of variable_name as the first input parameter:

uppercase_filter('Hello, world!')

For functions that accept multiple input parameters, you can pass it normally as follows:

<p>{{ variable_name | markup_filter('xml') }}</p>

Internally, Jinja2 will call it as follows (the first parameter is always tied to the value of the variable_name):

markup_filter('Hello, world!', 'xml')

Date formatting

If you have a Date object called event_date and a filter called date_filter that parses the text based on an input parameter called locale, you should use the following code in your HTML file:

<p>{{ event_date | date_filter }}</p>

In your server code, you need to define the corresponding functions as follows:

import jinja2

from babel.dates import format_date

env = jinja2.Environment(extensions=['jinja2.ext.i18n', 'jinja2.ext.with_'], loader=jinja2.FileSystemLoader('templates'))

@jinja2.contextfilter

def date_filter(context, input, locale=default_fallback):

    if context:

        context_locale = context.get('locale', default_fallback)

    else:

        context_locale = locale

    return format_date(input, format='full', locale=context_locale)

env.filters['date_filter'] = date_filter

...

It contains a jinja2.contextfilter decorator at the top of the function. The decorator will pass the context object as the first parameter, followed by the input as the second parameter, which can help you easily identify the active locale via context['locale'].

format_date is part of the built-in function in Babel. It accepts a Date object and the corresponding locale:

format_date(datetime.date(2021, 12, 4), locale='en')

# 'December 4, 2021'

format_date(datetime.date(2021, 12, 4), locale='de')

# 4. Dezember 2021

The function also accepts an optional format parameter with the following options:

  • short
  • medium (the default value)
  • long
  • full

You can call it as follows:

format_date(datetime.date(2021, 12, 4), format='full' locale='en')

# 'Saturday, December 4, 2021'

Next, register each custom filter manually one by one via Jinja2's Environment instance:

import jinja2

from babel.dates import format_date

env = jinja2.Environment(extensions=['jinja2.ext.i18n', 'jinja2.ext.with_'], loader=jinja2.FileSystemLoader('templates'))

@jinja2.contextfilter

def date_filter(context, input, locale=default_fallback):

    if context:

        context_locale = context.get('locale', default_fallback)

    else:

        context_locale = locale

    return format_date(input, format='full', locale=context_locale)

env.filters['date_filter'] = date_filter

...

Time formatting

Since Babel provides additional functions for number and time formatting, you can simply reuse the same concept and implement them accordingly.

For time formatting, you can use format_time to format time as follows (it takes in a time object):

format_time(datetime.time(10, 30, 0), locale='en')

# '10:30:00 AM'

format_time(datetime.time(10, 30, 0), locale='de')

# '10:30:00'

Number formatting

Likewise, you can use Babel's format_decimal function. It works for both whole numbers and decimal points. Pass in an input number and the corresponding locale this way:

format_decimal(1.2345, locale='en')

# '1.234'

format_decimal(12345, locale='en')

# '12,345'

Please note that Babel contains another function called format_number, but it has been deprecated as of version 2.6.0.

We will cover the actual implementation in the Falcon Server section later on.

Falcon server

Once you are done with it, let's create a new Python file called main.py in your working directory. This file is the backend code to serve your application. It will also include all the custom filter functions that you have defined earlier.

Import

Add the following import at the top of the file:

import falcon

import falcon.asgi

import jinja2

import datetime

import gettext

import os

from babel.dates import format_date, format_time

from babel.numbers import format_decimal

Initialization

Next, initialize the following variables:

app = falcon.asgi.App()

translations = {}

default_fallback = 'en'

env = jinja2.Environment(extensions=['jinja2.ext.i18n', 'jinja2.ext.with_'], loader=jinja2.FileSystemLoader('templates'))
  • app—represents the Falcon app instance
  • translations—a dictionary which contains translation objects for each locale
  • default_fallback—indicates the fallback locale for the application
  • env—holds the Jinja2 Environment instance, which loads an HTML file from the designated location; in this case, it will load from the templates folder; you also need to enable the extension for Jinja i18n to be able to use gettext within the template files

Load translation files

You can easily load all the translation (MO) files that reside in the locales folder stored inside the translations dictionary:

base_dir = 'locales'

supported_langs = [x for x in os.listdir(base_dir) if os.path.isdir(os.path.join(base_dir, x))]

for lang in supported_langs:

    translations[lang] = gettext.translation('messages', localedir='locales', languages=[lang])

env.install_gettext_translations(translations[default_fallback])

It uses the os module, which searches for all the directories inside the locales folder. This allows you to load all your translation files automatically. Creating a new locale is just a matter of generating a new message catalog and compiling the translation file into an MO file.

Afterwards, you can instantiate and set the active locale with the install_gettext_translations function, and pass in the desired translation object. For example, if you start with English as the base locale, you should call it as follows:

env.install_gettext_translations(translations['en'])

To change the language to German on the fly, simply call the function against with 'de' as the input parameter:

env.install_gettext_translations(translations['de'])

Filter functions

Now, it is time to define all the required custom filter functions. As mentioned previously, you need to equip it with the jinja2.contextfilter decorator:

def get_active_locale(context, locale):

    if context:

        context_locale = context.get('locale', default_fallback)

    else:

        context_locale = locale

    return context_locale

@jinja2.contextfilter

def num_filter(context, input, locale=default_fallback):

    context_locale = get_active_locale(context, locale)

    return format_decimal(input, locale=context_locale)

@jinja2.contextfilter

def date_filter(context, input, locale=default_fallback):

    context_locale = get_active_locale(context, locale)

    return format_date(input, format='full', locale=context_locale)

@jinja2.contextfilter

def time_filter(context, input, locale=default_fallback):

    context_locale = get_active_locale(context, locale)

    return format_time(input, locale=context_locale)

env.filters['num_filter'] = num_filter

env.filters['date_filter'] = date_filter

env.filters['time_filter'] = time_filter

There is a get_active_locale function that will return the currently active locale from the context object. If it is not present, it will fall back to the default locale.

Each filter function has been standardized with the following parameters:

  • context—contains the context object. You can use this to identify the currently active locale.
  • input—represents the input number, Date object, or Time object
  • locale—indicates the custom locale; you can pass it to format the output based on a locale different from the currently active locale

This allows us to normally call the function within the Python application itself, apart from using it as a custom filter. It is a good practice of modularizing it to ensure that the output is the same across your application.

Falcon routes

Once you are done with it, create a new class called ExampleResource and define a async on_get function. It will serve as the Restful URL route for the web application:

class ExampleResource:

    async def on_get(self, req, resp, locale):

        if(locale not in supported_langs):

            locale = default_fallback

        env.install_gettext_translations(translations[locale])

        # mock data

        data = {

            "event_attendee": 1234,

            "event_date": datetime.date(2021, 12, 4),

            "event_time": datetime.time(10, 30, 0)

        }

        resp.status = falcon.HTTP_200

        resp.content_type = 'text/html'

        template = env.get_template("index.html")

        resp.text = template.render(**data, locale=locale)

It accepts the following input arguments:

  • req—a request object which contains all the information related to the request sent by the user
  • resp—a response object to be returned back to the user
  • locale—a path parameter representing the selected locale, which you will use as a reference when setting the active locale

There is a conditional check that sets the active locale to the default fallback if the user passes in an unsupported locale string.

class ExampleResource:

    async def on_get(self, req, resp, locale):

        if(locale not in supported_langs):

            locale = default_fallback

        env.install_gettext_translations(translations[locale])

        # mock data

        data = {

            "event_attendee": 1234,

            "event_date": datetime.date(2021, 12, 4),

            "event_time": datetime.time(10, 30, 0)

        }

        resp.status = falcon.HTTP_200

        resp.content_type = 'text/html'

        template = env.get_template("index.html")

        resp.text = template.render(**data, locale=locale)

For example, if the user passes in "ar" as the locale, which is not supported by your web application, it will default to the default fallback that had been defined earlier ("en" in this case).

Next, you need to change the active locale based on the locale path parameter:

class ExampleResource:

    async def on_get(self, req, resp, locale):

        if(locale not in supported_langs):

            locale = default_fallback

        env.install_gettext_translations(translations[locale])

        # mock data

        data = {

            "event_attendee": 1234,

            "event_date": datetime.date(2021, 12, 4),

            "event_time": datetime.time(10, 30, 0)

        }

        resp.status = falcon.HTTP_200

        resp.content_type = 'text/html'

        template = env.get_template("index.html")

        resp.text = template.render(**data, locale=locale)

Next, complete the function by adding the following code:

class ExampleResource:

    async def on_get(self, req, resp, locale):

        if(locale not in supported_langs):

            locale = default_fallback

        env.install_gettext_translations(translations[locale])

        # mock data

        data = {

            "event_attendee": 1234,

            "event_date": datetime.date(2021, 12, 4),

            "event_time": datetime.time(10, 30, 0)

        }

        resp.status = falcon.HTTP_200

        resp.content_type = 'text/html'

        template = env.get_template("index.html")

        resp.text = template.render(**data, locale=locale)

It will perform the following actions:

  • Generate temporary mock data
  • Set the response status to 200
  • Get the underlying HTML text from index.html
  • Render the template with Jinja2 (translations and custom filter functions happen here)
  • Return the Jinja2 rendered text as HTML output

Add the final touch with the following code:

example = ExampleResource()

app.add_route('/{locale}/main', example)

This will create an instance of ExampleResource and add a new route to your Falcon web application. Please note that {locale} refers to the path parameter for the route. If you want to change the variable name to lang, you need to modify the parameter's name in the function as well:

async def on_get(self, req, resp, lang):

Falcon will capture all the routes with the following pattern and call the ExampleResource class:

<host:port>/<any_string>/main

For example:

http://localhost:8000/en/main

http://localhost:8000/ko/main

http://localhost:8000/xyz/main

http://localhost:8000/hahaha/main

Since we have implemented the code for the default fallback, xyz and hahaha routes will return the English webpage instead.

Redirection

Falcon will return "404 Not Found" for routes that are not part of the syntax. For example, accessing...

http://localhost:8000/

...will yield...

<error>

<title>404 Not Found</title>

</error>

You can easily handle this by adding a new resource and route to redirect users to the main web page:

...

class RedirectResource:

    async def on_get(self, req, resp):

        raise falcon.HTTPFound(req.prefix + '/en/main')

redirect = RedirectResource()

app.add_route('/', redirect)

req.prefix refers to the hostname and port of the web application. In this case, it will be:

http://localhost:8000

Run the Server

Finally, save the file and run the following command:

uvicorn main:app

By default, it will serve the application on port 8000. Head over to the following URL:

# English

http://localhost:8000/en/main

# German

http://localhost:8000/de/main

You should see the following user interface for the German locale:

Demo app with translated UI | Phrase

You should note that the date, time, and other number entries are formatted properly based on the locale path parameter.

Concluding our i18n walkthrough for Falcon apps

All in all, building a multilingual web application with the Falcon framework is quite simple with Babel and Jinja2. If you want to explore even more server-side i18n tutorials, feel free to have a look at:

If you are looking to take your localization workflow to the next level, give Phrase a try. The fastest, leanest, and most reliable localization management platform worldwide will help you streamline the i18n process with everything you need to:

  • Build production-ready integrations with your development workflow
  • Invite as many users as you wish to collaborate on your projects
  • Edit and convert localization files with more context for higher translation quality

Sign up for a free 14-day trial, and see for yourself how it can make your life as a developer easier.