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.
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.
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:
- URL prefix:
/fr/about/activates French - Cookie:
hyper_languagecookie value - Accept-Language header: Parses quality values, finds best match
LANGUAGE_CODEsetting: 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:
Then in templates:
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:
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,.jinja2templates
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_CODEsetting) - realtime.md -- Real-time patterns (localized notification content)