Provides translations, formatters, and timezone support for Starlette application by integrating Babel library.
Install starlette_babel using pip or uv:
pip install starlette_babel
# or
uv add starlette_babel- Locale middleware
- Multi-domain translations
- Contextual translations via
pgettext/npgettext - Locale selectors
- Timezone middleware
- Timezone selectors
- Locale-aware formatters (dates, times, numbers, currencies, percentages, units, lists)
- Locale negotiation via
negotiate_locale - Number and date parsing via
parse_number/parse_decimal/parse_date/parse_time - Text direction (LTR/RTL) detection via
get_text_direction - Jinja2 integration
See example application in examples/ directory of this repository.
To start using locale aware formatters, text translation and other components you have to set up a translator and add middleware to your Starlette application.
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette_babel import get_translator, LocaleMiddleware
supported_locales = ['be', 'en', 'pl']
shared_translator = get_translator() # process global instance
shared_translator.load_from_directories(['/path/to/locales/']) # one or multiple locale directories
app = Starlette(
middleware=[
Middleware(LocaleMiddleware, locales=supported_locales, default_locale='en'),
]
)The LocaleMiddleware adds three state options to the request: locale, language, and text_direction.
from babel import Locale
def index_view(request):
current_locale: Locale = request.state.locale
current_language: str = request.state.language
current_direction: str = request.state.text_direction # 'ltr' or 'rtl'Alternatively, use get_locale to get the locale information
from babel import Locale
from starlette_babel import get_locale
locale: Locale = get_locale()LocaleMiddleware uses locale selectors to detect the locale from the request object.
The selector is a callable that accepts HTTPConnection object and returns either a locale code as a string or
None. The first locale selector that returns non-None value wins.
If all selectors fail then the middleware sets locale from default_locale option.
The detected locale should be in the list defined by the locales option otherwise it won't be accepted.
The default selector order is:
- from
localequery parameter - from
languagecookie - from
get_preferred_languageuser method (will userequest.user, if available) - from
accept-languageheader - fallback to configured default locale
If you want to customize the way the middleware detects the locale, pass selectors option to the middleware:
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette_babel import LocaleFromHeader, LocaleFromQuery, LocaleMiddleware
app = Starlette(
middleware=[
Middleware(LocaleMiddleware, selectors=[
LocaleFromQuery(), LocaleFromHeader(),
])
]
)In this example we use only two selectors. They will be called in the order they are defined.
You can define your own selector by writing a function or a callable object:
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.requests import HTTPConnection
from starlette_babel import LocaleMiddleware
def my_locale_selector(conn: HTTPConnection) -> str | None:
return 'be_BY'
app = Starlette(
middleware=[
Middleware(LocaleMiddleware, selectors=[
my_locale_selector,
])
]
)At this point your application is translatable and each request contain locale information that you can use. Let's define some translatable strings
Please note, that we did not write any translations and the example below won't actually translate anything. This is an example of how to mark strings for translation. We will cover message extraction a bit later.
from starlette.responses import PlainTextResponse
from starlette_babel import gettext_lazy as _
welcome_message = _('Welcome')
def index_view(request):
return PlainTextResponse(welcome_message)Strings marked as translatable won't translate themselves. They should be extracted into .po files and compiled into
machine-readable .mo files. This topic is out of the scope if this documentation and is well-documented by the
official Babel documentation.
A brief hint on what to do is:
- configure
pybabeltool viapybabel.ini - create directories for each supported locale
- extract strings from the source code using
pybabel extractcommand - update locale specific message catalogues (
messages.po) usingpybabel updatecommand - compile these catalogues into machine-readable format (
messages.mo) usingpybabel compilecommand.
These commands are documented at https://babel.pocoo.org/en/latest/cmdline.html
The locales directory is a directory where we keep our translation files. Usually, this directory called locales.
The structure is like this: locales/_code_/LC_MESSAGES/messages.po where _code_ is a locale code.
Example:
your_app_package_name/
locales/
en/
LC_MESSAGES/
messages.po
de/
LC_MESSAGES/
messages.po
messages.potIf the directory format does not match the expectation, translator would not be able to load messages and will fail silently. You can use the examples/locales for a reference.
If you use Jinja2 templates you can integrate translator and formatters provided by this library with Jinja.
import jinja2
from starlette_babel.contrib.jinja import configure_jinja_env
jinja_env = jinja2.Environment()
configure_jinja_env(jinja_env)The configure_jinja_env makes the following utilities available in the templates:
_- alias forgettext_p- alias forngettext_c- alias forpgettext(contextual)_cp- alias fornpgettext(contextual plural)locale- currentLocaleobjectlanguage- current language codetext_direction-ltrorrtlfor the current localemonth_names,day_names- localized calendar namescurrency_symbol,currency_name- localized currency labels
<html dir="{{ text_direction() }}" lang="{{ language() }}">
<time>{{ _('Welcome') }}</time>
<span>{{ _c('month', 'May') }}</span>
</html>The {% trans %} tag accepts a context as its first argument:
{% trans "verb" %}May{% endtrans %}- datetime
- date
- time
- timedelta
- interval
- skeleton
- number
- decimal
- compact_decimal
- currency
- compact_currency
- percent
- scientific
- unit
- format_list
All these filters are locale-aware and will format passed data using locale defined format.
The list formatter is exposed as
format_listrather thanlist, because Jinja already ships a builtinlistfilter that we must not shadow.
<time>your local time is {{ now|datetime }}</time>You can set the current locale manually
from starlette_babel import set_locale
set_locale('pl')You can switch locale temporary for a code block using switch_locale context manager. When the manager exits the
previous locale gets restored. This utility is very useful in unit tests.
from starlette_babel import switch_locale, set_locale
set_locale('pl')
# all speak Polish here
with switch_locale('be'):
# all speak Belarussian here
...
# all speak Polish here againYou can translate messages using translator.gettext and translator.ngettext directly in the code of the view
function:
from starlette_babel import Translator
translator = Translator(['/path/to/locales'])
def index_view(request):
translated = translator.gettext('Hello', locale='en')This is advanced topic. Most apps don't need this but library developers may need it.
A translation domain is like a namespace. Same message can have different translations depending on the context (aka
domain). This library natively supports domains. We infer domain name from the .po file name, dropping the extension.
For the file like locales/en/LC_MESSAGES/errors.po the domain is errors.
The default translation domain is messages.
from starlette_babel import Translator
translator = Translator(['/path/to/locales'])
hello_message = translator.gettext('Hello', locale='en') # uses default `messages` domain
shopping_hello_message = translator.gettext('Hello', locale='en', domain='shopping') # uses `shopping` domainThe structure is exactly the same as stated above.
your_app_package_name/
locales/
en/
LC_MESSAGES/
messages.po
shopping.po # <-- new file. defines "shopping" domainThe library integrates formatting utilities from the Babel package. Our version automatically applies current locale/timezone without defining them manually.
Here is the list of adapted formatters:
Dates and times:
- format_datetime
- format_date
- format_time
- format_timedelta
- format_interval
- format_skeleton
- parse_date
- parse_time
- get_month_names
- get_day_names
Numbers, currencies, and units:
- format_number (alias of
format_decimal) - format_decimal
- format_compact_decimal
- format_currency
- format_compact_currency
- format_percent
- format_scientific
- format_unit
- parse_decimal
- parse_number
- get_currency_symbol
- get_currency_name
Lists:
- format_list
Consult Babel documentation for more information.
Babel provides no
parse_datetimecounterpart toparse_date/parse_time, so neither do we.
Use get_text_direction to render right-to-left languages correctly. Without an argument it uses the current locale.
from starlette_babel import get_text_direction
get_text_direction() # 'ltr' or 'rtl' for the current locale
get_text_direction('ar') # 'rtl'LocaleMiddleware also exposes it on the request state:
def index_view(request):
direction: str = request.state.text_directionimport datetime
from starlette_babel import format_datetime, set_locale, set_timezone
set_locale('be')
set_timezone('Europe/Minsk')
now = datetime.datetime.now()
local_time = format_datetime(now) # <-- thisThere formatters are automatically exposed to templates after applying configure_jinja_env on Jinja environment.
To enable timezone support add TimezoneMiddleware. The middleware behaves much like LocaleMiddleware and shares same
concepts.
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette_babel import TimezoneMiddleware
app = Starlette(
middleware=[
Middleware(TimezoneMiddleware, fallback='Europe/London')
]
)By default, the middleware will try these selectors:
- from
tzquery parameter - from
timezonecookie - from
get_timezoneuser method
import datetime
def index_view(request):
timezone: datetime.tzinfo = request.state.timezoneUse get_timezone helper to get the timezone information set by the middleware.
If middleware not used it will return UTC.
import datetime
from starlette_babel import get_timezone
tz: datetime.tzinfo = get_timezone()You can change selectors set or the order they are defined by configuring selectors option of the middleware:
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette_babel import TimezoneFromCookie, TimezoneFromQuery, TimezoneMiddleware
app = Starlette(
middleware=[
Middleware(TimezoneMiddleware, fallback='Europe/London', selectors=[
TimezoneFromQuery(), TimezoneFromCookie(),
])
]
)A selector is a callable that accepts HTTPConnection and returns timezone code as a string:
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette_babel import TimezoneMiddleware
def my_timezone_selector(conn):
return 'Europe/Minsk'
app = Starlette(
middleware=[
Middleware(TimezoneMiddleware, fallback='Europe/London', selectors=[
my_timezone_selector,
])
]
)Use set_timezone to set the timezone.
from starlette_babel import set_timezone
set_timezone('Europe/Minsk')Use set_timezone to set the timezone.
from starlette_babel import switch_timezone
set_timezone('Europe/Minsk')
# time in +03
with switch_timezone('Europe/Warsaw'):
# time in +02
...
# time in +03 againYou can apply currently active timezone to any datetime instance using to_user_timezone helper.
import datetime
from starlette_babel import to_user_timezone, set_timezone
set_timezone('Europe/Minsk')
now = datetime.datetime.now(datetime.timezone.utc) # time in UTC
user_now = to_user_timezone(now) # time in Europe/MinskYou can also convert datetime instance back to UTC using to_utc helper.
import datetime
from starlette_babel import set_timezone, to_user_timezone, to_utc
set_timezone('Europe/Minsk')
now = datetime.datetime.now() # time in UTC
user_now = to_user_timezone(now) # time in Europe/Minsk
utc_now = to_utc(user_now) # time in UTC againTo get current user time use now helper.
from starlette_babel import set_timezone, now
set_timezone('Europe/Minsk')
user_now = now() # time in Europe/Minsk