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
!rand:.2fformatting 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 = truein 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 keepngettext,pgettext, andnpgettextin 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
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
2ec0de316b22fc06a0d7f21e18ddce7445b612e52fabe1613112613a81e844be
|
|
| MD5 |
5d24b28fb88a77c6006233b48d041eb0
|
|
| BLAKE2b-256 |
af70613f528dad5c505981ac6b39c42f838ddbaad74eb61c942081478c40889e
|
Provenance
The following attestation bundles were made for gettext_tstrings-0.1.0a1.tar.gz:
Publisher:
release.yml on yhay81/gettext-tstrings
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
gettext_tstrings-0.1.0a1.tar.gz -
Subject digest:
2ec0de316b22fc06a0d7f21e18ddce7445b612e52fabe1613112613a81e844be - Sigstore transparency entry: 2262191313
- Sigstore integration time:
-
Permalink:
yhay81/gettext-tstrings@529dcba36bf462fbf65e3f82ec01e2e630d28dc8 -
Branch / Tag:
refs/tags/v0.1.0a1 - Owner: https://github.com/yhay81
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@529dcba36bf462fbf65e3f82ec01e2e630d28dc8 -
Trigger Event:
push
-
Statement type:
File details
Details for the file gettext_tstrings-0.1.0a1-py3-none-any.whl.
File metadata
- Download URL: gettext_tstrings-0.1.0a1-py3-none-any.whl
- Upload date:
- Size: 18.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
1f6df8c5a383d614ce5fed0120e48bdf16157da501e7a25c41cfe50aa45f842c
|
|
| MD5 |
7a5391636224d7cb94351139b3393967
|
|
| BLAKE2b-256 |
7b181d0038ff58e8efca4f27043d95523b184652c6aa889ebaa5af570e94bdfc
|
Provenance
The following attestation bundles were made for gettext_tstrings-0.1.0a1-py3-none-any.whl:
Publisher:
release.yml on yhay81/gettext-tstrings
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
gettext_tstrings-0.1.0a1-py3-none-any.whl -
Subject digest:
1f6df8c5a383d614ce5fed0120e48bdf16157da501e7a25c41cfe50aa45f842c - Sigstore transparency entry: 2262191539
- Sigstore integration time:
-
Permalink:
yhay81/gettext-tstrings@529dcba36bf462fbf65e3f82ec01e2e630d28dc8 -
Branch / Tag:
refs/tags/v0.1.0a1 - Owner: https://github.com/yhay81
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@529dcba36bf462fbf65e3f82ec01e2e630d28dc8 -
Trigger Event:
push
-
Statement type: