Saltar al contenido principal

Internationalization (i18n)

Horus Runtime provides a complete internationalization system built on GNU gettext and pybabel. The single building block for all translation needs (both inside horus-runtime and in external plugins) is make_translator.

How Translation Works

The i18n system operates in three phases:

  1. Extraction: Source code is scanned for strings marked for translation and extracted into a master catalog (.pot file)
  2. Translation: Translators work on language-specific .po files containing the original strings and their translations
  3. Compilation: .po files are compiled into binary .mo files loaded at execution time

When your code requests a translation, the runtime looks up the string in the compiled catalog for the current locale and returns the appropriate translation. If no translation exists, the original string is returned as a fallback.

make_translator

make_translator(domain, locale_dir) is the factory function that creates a translator callable. It is called once at module load time and returns a tr-compatible function:

from pathlib import Path
from horus_runtime.i18n import make_translator

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

The returned callable has the signature (msg, plural=None, n=None, **kwargs) -> str and accepts the same arguments as the tr function described below.

gettext is initialized with fallback=True, so an absent or unsupported locale silently returns the original English string: no exception handling needed at the call site.

horus-runtime's own internal translator is created the same way:

# inside horus_runtime/i18n.py
tr = make_translator("horus_runtime", Path(__file__).parent / "locale")

External plugins must not import horus-runtime's tr directly for their own strings. Each plugin should call make_translator with its own domain and locale directory so translations stay isolated. See Plugin Internationalization for the full setup.

Using Translations in Your Code

Basic Translation

Import the translator and alias it as _ which is the standard gettext convention:

from horus_runtime.i18n import tr as _

# Simple string translation
greeting = _("Hello, world!")
error_message = _("File not found")
status = _("Processing complete")

This imports horus-runtime's own internal translator. Plugin authors should instead import the tr they created with make_translator in their own package and alias that as _.

Pluralization

Many languages have complex plural rules. English has two forms (singular/plural), but other languages may have three, four, or even more. The i18n system handles this automatically:

from horus_runtime.i18n import tr as _

# Provide both singular and plural forms
count = 5
message = _("{n} file", "{n} files", n=count)
# Result: "5 files"

count = 1
message = _("{n} file", "{n} files", n=count)
# Result: "1 file"

# Works with any countable noun
tasks = _("{n} task completed", "{n} tasks completed", n=total)
errors = _("{n} error found", "{n} errors found", n=error_count)

The system automatically selects the correct plural form based on the language's rules. For example, Polish has different forms for 1, 2-4, and 5+. You provide the English forms, and translators provide all necessary forms for their language.

Variable Substitution

Embed variables into translated strings using named placeholders:

from horus_runtime.i18n import tr as _

# Single variable
username = "Alice"
message = _("Welcome, {user}!", user=username)
# Result: "Welcome, Alice!"

# Multiple variables
file_name = "database.db"
file_size = "2.3 MB"
status = _("Downloaded {name} ({size})", name=file_name, size=file_size)
# Result: "Downloaded database.db (2.3 MB)"

# Combining plurals and substitution
items = 42
category = "documents"
summary = _("Found {n} {type}", "Found {n} {type}s", n=items, type=category)
# Result: "Found 42 documents"

Named placeholders ensure translators can reorder variables to match their language's grammar. For example, English says "Welcome, Alice" but another language might require "Alice, welcome".

Context-Specific Translations

Some words translate differently depending on context. For example, "Open" can mean "open a file" or "not closed":

from horus_runtime.i18n import tr as _

# Use descriptive strings that provide context
button_label = _("Open file") # Better than just _("Open")
status = _("File is open") # Different context, different translation

# For very short strings, add context in comments
# Translators: This is a button label for opening files
action = _("Open")

The clearer your original strings, the better the translations will be.

Babel Configuration

Babel automates the extraction, merging, and compilation of translation files. The Horus Runtime uses a babel.cfg configuration file that tells Babel where to find translatable strings.

Initial Setup

Before extracting translations, the locale directory structure must exist:

mkdir -p src/horus_runtime/locale/

This creates the root directory where all translation files will be stored. Each language will get its own subdirectory following the gettext convention: locale/LANG/LC_MESSAGES/.

Extracting Translatable Strings

When you add new _() calls to your code, you need to extract them into the master catalog:

make babel-extract

This command does the following:

  1. Scans all Python files in src/horus_runtime/ for _() function calls
  2. Extracts the strings and their source locations (file and line number)
  3. Creates or updates src/horus_runtime/locale/messages.pot

The .pot file (Portable Object Template) is the master catalog. It contains all extractable strings in your source code with no translations, just the original English text. This file is never edited manually; it's regenerated from source code.

After extraction, the .pot file looks like this:

#: src/horus_runtime/workflow.py:45
msgid "Workflow started"
msgstr ""

