Skip to main content

Safe gettext and Babel integration for Python t-strings

Project description

gettext-tstrings

Safe gettext integration for Python 3.14+ t-strings, with first-class Babel extraction and validation.

import gettext

from gettext_tstrings import Translator

_ = Translator(gettext.translation("messages", localedir="locales"))
name = "Ada"
print(_(t"Hello {name}"))

The catalog receives the complete sentence Hello {name}. A translation may reorder or repeat {name}, while the runtime rejects missing, unknown, or modified placeholders.

Why

Python t-strings preserve the static text, evaluated values, source expressions, conversions, and format specifications separately. That makes them a useful boundary for internationalization, but gettext and Babel do not define how a t-string becomes a catalog message.

gettext-tstrings makes one deliberately narrow choice:

  • translate complete messages, never sentence fragments;
  • accept only simple variable names such as {name};
  • keep !r and :.2f formatting under application control;
  • let translators reorder and repeat known placeholders, but not execute attribute access or add formatting behavior;
  • reuse normal POT, PO, and MO files.

Install

python -m pip install gettext-tstrings

Python 3.14 or newer is required. Rendering has no dependencies — it uses the standard library's gettext and nothing else.

Extraction and catalog validation run through Babel, so install that extra wherever you run pybabel (typically a dev or CI environment, not your production image):

python -m pip install "gettext-tstrings[babel]"

Pythonic runtime API

The recommended API mirrors gettext's class-based usage. Bind a standard translation object once and use the callable processor as _:

import gettext

from gettext_tstrings import Translator

translations = gettext.translation(
    "messages",
    localedir="locales",
    languages=["ja"],
)

_ = Translator(translations)

name = "Ada"
print(_(t"Hello {name}"))

n = 3
print(_.ngettext(t"One file", t"{n} files", n))

filename = "report.txt"
print(_.pgettext("button", t"Open {filename}"))

The module-level API follows the standard library names and positional-only calling convention:

from gettext_tstrings import gettext, ngettext, npgettext, pgettext

gettext(t"Hello {name}", translations=translations)
ngettext(t"One file", t"{n} files", n, translations=translations)
pgettext("button", t"Open {filename}", translations=translations)
npgettext(
    "inbox",
    t"One message",
    t"{n} messages",
    n,
    translations=translations,
)

tr and ntr are exact aliases of gettext and ngettext. When an explicit translation object is omitted, module-level functions use the translations bound to the current context (see Per-request language), and otherwise fall back to the standard library's globally installed gettext functions.

Plural branches may expose different values. This common form is valid:

ngettext(t"One file", t"{n} files", n)

Fields present in both source branches are required in every translated plural form. A field present in only one branch is available but optional, allowing a language's plural rules to differ from the source language.

Conversions and format specs stay outside the catalog:

amount = 1234.5
tr(t"Total: {amount:,.2f}")
# msgid: "Total: {amount}"

Per-request language

Web frameworks pick a language per request. Bind the request's translations to the current context and the module-level functions (and any _ that calls them) resolve to that language, safely across concurrent requests:

from gettext_tstrings import tr, use_translations


def handle(request):
    translations = load_translations(request.locale)  # your gettext.translation(...)
    with use_translations(translations):
        return render(tr(t"Hello {name}"))

set_translations(translations) binds without a with block (for frameworks that manage the request lifecycle themselves), and get_translations() reads the current binding. An explicit translations= argument always wins over the context. A bound Translator is the alternative when you prefer to thread one object through your call sites explicitly.

Deferred (lazy) translation

A t-string captures its values eagerly, which is wrong for a string defined at import time — a form label, an enum value, a module constant — that must render in whatever language is active when it is used. lazy_gettext defers the catalog lookup and rendering to first use, resolving the current context:

from gettext_tstrings import lazy_gettext, lazy_pgettext, use_translations

SAVE = lazy_gettext(t"Save changes")  # defined once, at import
OPEN = lazy_pgettext("button", t"Open file")

with use_translations(japanese):
    assert str(SAVE) == "変更を保存"  # rendered here, in this language

A LazyString renders through str(), format(), and f-strings, and compares equal to its rendered text. Plural forms depend on a runtime count, so render those eagerly with ngettext where the count is known.

Broken catalogs never crash a render

If a translation's placeholders do not match the source — a missing, unknown, or reformatted field slipping past validation (a hand-edited MO, a vendor catalog, a pipeline that skips the checker) — the default is to reproduce the source text rather than raise, mirroring gettext's contract that a bad catalog never breaks the application. A warning is logged on the gettext_tstrings logger.

