Skip to content

Add i18n to a Python app ​

This walkthrough takes a Python application that has a single language and adds locales with ai-i18n-tools. Bare user-facing strings are wrapped by hand or with the prompts under Wrap strings. migrate-python-i18n only repairs unsafe calls that are already wrapped. Anything that still needs a look is listed in a report.

Already using GNU gettext, Django, or Flask catalogs? Keep those calls. Pick the matching init template below and let extract fill empty catalog slots from the existing .po files.

The steps:

  1. Install the CLI and set the API key.
  2. Pick a template and run init.
  3. Wrap strings in t() or _().
  4. Migrate call sites that are already wrapped unsafely.
  5. Choose models and translate.
  6. Switch locale at runtime.
  7. Keep it translated.

Not sure this is your case? See Not your case? at the end.

Install ​

Use the npm package when the machine already has Node.js >=22.16.0 (Installation). For a Python project, install the standalone executable (no Node.js).

Linux and macOS:

bash
curl -fsSL https://raw.githubusercontent.com/wsj-br/ai-i18n-tools/main/install.sh | bash

Windows (PowerShell):

powershell
irm https://raw.githubusercontent.com/wsj-br/ai-i18n-tools/main/install.ps1 | iex

The installer checks SHA256SUMS.txt, installs the command as ai-i18n-tools, and adds it to PATH. The binaries are not notarized or Authenticode-signed. Manual steps and asset names: Standalone executables.

Set the API key for the provider you pass to init. For example, Cheaper Inference:

bash
export CHEAPERINFERENCE_API_KEY=your-key-here

Ollama needs no key. Other presets: Built-in providers.

Pick a template ​

App todayCommandWhat you get
Hard-coded strings, or new t() / _() callsai-i18n-tools init -t ui-python -P cheaperinferenceFlat JSON keyed by the source-language string, and i18n.py
GNU gettext, and you also want flat JSONai-i18n-tools init -t ui-python-gettext -P anthropicJSON plus .po / .mo, and i18n.py
Flask or Jinja catalogs under translations/ai-i18n-tools init -t ui-python-flask -P groqGettext catalogs and i18n.py
Django gettext / {% blocktrans %}ai-i18n-tools init -t ui-python-django -P ollamalocale/<lang>/LC_MESSAGES/django.po (no i18n.py)

Edit sourceLocale and targetLocales. sourceLocale is the language of the strings in the source. Any BCP-47 language is valid; examples often use en. Framework wiring: Python integrations. Call shapes and catalog layout: Python applications.

Wrap strings ​

Look up the catalog entry first, then interpolate. Import t and _ from the generated i18n.py (Django keeps gettext and ngettext).

python
t("Hello, {name}!", name=name)
_("Hello, %s!") % name
_("Total: {price:,.2f}").format(price=price)

These are evaluated before any catalog lookup. The extractor marks them unsafe, and migrate-python-i18n rewrites them:

python
_(f"Hello, {name}!")
_("Hello, %s!" % name)
_("Hello, {name}!".format(name=name))

Use this for Streamlit, FastAPI, Flask with flat JSON, Tkinter, Qt, and a PyWebView shell. Copy it into a coding agent. A Django or existing-gettext project should use the next prompt instead.

Agent prompt: i18n.py helper
markdown
# Wrap user-facing Python strings with i18n.py

## Context

ai-i18n-tools extracts only string literals passed to `t()` or `_()` (and the
other names in `ui.uiExtractor.funcNames`). The literal is the source-language
sentence and the catalog key. After this edit the app must behave the same when
the active locale is the source locale. Do not translate any string.

Call shapes: https://wsj-br.github.io/ai-i18n-tools/guide/ui-strings/python

## Scope

Read `ui.sourceRoots` in `ai-i18n-tools.config.json` and edit Python (and Jinja
templates) under those roots. Skip class names, URLs and routes, import paths,
ids, enum and error codes, object keys used as identifiers, log field names,
regexes, comments, and text that is already wrapped. Do not edit the generated
`i18n.py`.

## Call shape

Import `t` and `_` from the generated `i18n.py`. Placeholders are `{name}` or
`{price:,.2f}`, passed as keyword arguments. `_("…") % x` and `_("…").format(…)`
are safe because the lookup happens first.

Never `_(f"…")`, `_("…" % x)`, or `_("…".format(…))`. If one slips in,
`migrate-python-i18n` can rewrite it later.

Before:

```python
st.write(f"Hello, {name}!")
```

After:

```python
st.write(t("Hello, {name}!", name=name))
```

Framework notes:

- Streamlit widgets keep a fixed `key=`.
- Tkinter text is rebuilt after `set_locale`.
- Qt uses `self.tr("…")` (already in the default function list).
- Jinja uses `{% trans %}` / `{% blocktrans %}` rather than an f-string inside
  the template.
- FastAPI and Flask middleware pair `set_locale` with `reset_locale` in
  `finally`.

## Order

1. `ai-i18n-tools init -t ui-python` (or `ui-python-gettext` / `ui-python-flask`
   if you also want `.po` files) so `i18n.py` exists.
2. Wrap call sites.
3. Run `ai-i18n-tools extract`. Then `ai-i18n-tools migrate-python-i18n` (dry
   run) and read the report.

## Do not

- Do not translate, rename, or reword the source text.
- Do not introduce semantic keys.
- Do not create a second helper besides `i18n.py`.
- Do not hand-edit `strings.json`, flat locale JSON, `.po` / `.mo`, or
  `ui-languages.json`.
- Do not change behaviour, layout, or test selectors.

## Verify

- `ai-i18n-tools extract` writes a non-empty catalog.
- The project's tests pass.
- The source locale still shows the original sentences. One target locale shows
  translations after `translate-ui`.

## Report back

List files changed, each string you deliberately skipped with a one-line reason,
and any ambiguous case left for a human (the same word with different meanings,
concatenated sentences, or plurals).

Use this when the app already calls Django or GNU gettext, or when you want to stay on .po catalogs. Do not add i18n.py.

The init templates recognise these call names exactly: _, t, translate, i18n.t, gettext, ngettext, pgettext, and self.tr. Lazy and noop variants are not in that list. Add the names the project actually calls — typically gettext_lazy, ngettext_lazy, pgettext_lazy, and gettext_noop — to ui.uiExtractor.funcNames, or extract skips them. ngettext_lazy is read as singular plus plural, and pgettext_lazy as context plus message, only after the name is listed. {% trans %} and {% blocktrans %} in templates are collected on their own.

Agent prompt: Django and native gettext
markdown
# Wrap user-facing strings with Django / gettext

## Context

ai-i18n-tools extracts string literals passed to gettext calls whose names are
listed in `ui.uiExtractor.funcNames`, plus `{% trans %}` and `{% blocktrans %}`
in templates. The literal is the source-language message. Existing `.po` files
stay; `extract` fills empty catalog slots from them when
`ui.gettext.ingestExistingPo` is on (the default). Do not translate any string.
Do not create `i18n.py`.

## Scope

Edit Python modules, Django templates, and forms under `ui.sourceRoots`. Skip
URLs, import paths, ids, field names used as identifiers, `verbose_name` keys
that are already lazy-wrapped, comments, and migrations that must not change. Do
not wrap strings inside `help_text` that are already `gettext_lazy` calls.

## Call shape

Init with `ui-python-django` (Django `locale/` and the `django` domain) or
`ui-python-gettext` (existing GNU catalogs). Use `gettext`, `ngettext`,
`pgettext`, and `{% blocktrans %}` / `{% trans %}`. Placeholders are `%(name)s`.

Module-level model, form, and settings strings use the lazy form. Add every lazy
or noop name you call to `ui.uiExtractor.funcNames` (for example `gettext_lazy`,
`ngettext_lazy`, `pgettext_lazy`, and `gettext_noop`). The default list does not
include them.

