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.

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)

Extract with 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:

# 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.

Performance

The overhead is sub-microsecond per call: roughly 0.7 µs for a one-field message on Apple Silicon, a few times 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 cached by static strings, expressions, conversions, and format specifications;
  • translated brace patterns are parsed and validated once;
  • both caches are bounded and never retain interpolated values;
  • each distinct value is formatted at most once per render, even when a translation repeats its placeholder;
  • one-field and constant 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.0a1.tar.gz (46.8 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.0a1-py3-none-any.whl (18.4 kB view details)

Uploaded Python 3

File details

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

File metadata

  • Download URL: gettext_tstrings-0.1.0a1.tar.gz
  • Upload date:
  • Size: 46.8 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.0a1.tar.gz
Algorithm Hash digest
SHA256 2ec0de316b22fc06a0d7f21e18ddce7445b612e52fabe1613112613a81e844be
MD5 5d24b28fb88a77c6006233b48d041eb0
BLAKE2b-256 af70613f528dad5c505981ac6b39c42f838ddbaad74eb61c942081478c40889e

See more details on using hashes here.

Provenance

The following attestation bundles were made for gettext_tstrings-0.1.0a1.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.0a1-py3-none-any.whl.

File metadata

File hashes

Hashes for gettext_tstrings-0.1.0a1-py3-none-any.whl
Algorithm Hash digest
SHA256 1f6df8c5a383d614ce5fed0120e48bdf16157da501e7a25c41cfe50aa45f842c
MD5 7a5391636224d7cb94351139b3393967
BLAKE2b-256 7b181d0038ff58e8efca4f27043d95523b184652c6aa889ebaa5af570e94bdfc

See more details on using hashes here.

Provenance

The following attestation bundles were made for gettext_tstrings-0.1.0a1-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