Opt into fail-loud behavior for tests and CI:

_ = Translator(translations, strict=True)  # raises InvalidTranslationError
tr(t"Hello {name}", translations=translations, strict=True)

Rendering a pattern without a catalog

compile_template exposes the same machinery one level down: it turns a t-string into its msgid plus a bound set of values, and renders any pattern you hand it. Use it when the pattern comes from somewhere other than a gettext catalog, or to see the msgid a call will produce.

from gettext_tstrings import compile_template

name = "Ada"
compiled = compile_template(t"Hello {name}")

compiled.msgid  # "Hello {name}"
compiled.placeholders  # ("name",)
compiled.render("こんにちは {name}")  # "こんにちは Ada"

render validates its pattern by the same rules and always raises InvalidTranslationError on a mismatch. There is no lenient mode here: leniency exists so that a catalog lookup can degrade to the source text, and a pattern you passed in yourself has nothing to degrade away from.

Extract with Babel

Extraction needs the babel extra (pip install "gettext-tstrings[babel]"). Create babel.cfg:

[gettext_tstrings: **.py]
encoding = utf-8

Then use the normal Babel workflow:

pybabel extract -F babel.cfg -o locales/messages.pot .
pybabel init -i locales/messages.pot -d locales -l ja
pybabel compile -d locales

The gettext_tstrings extractor also extracts ordinary _(), gettext(), and ngettext() calls, so one mapping can cover mixed codebases.

The extractor recognizes _(), the four standard gettext names, the tr() / ntr() aliases, and the deferred lazy_gettext() / lazy_pgettext(). Additional aliases can be configured:

[gettext_tstrings: **.py]
tr_functions = tr, translate
ntr_functions = ntr, pluralize
gettext_functions = gettext, _, lazy_gettext
ngettext_functions = ngettext
pgettext_functions = pgettext, lazy_pgettext
npgettext_functions = npgettext

All Translator methods are recognized regardless of the variable name. Callable processors named _ are recognized by default; add another callable variable name to gettext_functions if needed.

Registering t-string functions

t-string calls are recognized through the *_functions mapping options above, not through Babel's -k/--keyword flag. A t-string literal cannot be read by Babel's built-in keyword machinery, so a custom helper such as mytr(t"...") must be listed in tr_functions (or the matching option) — -k mytr alone will not extract it. The -k flag continues to work for ordinary (non-t-string) gettext calls, which are extracted alongside.

Only the standard gettext argument order is supported (message first; context then message for pgettext; context, singular, plural for npgettext). Wrappers with non-standard argument positions are not configurable.

Robust by default

  • A t-string the extractor rejects (attribute access, an expression, a wrong argument) is warned about and skipped; it never aborts extraction of the rest of the project. An unparsable file is skipped the same way. Set strict = true in the mapping to fail the run instead.
  • Simple messages extract under any keyword set, including pybabel extract --no-default-keywords -k tr. Plural and contextual messages need Babel's canonical keyword specs, so keep ngettext, pgettext, and npgettext in the keyword set (the default set already has them); otherwise those messages are skipped with a warning.

Translator comments work as usual — pass the tag to pybabel extract with -c "Translators:", exactly as for ordinary gettext calls:

# Translators: Product name shown in the account header.
tr(t"Welcome, {product_name}")

Every extracted t-string message carries an automatic gettext-tstrings comment. The installed Babel checker uses that marker to reject incompatible placeholders and translation-controlled formatting during catalog validation and compilation.

Your existing toolchain validates these catalogs

Babel (≥2.18, the declared floor) automatically marks extracted t-string messages with the standard python-brace-format flag:

#: app.py:4
#, python-brace-format
msgid "Hello {name}"
msgstr ""

That flag is what standard gettext tooling keys on, so a broken translation is caught without any extra configuration:

  • GNU msgfmt (≥0.19) rejects it at compile time — verified against gettext-tools, and covered by this project's test suite:

    $ msgfmt --check-format -o /dev/null ja.po
    ja.po:24: a format specification for argument 'name' doesn't exist in 'msgstr'
    msgfmt: found 1 fatal error
    
  • The Babel checker shipped with this package applies the stricter t-string rules during pybabel compile: no translation-side conversions or format specs, and the plural required/allowed placeholder sets.

Beyond those two, the flag is a documented PO convention rather than something this project can vouch for: Weblate documents a Python brace format check, and the commercial platforms have their own placeholder QA. Behavior there is theirs, not verified here.