Before:

```python
verbose_name = "Display name"
```

After:

```python
from django.utils.translation import gettext_lazy as _

verbose_name = _("Display name")
```

Template:

```django
{% blocktrans with name=user.name %}Hello, {{ name }}{% endblocktrans %}
```

The extractor stores that message with a `%(name)s` placeholder. Do not switch
Django templates to `{name}` or to i18next `{{name}}` inside a Python `t()`
call.

## Order

1. Pick `ui-python-django` or `ui-python-gettext`. Confirm `funcNames` includes
   every gettext function the code calls.
2. Wrap call sites. Keep existing `.po` files.
3. Run `ai-i18n-tools extract`.

## Do not

- Do not translate, rename, or reword msgid text.
- Do not add `i18n.py` or a flat-JSON `t()` helper next to Django's gettext.
- Do not hand-edit generated `.po`, `.mo`, `strings.json`, or
  `ui-languages.json`.
- Do not change model field names, migrations, or test selectors.

## Verify

- `ai-i18n-tools extract` lists the new messages and does not drop existing
  msgid entries that `ingestExistingPo` already filled.
- Django checks and the project's tests pass.
- The source locale still shows the original copy.

## Report back

List files changed, each string you deliberately skipped with a one-line reason,
function names you added to `funcNames`, and any ambiguous case left for a
human.

Migrate call sites ​

migrate-python-i18n is a dry run until you pass --write. It always writes migrate-python-i18n-report.md. Generated i18n.py files are skipped. Debug fields such as {value=} and nested format specs are reported and left in place.

bash
ai-i18n-tools migrate-python-i18n
ai-i18n-tools migrate-python-i18n --write

Read the report before --write when the dry run lists manual-review sites. Optional --report <path> changes the report path. Optional --ui-block limits the scan to one UI block.

Choose models and translate ​

The model list from init is a starting point. Rank the active provider's chat models and, when the shortlist looks right, write it into translationModels:

bash
ai-i18n-tools suggest-models
ai-i18n-tools suggest-models --apply

--apply replaces only translationModels. The same ranking is on the Models tab. Then extract and translate:

bash
ai-i18n-tools extract
ai-i18n-tools translate-ui

extract writes strings.json and ui-languages.json. translate-ui fills the flat JSON files and, when ui.gettext is set, the .po / .mo catalogs. Do not hand-edit those generated catalogs.

Switch locale at runtime ​

set_locale returns a ContextVar token. Reset it when the request or the callback ends.

python
token = set_locale(code)
try:
    text = t("Hello, {name}!", name=name)
finally:
    reset_locale(token)

FastAPI and Flask middleware must reset in finally (Flask: teardown_request) so concurrent requests do not leak a locale. Django projects keep Django's locale activation and do not use i18n.py. Per-framework notes: Locale in each framework.

Keep it translated ​

Re-run ai-i18n-tools sync (or extract then translate-ui) after changing source text. The cache means only new or changed strings go to the model. Run it in CI so translations do not drift. ai-i18n-tools dashboard opens the Translation Dashboard to review translations.

To translate README.md in the same run, scaffold with --with-readme (or add the block to an existing config). See Add i18n to your README and Markdown docs.

Not your case? ​

You haveRead
A JS/TS appAdd i18n to a JS/TS app
A website or docs siteAdd i18n to a website or docs site
A repository README or other Markdown filesAdd i18n to your README and Markdown docs
Nested locale JSONJSON

Examples ​

ExampleWhat it shows
python-streamlitDashboard, t() / _(), flat JSON
python-fastapiRequest-scoped locale, flat JSON
python-flaskFlask + Jinja, flat JSON
python-gettextExisting gettext CLI; translate-ui refreshes .po and .mo

Copy one with npx degit, or download ai-i18n-tools-python-examples.zip from the GitHub Release. Commands: Examples.

Released under the MIT License.