Skip to content

Internationalization (i18n) -- Translation Framework

Complete i18n system for HyperDjango: translation catalogs, PO file parsing, plural rules, lazy strings, per-thread language activation, locale middleware, template {% trans %} integration, URL prefixing, and message extraction.

Django-compatible API (gettext, ngettext, pgettext, npgettext) with full support for .po file format, BCP 47 language codes, and Accept-Language header parsing.

from hyperdjango.i18n import gettext as _, ngettext, activate, override

activate("fr")
print(_("Hello"))                             # "Bonjour"

print(ngettext("%(count)d item", "%(count)d items", 3) % {"count": 3})

with override("de"):
    print(_("Hello"))                         # "Hallo"

Core Translation API

gettext(message) / _(message)

Translate a message string using the active language. Returns the original string if no translation is found.

from hyperdjango.i18n import gettext as _

label = _("Submit")      # "Envoyer" when French is active

ngettext(singular, plural, count)

Translate with pluralization. The plural form is selected by the language's plural rules.

from hyperdjango.i18n import ngettext

msg = ngettext("%(count)d file", "%(count)d files", count) % {"count": count}
# count=1: "1 file", count=5: "5 files"
# In Russian with 21: uses the singular form (21 = "1 file" form)

pgettext(context, message)

Translate with disambiguation context. Use when the same English string has different meanings in different contexts.

from hyperdjango.i18n import pgettext

month = pgettext("month name", "May")        # "Mai" (German month)
verb = pgettext("verb", "May")               # "Darf" (German permission)

npgettext(context, singular, plural, count)

Translate with both context and pluralization.

from hyperdjango.i18n import npgettext

msg = npgettext("email", "%(count)d message", "%(count)d messages", count)

Lazy Translations

Lazy strings delay translation until str() is called. Essential for module-level definitions where the active language is not yet known.

from hyperdjango.i18n import gettext_lazy, ngettext_lazy, pgettext_lazy, npgettext_lazy

# At module level -- translated at render time, not import time
FORM_LABEL = gettext_lazy("Username")
HELP_TEXT = gettext_lazy("Enter your full name")
ERROR_MSG = ngettext_lazy("%(count)d error", "%(count)d errors", 1)
MONTH_LABEL = pgettext_lazy("month name", "May")

LazyString behaves like a regular string in most contexts: comparison, formatting, concatenation, iteration, indexing, and hashing all work transparently.

from hyperdjango.i18n import gettext_lazy

label = gettext_lazy("Hello")
assert label == "Hello"          # True (compares resolved value)
assert len(label) == 5
assert f"Say: {label}" == "Say: Hello"

Language Activation

activate(language)

Set the active language for the current thread.

from hyperdjango.i18n import activate, get_language

activate("fr")
print(get_language())    # "fr"

deactivate()

Reset to the default language from the LANGUAGE_CODE setting.

get_language()

Return the active language code for the current thread, or LANGUAGE_CODE if none is set.

override(language)

Context manager to temporarily switch the active language.

from hyperdjango.i18n import override, gettext as _

activate("en")
print(_("Hello"))        # "Hello"

with override("fr"):
    print(_("Hello"))    # "Bonjour"

print(_("Hello"))        # "Hello" (restored)

with override(None):
    print(_("Hello"))    # uses LANGUAGE_CODE setting

Language Info

get_language_info(language)

Get metadata about a language. Returns a LanguageInfo dataclass with code, name (English), name_local (native), and bidi (RTL flag). Raises KeyError if the language code is not recognized (tries base language first, e.g. "en-US" falls back to "en").

from hyperdjango.i18n import get_language_info

info = get_language_info("fr")
print(info.name)         # "French"
print(info.name_local)   # "fran\u00e7ais"
print(info.bidi)         # False

info = get_language_info("ar")
print(info.bidi)         # True (Arabic is RTL)

Over 50 languages are built in, including: af, ar, bg, bn, bs, ca, cs, cy, da, de, el, en, es, et, fa, fi, fr, ga, gl, he, hi, hr, hu, id, is, it, ja, ko, lt, lv, mk, ms, nb, nl, nn, pl, pt, pt-BR, ro, ru, sk, sl, sr, sv, sw, th, tr, uk, ur, vi, zh, zh-Hans, zh-Hant.


PO File Format

HyperDjango uses the standard GNU gettext .po file format for translations.

Directory Structure

locale/
  fr/
    LC_MESSAGES/
      django.po          # French translations
      django.mo          # (optional, not needed -- HyperDjango reads .po directly)
  de/
    LC_MESSAGES/
      django.po          # German translations
  es/
    LC_MESSAGES/
      django.po

PO File Example

# Translation file for language: fr

msgid ""
msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: fr\n"
"Plural-Forms: nplurals=2;\n"

msgid "Hello"
msgstr "Bonjour"

msgid "Submit"
msgstr "Envoyer"