Note that msgfmt only checks placeholder names it can parse as Python brace format. Sticking to ASCII placeholder names keeps every tool in the chain able to validate the message.

Performance

The overhead is sub-microsecond per call: roughly 0.4 µs for a one-field message on Apple Silicon (including constructing the t-string itself), about 2.5× a plain gettext(...).format(...). That difference buys placeholder validation, safe rendering, and the structural guarantees above — this library optimizes for safety per nanosecond, not for beating str.format.

The runtime separates static structure from dynamic values as intended by PEP 750:

  • template plans are looked up by the template's static strings with per-interpolation verification, so a warm call builds no cache keys;
  • translated brace patterns are parsed and validated once, then cached on their plan;
  • both caches are bounded and never retain interpolated values — with one documented exception: a nested format spec (t"{x:{width}}") is evaluated by the interpreter before the library sees it, so that evaluated spec is part of the cached plan's identity;
  • a translation that fails validation is remembered too, so a broken catalog entry warns once instead of re-validating on every render;
  • each distinct value is formatted at most once per render, even when a translation repeats its placeholder;
  • constant, one-field, and two-field messages use specialized rendering paths.

Run the reproducible microbenchmark on your target interpreter and hardware:

uv run python benchmarks/runtime.py

Safety and scope

This is valid:

tr(t"Hello {name}")

These are intentionally rejected:

tr(t"Hello {user.name}")  # attribute access
tr(t"Hello {display_name()}")  # function call

Compute a meaningful value first:

name = user.display_name()
tr(t"Hello {name}")

This restriction produces stable catalog keys, gives translators useful names, and prevents translated strings from becoming an expression language.

The safety guarantee is scoped to structure and formatting: a translation is never evaluated, and can never add attribute access, calls, conversions, or format specs. Two things stay the caller's responsibility, exactly as with stdlib gettext: escaping rendered output for its sink (HTML, shell, terminal), and catalog integrity — a hostile catalog can repeat a placeholder to amplify output size, which is inherent to any placeholder-based i18n and is not bounded here.

Templates and other tools

t-strings are Python syntax, so this library covers Python source. Template languages (Jinja2, Django templates) keep using their own {% trans %} / {{ _(...) }} i18n and Babel's template extractors; both feed the same PO catalog, so one translation workflow covers a mixed codebase.

pygettext cannot parse t-strings today, so extraction goes through Babel. The t-string→msgid convention is written down as a small, versioned contract in SPEC.md so that other extractors, IDEs, type checkers, or a future pygettext can target it.

Status

The project is an alpha. Its core contract is small on purpose; the specification is the stable reference. Before a stable release it will add broader language fixtures, sustained performance tracking, API review from gettext/Babel users, and compatibility testing against every supported Python and Babel release.

Contributions are welcome — see CONTRIBUTING.md.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

gettext_tstrings-0.1.0a4.tar.gz (84.5 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

gettext_tstrings-0.1.0a4-py3-none-any.whl (28.4 kB view details)

Uploaded Python 3

File details

Details for the file gettext_tstrings-0.1.0a4.tar.gz.

File metadata

  • Download URL: gettext_tstrings-0.1.0a4.tar.gz
  • Upload date:
  • Size: 84.5 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.14

File hashes

Hashes for gettext_tstrings-0.1.0a4.tar.gz
Algorithm Hash digest
SHA256 c410939d90c5db4327970e26144ccd1e4fb67ea14dd7818294cf375e0ac0e2bd
MD5 808c9eb02c6ee3fc5d36f70b7b0377b9
BLAKE2b-256 8380a0d085836f197f34fbf7bf332f4708f2718c2dfc9f554e164972c527f511

See more details on using hashes here.

Provenance

The following attestation bundles were made for gettext_tstrings-0.1.0a4.tar.gz:

Publisher: release.yml on yhay81/gettext-tstrings

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file gettext_tstrings-0.1.0a4-py3-none-any.whl.

File metadata

File hashes

Hashes for gettext_tstrings-0.1.0a4-py3-none-any.whl
Algorithm Hash digest
SHA256 52f52eb34c035ff82657b1e20cc1e70311f1a142518c01048da608c3963527ed
MD5 eff4b1c7853a8798cb1b812c47e4e799
BLAKE2b-256 32f202c21575e8955ff54a64793004b770fdb5195182993e1eaa212a3ade3523

See more details on using hashes here.

Provenance

The following attestation bundles were made for gettext_tstrings-0.1.0a4-py3-none-any.whl:

Publisher: release.yml on yhay81/gettext-tstrings

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page