Skip to main content

Plugin Internationalization

Plugins get their own isolated translation namespace using make_translator from horus_runtime.i18n. Each plugin calls this factory once at module load time and receives a tr callable bound exclusively to its own domain and locale directory.

Setup

Three lines of boilerplate in your plugin's top-level i18n.py (or equivalent):

from pathlib import Path
from horus_runtime.i18n import make_translator

tr = make_translator("my_plugin", Path(__file__).parent / "locale")

Then import and alias tr as _ in every module that has user-visible strings:

from my_plugin.i18n import tr as _

_("Task {name} started.", name=self.name)
_("Processed {n} file", "Processed {n} files", n=count)

make_translator calls gettext.translation with fallback=True, so an absent or unsupported locale returns the original English string without raising an exception.

Why Not Import horus-runtime's tr Directly?

horus-runtime's tr is bound to the horus_runtime gettext domain and its own locale directory. Strings from your plugin would never be found there. Each plugin must create its own translator with its own domain so that:

  • Your .po/.mo files live inside your own package
  • Your strings are extracted and compiled independently from everyone else's
  • Users can translate your plugin without touching horus-runtime's catalogs

Locale Directory Structure

Follow the standard gettext layout inside your package:

src/my_plugin/
i18n.py # make_translator call lives here
locale/
messages.pot # master template (auto-generated by pybabel)
es/
LC_MESSAGES/
my_plugin.po # Spanish translations
my_plugin.mo # compiled binary (generated by pybabel compile)
fr/
LC_MESSAGES/
my_plugin.po
my_plugin.mo

The domain string passed to make_translator ("my_plugin") must match the .po/.mo filename.

Babel Workflow

The workflow mirrors the one used for horus-runtime itself. Add a babel.cfg to your plugin repository:

[python: src/**.py]

Then manage translations with pybabel:

Extract strings

pybabel extract -F babel.cfg -o src/my_plugin/locale/messages.pot src/

Initialize a new language

pybabel init -i src/my_plugin/locale/messages.pot \
-d src/my_plugin/locale \
-D my_plugin \
-l es

Update existing catalogs after source changes

pybabel update -i src/my_plugin/locale/messages.pot \
-d src/my_plugin/locale \
-D my_plugin

Compile to binary

pybabel compile -d src/my_plugin/locale -D my_plugin

If you use the same Makefile targets as horus-runtime (babel-extract, babel-refresh, babel-check, babel-add), point them at your package's locale directory and domain.

Usage Patterns

All the same patterns as horus-runtime's tr apply:

from my_plugin.i18n import tr as _

# Simple string
message = _("Plugin initialized")

# Variable substitution
message = _("Connected to {host}:{port}", host=hostname, port=port)

# Pluralization
message = _("{n} item processed", "{n} items processed", n=count)

# Plural + extra variables
message = _("Uploaded {n} file to {bucket}",
"Uploaded {n} files to {bucket}",
n=count, bucket=bucket_name)

Packaging Locale Files

Make sure the compiled .mo files are included in your distribution. In pyproject.toml:

[tool.setuptools.package-data]
my_plugin = ["locale/**/*.mo"]

Without this, users will receive English strings regardless of their locale.