# Context disambiguation
msgctxt "month name"
msgid "May"
msgstr "Mai"

# Pluralization
msgid "%(count)d item"
msgid_plural "%(count)d items"
msgstr[0] "%(count)d article"
msgstr[1] "%(count)d articles"

parse_po_file(content)

Parse a .po file string into POEntry objects. Handles single-line and multi-line strings, msgctxt, msgid_plural, and msgstr[N].

Entries marked with the #, fuzzy flag are automatically skipped and not included in the returned list. Fuzzy entries are incomplete translations that have not been verified by a translator. The metadata entry (empty msgid) is also skipped.

from hyperdjango.i18n import parse_po_file

content = open("locale/fr/LC_MESSAGES/django.po").read()
entries = parse_po_file(content)
for entry in entries:
    print(f"{entry.msgid} -> {entry.msgstr}")

load_po_file(path)

Load a .po file and return a TranslationCatalog. The language code is inferred from the directory structure.

from hyperdjango.i18n import load_po_file

catalog = load_po_file("locale/fr/LC_MESSAGES/django.po")
print(catalog.language)       # "fr"
print(catalog.messages)       # {"Hello": "Bonjour", "Submit": "Envoyer", ...}

discover_translations(locale_paths=None)

Scan locale directories for all .po files and return merged catalogs per language. Uses LOCALE_PATHS setting if locale_paths is None.

from hyperdjango.i18n import discover_translations

catalogs = discover_translations(["locale", "myapp/locale"])
for lang, catalog in catalogs.items():
    print(f"{lang}: {len(catalog.messages)} messages")

load_translations(locale_paths=None)

Convenience function that discovers and loads all translations into the global engine.

from hyperdjango.i18n import load_translations

load_translations()    # loads from LOCALE_PATHS setting

Plural Rules

HyperDjango includes CLDR-based plural rules for 40+ languages, grouped by plural rule pattern:

Plural Pattern Languages Forms Rule
One/Other en, de, nl, sv, da, nb, nn, is, fo, fi, et, el, he 2 one/other (n == 1)
French-style fr, pt-BR, hi, bn 2 0 and 1 are singular
Romance es, it, pt, ca, gl 2 one/other (n == 1)
Russian-style ru, uk, sr, hr, bs 3 Slavic three-form
Polish pl 3 Polish three-form
Czech/Slovak cs, sk 3 1 / 2-4 / other
No Plurals zh, ja, ko, vi, th, id, ms, tr, hu 1 Single form for all counts
Arabic ar 6 Six plural forms
Romanian ro 3 1 / 0,1-19 / other
Lithuanian lt 3 Special mod-10/mod-100
Latvian lv 3 0 / ends-1-not-11 / other
Irish ga 5 Five forms
Welsh cy 6 Six forms (0/½/3/6/other)

get_plural_func(language)

Get the plural rule function for a language. Falls back through base language, then to Germanic (one/other).

from hyperdjango.i18n import get_plural_func

func = get_plural_func("ru")
print(func(1))    # 0 (singular)
print(func(2))    # 1 (few)
print(func(5))    # 2 (many)
print(func(21))   # 0 (singular -- Russian rule)

LocaleMiddleware

Detect and activate language per-request. Add to your middleware stack:

from hyperdjango.i18n import LocaleMiddleware

app.use(LocaleMiddleware(cookie_name="hyper_language"))

Detection order:

  1. URL prefix: /fr/about/ activates French
  2. Cookie: hyper_language cookie value
  3. Accept-Language header: Parses quality values, finds best match
  4. LANGUAGE_CODE setting: Final fallback

parse_accept_language(header)

Parse an Accept-Language header into (language, quality) pairs sorted by quality descending.

As a security measure, headers longer than 4096 bytes are silently truncated before parsing. This prevents regex denial-of-service via extremely long Accept-Language values.

from hyperdjango.i18n import parse_accept_language

pairs = parse_accept_language("en-US,en;q=0.9,fr;q=0.8")
# [("en-US", 1.0), ("en", 0.9), ("fr", 0.8)]

Template Integration

{% trans %} Tag

Register the i18n callback with the Zig template engine to enable {% trans %} in templates:

from hyperdjango.i18n import setup_template_i18n

setup_template_i18n()    # call once at startup

Then in templates:

<h1>{% trans "Welcome" %}</h1>
<p>{% trans "Hello, world!" %}</p>

The {% trans %} tag calls gettext() with the active language for the current thread.


URL i18n Patterns

i18n_url_patterns(*patterns, prefix_default_language=True, languages=None)

Wrap URL patterns with language prefixes. Each pattern gets a copy per language.

from hyperdjango.i18n import i18n_url_patterns

routes = i18n_url_patterns(
    ("/about/", about_view),
    ("/contact/", contact_view),
    prefix_default_language=True,
)
# Generates:
#   /en/about/, /en/contact/
#   /fr/about/, /fr/contact/
#   /de/about/, /de/contact/
#   ... (one per loaded language)