#: src/horus_runtime/workflow.py:67
#, python-format
msgid "{n} task"
msgid_plural "{n} tasks"
msgstr[0] ""
msgstr[1] ""

Each entry shows where the string was found in the source code (#: comment), the original string (msgid), and empty translation slots (msgstr).

Adding a New Language

Adding a language creates a language-specific catalog based on the master .pot file.

Step 1: Create Language Files

make babel-add LANG=es

Replace es with the appropriate ISO 639-1 language code:

  • es - Spanish
  • fr - French
  • de - German
  • ja - Japanese
  • pt_BR - Brazilian Portuguese (use underscore for regional variants)
  • zh_CN - Simplified Chinese

This command:

  1. Creates the directory structure: src/horus_runtime/locale/es/LC_MESSAGES/
  2. Generates horus_runtime.po by copying the .pot file
  3. Sets the language metadata in the .po header

The LC_MESSAGES directory name is a gettext convention. All applications using gettext expect translations in this exact directory name.

Step 2: Edit Translations

Open the generated .po file at src/horus_runtime/locale/es/LC_MESSAGES/horus_runtime.po. You'll see entries like this:

#: src/horus_runtime/workflow.py:45
msgid "Workflow started"
msgstr ""

Fill in the msgstr field with the translation:

#: src/horus_runtime/workflow.py:45
msgid "Workflow started"
msgstr "Flujo de trabajo iniciado"

For plurals, fill in all plural forms required by the language:

#: src/horus_runtime/workflow.py:67
#, python-format
msgid "{n} task"
msgid_plural "{n} tasks"
msgstr[0] "{n} tarea"
msgstr[1] "{n} tareas"

The .po file header contains important metadata:

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

The Plural-Forms field defines the plural rule for the language. Spanish has two forms: one for n=1, another for everything else. This rule is language-specific and must be correct for pluralization to work.

Step 3: Validate Translations

After editing, verify that your translations are syntactically correct:

make babel-check

This command:

  1. Compiles all .po files to .mo files (binary format)
  2. Reports any syntax errors (malformed entries, missing translations, etc.)
  3. Shows statistics: how many strings are translated vs. untranslated

If compilation succeeds, the .mo files are created in the same directory as the .po files. These binary files are what the runtime actually loads, they're optimized for fast lookups at runtime.

Updating Existing Translations

As you develop, you'll add new strings, modify existing ones, or remove obsolete ones. The translation files need to stay synchronized with the source code.

Step 1: Extract and Update

make babel-refresh

This command does two things:

  1. Runs babel-extract to regenerate the .pot file from current source code
  2. Runs babel update to merge changes into all existing .po files

The merge process is intelligent:

  • New strings are added with empty msgstr fields (need translation)
  • Modified strings are marked as "fuzzy" (need review)
  • Unchanged strings keep their existing translations
  • Removed strings are commented out (in case you need to reference them)

Step 2: Review Changes

Open each .po file and look for:

New entries (empty translations):

msgid "New feature enabled"
msgstr ""

Provide the translation.

Fuzzy entries (marked with #, fuzzy):

#, fuzzy
msgid "File saved successfully"
msgstr "Archivo guardado"

The "fuzzy" flag indicates the original string changed slightly, and the existing translation might need adjustment. Review the translation, update it if necessary, and remove the #, fuzzy comment.

Obsolete entries (commented out):

#~ msgid "Old message"
#~ msgstr "Mensaje antiguo"

These can be deleted or kept for reference.

Step 3: Compile

After updating translations, recompile:

make babel-check

This verifies syntax and regenerates the .mo files that the runtime will load.

Translation Management Commands

The Makefile provides these commands for managing translations:

make babel-extract

Extracts translatable strings from the source code into the master .pot file. Does not modify existing .po files.

make babel-update

Updates all language .po files using the current .pot template. Merges new and changed strings into each .po file without marking modified entries as fuzzy.

make babel-refresh

Runs both babel-extract and babel-update to regenerate the .pot file and update all .po files in one step.

make babel-check

Validates all .po files for missing or fuzzy translations. Fails if any untranslated or fuzzy strings are found. Compiles all .po files to .mo files if validation passes.

make babel-add LANG=xx

Initializes a new language catalog for the given language code (replace xx with the ISO code). Creates the necessary directory and .po file for the new language.

Directory Structure

The complete translation directory structure for horus-runtime:

src/horus_runtime/
locale/
messages.pot # Master template (auto-generated)
es/
LC_MESSAGES/
horus_runtime.po # Spanish translations
horus_runtime.mo # Compiled binary
fr/
LC_MESSAGES/
horus_runtime.po # French translations
horus_runtime.mo # Compiled binary

Plugins follow the same layout under their own source tree, using their package name as the domain. See Plugin Internationalization.

Test with Long Translations

Some languages (German, Finnish) produce translations 30-50% longer than English. Make sure the UI can handle longer text without breaking layouts.