To leave the default language unprefixed:

routes = i18n_url_patterns(
    ("/about/", about_view),
    prefix_default_language=False,   # /about/ for default, /fr/about/ for others
)

To restrict to specific languages:

routes = i18n_url_patterns(
    ("/about/", about_view),
    languages=["en", "fr", "de"],
)

Message Extraction and PO File Creation

extract_messages(source_dirs)

Scan Python files and templates for translatable strings. Finds:

  • gettext("..."), _("...")
  • ngettext("...", "...", n) (both singular and plural)
  • pgettext("context", "...")
  • {% trans "..." %} in .html, .txt, .xml, .jinja, .jinja2 templates
from hyperdjango.i18n import extract_messages

messages = extract_messages(["myapp", "templates"])
print(messages)    # ["Hello", "Submit", "Welcome", ...]

create_po_file(messages, language, existing=None)

Generate a .po file from extracted messages. If existing content is provided, preserves existing translations and adds new entries with empty msgstr.

from hyperdjango.i18n import extract_messages, create_po_file
from pathlib import Path

messages = extract_messages(["myapp"])

# Create a new PO file
content = create_po_file(messages, "fr")
Path("locale/fr/LC_MESSAGES/django.po").write_text(content)

# Update an existing PO file (merge)
existing = Path("locale/fr/LC_MESSAGES/django.po").read_text()
content = create_po_file(messages, "fr", existing=existing)
Path("locale/fr/LC_MESSAGES/django.po").write_text(content)

Full Workflow

from hyperdjango.i18n import extract_messages, create_po_file, load_translations
from pathlib import Path

# 1. Extract messages from source code
messages = extract_messages(["myapp", "templates"])

# 2. Create/update PO files for each target language
for lang in ["fr", "de", "es"]:
    po_path = Path(f"locale/{lang}/LC_MESSAGES/django.po")
    po_path.parent.mkdir(parents=True, exist_ok=True)
    existing = po_path.read_text() if po_path.exists() else None
    content = create_po_file(messages, lang, existing=existing)
    po_path.write_text(content)

# 3. Translate the PO files (manually or via translation service)
# 4. Load translations at startup
load_translations()

Configuration

These settings are defined in hyperdjango.conf. See settings.md for full details on configuration via Django settings, environment variables, and .env files.

Setting Default Description
LANGUAGE_CODE "en" Default language when no language is activated. Used as the fallback in get_language() and by LocaleMiddleware when no other language is detected.
LOCALE_PATHS [] List of filesystem directories to scan for .po translation files. Each directory should follow the {lang}/LC_MESSAGES/django.po structure. If empty and no explicit paths are passed, discover_translations() falls back to ["locale"].
LANGUAGES all built-in Allowed language codes

Data Classes

LanguageInfo

Field Type Description
code str BCP 47 language code (e.g. "fr")
name str English name (e.g. "French")
name_local str Native name (e.g. "fran\u00e7ais")
bidi bool True for right-to-left languages (Arabic, Hebrew, Persian, Urdu)

TranslationCatalog

Holds all translations for a single language.

Field Type Description
language str BCP 47 code
messages dict[str, str] msgid to msgstr mapping
plural_messages dict[str, tuple[str, ...]] msgid to plural form tuple
context_messages dict[tuple[str, str], str] (context, msgid) to msgstr
plural_func Callable[[int], int] Plural rule function

TranslationEngine

Thread-safe translation engine with catalog management. The global instance is used by gettext(), ngettext(), etc. All operations (load, get, translate) acquire a threading.Lock to protect the catalog dictionary under Python 3.14t free-threading. Read operations (get_catalog, translate, get_loaded_languages) also hold the lock to prevent TOCTOU races with concurrent catalog loading.

Method Returns Description
load_catalog(language, catalog) None Register (or merge into) a catalog
get_catalog(language) TranslationCatalog \| None Look up a catalog by language
translate(msgid, language=None) str Translate a message
ntranslate(singular, plural, count, language=None) str Translate with pluralization
ptranslate(context, msgid, language=None) str Translate with context
nptranslate(context, singular, plural, count, language=None) str Context + plural
get_loaded_languages() list[str] List all loaded language codes

POEntry

A single entry parsed from a .po file.

Field Type Description
msgid str Source message string
msgstr str Translated string
msgid_plural str \| None Plural form of source
msgstr_plural dict[int, str] \| None Index to plural translation
msgctxt str \| None Disambiguation context

LazyString

String proxy that delays translation until str() is called. Supports comparison, formatting, concatenation, hashing, indexing, iteration, len(), in, and % formatting. Created via gettext_lazy(), ngettext_lazy(), pgettext_lazy(), or npgettext_lazy().


See Also

  • formats.md -- Locale-aware date/number/currency formatting (uses LANGUAGE_CODE setting)
  • realtime.md -- Real-time patterns (localized notification content)