gettext-tstrings 0.1.0a1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. gettext_tstrings-0.1.0a1/.github/workflows/ci.yml +44 -0
  2. gettext_tstrings-0.1.0a1/.github/workflows/release.yml +52 -0
  3. gettext_tstrings-0.1.0a1/.gitignore +11 -0
  4. gettext_tstrings-0.1.0a1/.python-version +1 -0
  5. gettext_tstrings-0.1.0a1/CHANGELOG.md +26 -0
  6. gettext_tstrings-0.1.0a1/CONTRIBUTING.md +42 -0
  7. gettext_tstrings-0.1.0a1/LICENSE +21 -0
  8. gettext_tstrings-0.1.0a1/PKG-INFO +346 -0
  9. gettext_tstrings-0.1.0a1/README.md +325 -0
  10. gettext_tstrings-0.1.0a1/RELEASING.md +34 -0
  11. gettext_tstrings-0.1.0a1/SPEC.md +126 -0
  12. gettext_tstrings-0.1.0a1/babel.cfg +2 -0
  13. gettext_tstrings-0.1.0a1/benchmarks/runtime.py +66 -0
  14. gettext_tstrings-0.1.0a1/examples/app.py +41 -0
  15. gettext_tstrings-0.1.0a1/pyproject.toml +75 -0
  16. gettext_tstrings-0.1.0a1/src/gettext_tstrings/__init__.py +43 -0
  17. gettext_tstrings-0.1.0a1/src/gettext_tstrings/_patterns.py +93 -0
  18. gettext_tstrings-0.1.0a1/src/gettext_tstrings/checkers.py +42 -0
  19. gettext_tstrings-0.1.0a1/src/gettext_tstrings/core.py +593 -0
  20. gettext_tstrings-0.1.0a1/src/gettext_tstrings/errors.py +13 -0
  21. gettext_tstrings-0.1.0a1/src/gettext_tstrings/extract.py +327 -0
  22. gettext_tstrings-0.1.0a1/src/gettext_tstrings/lazy.py +57 -0
  23. gettext_tstrings-0.1.0a1/src/gettext_tstrings/py.typed +1 -0
  24. gettext_tstrings-0.1.0a1/tests/conftest.py +25 -0
  25. gettext_tstrings-0.1.0a1/tests/fixtures/locales/ja/LC_MESSAGES/messages.po +28 -0
  26. gettext_tstrings-0.1.0a1/tests/fixtures/locales/ru/LC_MESSAGES/messages.po +16 -0
  27. gettext_tstrings-0.1.0a1/tests/test_checkers.py +91 -0
  28. gettext_tstrings-0.1.0a1/tests/test_core.py +371 -0
  29. gettext_tstrings-0.1.0a1/tests/test_extract.py +273 -0
  30. gettext_tstrings-0.1.0a1/tests/test_gettext_integration.py +72 -0
  31. gettext_tstrings-0.1.0a1/tests/test_runtime_binding.py +112 -0
  32. gettext_tstrings-0.1.0a1/uv.lock +315 -0
@@ -0,0 +1,44 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ pull_request:
6
+
7
+ permissions:
8
+ contents: read
9
+
10
+ jobs:
11
+ test:
12
+ strategy:
13
+ fail-fast: false
14
+ matrix:
15
+ os: [ubuntu-latest, macos-latest, windows-latest]
16
+ runs-on: ${{ matrix.os }}
17
+ steps:
18
+ - uses: actions/checkout@v5
19
+ - uses: actions/setup-python@v6
20
+ with:
21
+ python-version: "3.14"
22
+ - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
23
+ with:
24
+ enable-cache: true
25
+ - run: uv sync --locked
26
+ - run: uv run ruff format --check .
27
+ - run: uv run ruff check .
28
+ - run: uv run mypy
29
+ - run: uv run pytest --cov=gettext_tstrings --cov-report=term-missing
30
+ - run: uv build
31
+ - name: Smoke test the built wheel in a clean environment
32
+ shell: bash
33
+ run: |
34
+ uv venv smoke
35
+ if [ -x smoke/bin/python ]; then PY=smoke/bin/python; else PY=smoke/Scripts/python.exe; fi
36
+ uv pip install --python "$PY" dist/*.whl
37
+ cat > smoke_check.py <<'EOF'
38
+ import gettext_tstrings
39
+
40
+ name = "World"
41
+ assert gettext_tstrings.tr(t"Hello {name}") == "Hello World"
42
+ print("smoke OK", gettext_tstrings.__version__)
43
+ EOF
44
+ "$PY" smoke_check.py
@@ -0,0 +1,52 @@
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ tags: ["v*"]
6
+
7
+ permissions:
8
+ contents: read
9
+
10
+ jobs:
11
+ build:
12
+ runs-on: ubuntu-latest
13
+ steps:
14
+ - uses: actions/checkout@v5
15
+ - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
16
+ - name: Check tag matches package version
17
+ run: |
18
+ VERSION=$(grep -oE '__version__ = "[^"]+"' src/gettext_tstrings/__init__.py | cut -d'"' -f2)
19
+ test "$GITHUB_REF_NAME" = "v$VERSION" || {
20
+ echo "tag $GITHUB_REF_NAME does not match __version__ $VERSION" >&2
21
+ exit 1
22
+ }
23
+ - run: uv build
24
+ - name: Smoke test the built wheel in a clean environment
25
+ run: |
26
+ uv venv smoke --python 3.14
27
+ uv pip install --python smoke/bin/python dist/*.whl
28
+ cat > smoke_check.py <<'EOF'
29
+ import gettext_tstrings
30
+
31
+ name = "World"
32
+ assert gettext_tstrings.tr(t"Hello {name}") == "Hello World"
33
+ print("smoke OK", gettext_tstrings.__version__)
34
+ EOF
35
+ smoke/bin/python smoke_check.py
36
+ - uses: actions/upload-artifact@v4
37
+ with:
38
+ name: dist
39
+ path: dist/
40
+
41
+ publish:
42
+ needs: build
43
+ runs-on: ubuntu-latest
44
+ environment: pypi
45
+ permissions:
46
+ id-token: write
47
+ steps:
48
+ - uses: actions/download-artifact@v4
49
+ with:
50
+ name: dist
51
+ path: dist/
52
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,11 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .coverage
5
+ .mypy_cache/
6
+ .pytest_cache/
7
+ .ruff_cache/
8
+ .venv/
9
+ build/
10
+ dist/
11
+ *.mo
@@ -0,0 +1 @@
1
+ 3.14
@@ -0,0 +1,26 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0a1 - 2026-07-28
4
+
5
+ First public release.
6
+
7
+ - Add safe `tr()` and `ntr()` runtimes for Python t-strings.
8
+ - Add a Babel extractor that coexists with ordinary gettext calls.
9
+ - Add strict placeholder validation as a Babel checker.
10
+ - Preserve source-controlled conversions and format specifications.
11
+ - Add canonical `gettext`, `ngettext`, `pgettext`, and `npgettext` APIs.
12
+ - Cache static template and translated-pattern plans on the runtime hot path.
13
+ - Support branch-specific plural placeholders and safe translated repetition.
14
+ - Lenient runtime rendering: a catalog whose placeholders do not match the source falls back to the source text instead of raising; `strict=True` restores the raising behavior (on `Translator` and every module function).
15
+ - Guard the empty-msgid trap so `t""` renders as `""` instead of returning the catalog header.
16
+ - Extraction does not abort the whole run on one rejected t-string or one unparsable file; such calls warn and are skipped, with an opt-in `strict` extractor option.
17
+ - Emit simple t-string messages with no funcname so extraction works under custom Babel keyword sets (e.g. `--no-default-keywords -k tr`); plural/contextual messages without the standard gettext keywords are skipped with a warning.
18
+ - Add context-scoped current translations (`use_translations`, `set_translations`, `get_translations`) for per-request language selection.
19
+ - Add deferred translation (`lazy_gettext`, `lazy_pgettext`, `LazyString`) for module-level translatable strings, extracted by default with an example-driven extraction-to-runtime round-trip test.
20
+ - Make `LazyString` unhashable: its rendered text depends on the active language, so a hash would silently break sets and dict keys across switches.
21
+ - Make the `Translations` protocol `runtime_checkable`.
22
+ - Improve confusable-placeholder diagnostics with escaped names.
23
+ - Document the t-string→msgid convention as a versioned contract (SPEC.md).
24
+ - State the measured sub-microsecond overhead instead of claiming "fast".
25
+ - Run CI on Linux, macOS, and Windows, and smoke-test the built wheel in a clean environment.
26
+ - Add a tag-triggered release workflow using PyPI Trusted Publishing (see RELEASING.md).
@@ -0,0 +1,42 @@
1
+ # Contributing
2
+
3
+ Thanks for helping improve `gettext-tstrings`. The project is deliberately small:
4
+ it is the connection convention between Python t-strings and gettext, not a new
5
+ i18n framework. Changes are judged against that scope.
6
+
7
+ ## The convention comes first
8
+
9
+ [SPEC.md](SPEC.md) defines the t-string→msgid derivation and the translation
10
+ validation rules as a versioned contract. Any change to how a msgid is derived,
11
+ or to which translations are accepted or rejected, is a change to the spec:
12
+ update `SPEC.md` in the same pull request and explain why the version does or
13
+ does not need to increment.
14
+
15
+ ## Development
16
+
17
+ The project uses [uv](https://docs.astral.sh/uv/) and requires Python 3.14+.
18
+
19
+ ```console
20
+ uv sync
21
+ uv run ruff format .
22
+ uv run ruff check .
23
+ uv run mypy
24
+ uv run pytest --cov=gettext_tstrings --cov-report=term-missing
25
+ ```
26
+
27
+ All five must pass, matching CI. New behavior needs tests, and behavior that
28
+ touches the spec needs tests that read like executable examples of the rule.
29
+
30
+ ## Guidelines
31
+
32
+ - **Keep it narrow.** Prefer the simplest change that closes a concrete gap
33
+ backed by a real failure, test, or user need. New configuration, layers, or
34
+ branches need a strong justification.
35
+ - **Never evaluate a translation.** The safety guarantee — no evaluation, no
36
+ attribute access, no calls, no translation-side formatting — is the core of
37
+ the project. Code that renders catalog data must go through the existing
38
+ validated path.
39
+ - **Match the surrounding code.** Docstrings and comments in this repository are
40
+ written in Japanese; follow that convention.
41
+ - **gettext parity.** The public API mirrors stdlib gettext naming and calling
42
+ conventions; new surface should extend that, not diverge from it.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Yusuke Hayashi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,346 @@
1
+ Metadata-Version: 2.4
2
+ Name: gettext-tstrings
3
+ Version: 0.1.0a1
4
+ Summary: Safe gettext and Babel integration for Python t-strings
5
+ Project-URL: Homepage, https://github.com/yhay81/gettext-tstrings
6
+ Project-URL: Issues, https://github.com/yhay81/gettext-tstrings/issues
7
+ Author-email: Yusuke Hayashi <yusuke8h@gmail.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: babel,gettext,i18n,internationalization,t-strings
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.14
17
+ Classifier: Topic :: Software Development :: Internationalization
18
+ Requires-Python: >=3.14
19
+ Requires-Dist: babel>=2.18
20
+ Description-Content-Type: text/markdown
21
+
22
+ # gettext-tstrings
23
+
24
+ Safe gettext integration for Python 3.14+ t-strings, with first-class
25
+ [Babel](https://babel.pocoo.org/) extraction and validation.
26
+
27
+ ```python
28
+ import gettext
29
+
30
+ from gettext_tstrings import Translator
31
+
32
+ _ = Translator(gettext.translation("messages", localedir="locales"))
33
+ name = "Ada"
34
+ print(_(t"Hello {name}"))
35
+ ```
36
+
37
+ The catalog receives the complete sentence `Hello {name}`. A translation may
38
+ reorder or repeat `{name}`, while the runtime rejects missing, unknown, or
39
+ modified placeholders.
40
+
41
+ ## Why
42
+
43
+ Python t-strings preserve the static text, evaluated values, source expressions,
44
+ conversions, and format specifications separately. That makes them a useful
45
+ boundary for internationalization, but gettext and Babel do not define how a
46
+ t-string becomes a catalog message.
47
+
48
+ `gettext-tstrings` makes one deliberately narrow choice:
49
+
50
+ - translate complete messages, never sentence fragments;
51
+ - accept only simple variable names such as `{name}`;
52
+ - keep `!r` and `:.2f` formatting under application control;
53
+ - let translators reorder and repeat known placeholders, but not execute
54
+ attribute access or add formatting behavior;
55
+ - reuse normal POT, PO, and MO files.
56
+
57
+ ## Install
58
+
59
+ ```console
60
+ python -m pip install gettext-tstrings
61
+ ```
62
+
63
+ Python 3.14 or newer is required.
64
+
65
+ ## Pythonic runtime API
66
+
67
+ The recommended API mirrors gettext's class-based usage. Bind a standard
68
+ translation object once and use the callable processor as `_`:
69
+
70
+ ```python
71
+ import gettext
72
+
73
+ from gettext_tstrings import Translator
74
+
75
+ translations = gettext.translation(
76
+ "messages",
77
+ localedir="locales",
78
+ languages=["ja"],
79
+ )
80
+
81
+ _ = Translator(translations)
82
+
83
+ name = "Ada"
84
+ print(_(t"Hello {name}"))
85
+
86
+ n = 3
87
+ print(_.ngettext(t"One file", t"{n} files", n))
88
+
89
+ filename = "report.txt"
90
+ print(_.pgettext("button", t"Open {filename}"))
91
+ ```
92
+
93
+ The module-level API follows the standard library names and positional-only
94
+ calling convention:
95
+
96
+ ```python
97
+ from gettext_tstrings import gettext, ngettext, npgettext, pgettext
98
+
99
+ gettext(t"Hello {name}", translations=translations)
100
+ ngettext(t"One file", t"{n} files", n, translations=translations)
101
+ pgettext("button", t"Open {filename}", translations=translations)
102
+ npgettext(
103
+ "inbox",
104
+ t"One message",
105
+ t"{n} messages",
106
+ n,
107
+ translations=translations,
108
+ )
109
+ ```
110
+
111
+ `tr` and `ntr` are exact aliases of `gettext` and `ngettext`. When an explicit
112
+ translation object is omitted, module-level functions use the translations bound
113
+ to the current context (see [Per-request language](#per-request-language)), and
114
+ otherwise fall back to the standard library's globally installed gettext
115
+ functions.
116
+
117
+ Plural branches may expose different values. This common form is valid:
118
+
119
+ ```python
120
+ ngettext(t"One file", t"{n} files", n)
121
+ ```
122
+
123
+ Fields present in both source branches are required in every translated plural
124
+ form. A field present in only one branch is available but optional, allowing a
125
+ language's plural rules to differ from the source language.
126
+
127
+ Conversions and format specs stay outside the catalog:
128
+
129
+ ```python
130
+ amount = 1234.5
131
+ tr(t"Total: {amount:,.2f}")
132
+ # msgid: "Total: {amount}"
133
+ ```
134
+
135
+ ## Per-request language
136
+
137
+ Web frameworks pick a language per request. Bind the request's translations to
138
+ the current context and the module-level functions (and any `_` that calls them)
139
+ resolve to that language, safely across concurrent requests:
140
+
141
+ ```python
142
+ from gettext_tstrings import tr, use_translations
143
+
144
+
145
+ def handle(request):
146
+ translations = load_translations(request.locale) # your gettext.translation(...)
147
+ with use_translations(translations):
148
+ return render(tr(t"Hello {name}"))
149
+ ```
150
+
151
+ `set_translations(translations)` binds without a `with` block (for frameworks
152
+ that manage the request lifecycle themselves), and `get_translations()` reads
153
+ the current binding. An explicit `translations=` argument always wins over the
154
+ context. A bound `Translator` is the alternative when you prefer to thread one
155
+ object through your call sites explicitly.
156
+
157
+ ## Deferred (lazy) translation
158
+
159
+ A t-string captures its values eagerly, which is wrong for a string defined at
160
+ import time — a form label, an enum value, a module constant — that must render
161
+ in whatever language is active when it is *used*. `lazy_gettext` defers the
162
+ catalog lookup and rendering to first use, resolving the current context:
163
+
164
+ ```python
165
+ from gettext_tstrings import lazy_gettext, lazy_pgettext, use_translations
166
+
167
+ SAVE = lazy_gettext(t"Save changes") # defined once, at import
168
+ OPEN = lazy_pgettext("button", t"Open file")
169
+
170
+ with use_translations(japanese):
171
+ assert str(SAVE) == "変更を保存" # rendered here, in this language
172
+ ```
173
+
174
+ A `LazyString` renders through `str()`, `format()`, and f-strings, and compares
175
+ equal to its rendered text. Plural forms depend on a runtime count, so render
176
+ those eagerly with `ngettext` where the count is known.
177
+
178
+ ## Broken catalogs never crash a render
179
+
180
+ If a translation's placeholders do not match the source — a missing, unknown, or
181
+ reformatted field slipping past validation (a hand-edited MO, a vendor catalog,
182
+ a pipeline that skips the checker) — the default is to reproduce the source text
183
+ rather than raise, mirroring gettext's contract that a bad catalog never breaks
184
+ the application. A warning is logged on the `gettext_tstrings` logger.
185
+
186
+ Opt into fail-loud behavior for tests and CI:
187
+
188
+ ```python
189
+ _ = Translator(translations, strict=True) # raises InvalidTranslationError
190
+ tr(t"Hello {name}", translations=translations, strict=True)
191
+ ```
192
+
193
+ ## Extract with Babel
194
+
195
+ Create `babel.cfg`:
196
+
197
+ ```ini
198
+ [gettext_tstrings: **.py]
199
+ encoding = utf-8
200
+ ```
201
+
202
+ Then use the normal Babel workflow:
203
+
204
+ ```console
205
+ pybabel extract -F babel.cfg -o locales/messages.pot .
206
+ pybabel init -i locales/messages.pot -d locales -l ja
207
+ pybabel compile -d locales
208
+ ```
209
+
210
+ The `gettext_tstrings` extractor also extracts ordinary `_()`, `gettext()`,
211
+ and `ngettext()` calls, so one mapping can cover mixed codebases.
212
+
213
+ The extractor recognizes `_()`, the four standard gettext names, the
214
+ `tr()` / `ntr()` aliases, and the deferred `lazy_gettext()` / `lazy_pgettext()`.
215
+ Additional aliases can be configured:
216
+
217
+ ```ini
218
+ [gettext_tstrings: **.py]
219
+ tr_functions = tr, translate
220
+ ntr_functions = ntr, pluralize
221
+ gettext_functions = gettext, _, lazy_gettext
222
+ ngettext_functions = ngettext
223
+ pgettext_functions = pgettext, lazy_pgettext
224
+ npgettext_functions = npgettext
225
+ ```
226
+
227
+ All `Translator` methods are recognized regardless of the variable name.
228
+ Callable processors named `_` are recognized by default; add another callable
229
+ variable name to `gettext_functions` if needed.
230
+
231
+ ### Registering t-string functions
232
+
233
+ t-string calls are recognized through the `*_functions` mapping options above,
234
+ **not** through Babel's `-k`/`--keyword` flag. A t-string literal cannot be read
235
+ by Babel's built-in keyword machinery, so a custom helper such as `mytr(t"...")`
236
+ must be listed in `tr_functions` (or the matching option) — `-k mytr` alone will
237
+ not extract it. The `-k` flag continues to work for ordinary (non-t-string)
238
+ gettext calls, which are extracted alongside.
239
+
240
+ Only the standard gettext argument order is supported (message first; context
241
+ then message for `pgettext`; context, singular, plural for `npgettext`). Wrappers
242
+ with non-standard argument positions are not configurable.
243
+
244
+ ### Robust by default
245
+
246
+ - A t-string the extractor rejects (attribute access, an expression, a wrong
247
+ argument) is **warned about and skipped**; it never aborts extraction of the
248
+ rest of the project. An unparsable file is skipped the same way. Set
249
+ `strict = true` in the mapping to fail the run instead.
250
+ - Simple messages extract under **any** keyword set, including
251
+ `pybabel extract --no-default-keywords -k tr`. Plural and contextual messages
252
+ need Babel's canonical keyword specs, so keep `ngettext`, `pgettext`, and
253
+ `npgettext` in the keyword set (the default set already has them); otherwise
254
+ those messages are skipped with a warning.
255
+
256
+ Translator comments work as usual:
257
+
258
+ ```python
259
+ # Translators: Product name shown in the account header.
260
+ tr(t"Welcome, {product_name}")
261
+ ```
262
+
263
+ Every extracted t-string message carries an automatic `gettext-tstrings`
264
+ comment. The installed Babel checker uses that marker to reject incompatible
265
+ placeholders and translation-controlled formatting during catalog validation
266
+ and compilation.
267
+
268
+ ## Performance
269
+
270
+ The overhead is sub-microsecond per call: roughly 0.7 µs for a one-field
271
+ message on Apple Silicon, a few times a plain `gettext(...).format(...)`. That
272
+ difference buys placeholder validation, safe rendering, and the structural
273
+ guarantees above — this library optimizes for safety per nanosecond, not for
274
+ beating `str.format`.
275
+
276
+ The runtime separates static structure from dynamic values as intended by
277
+ PEP 750:
278
+
279
+ - template plans are cached by static strings, expressions, conversions, and
280
+ format specifications;
281
+ - translated brace patterns are parsed and validated once;
282
+ - both caches are bounded and never retain interpolated values;
283
+ - each distinct value is formatted at most once per render, even when a
284
+ translation repeats its placeholder;
285
+ - one-field and constant messages use specialized rendering paths.
286
+
287
+ Run the reproducible microbenchmark on your target interpreter and hardware:
288
+
289
+ ```console
290
+ uv run python benchmarks/runtime.py
291
+ ```
292
+
293
+ ## Safety and scope
294
+
295
+ This is valid:
296
+
297
+ ```python
298
+ tr(t"Hello {name}")
299
+ ```
300
+
301
+ These are intentionally rejected:
302
+
303
+ ```python
304
+ tr(t"Hello {user.name}") # attribute access
305
+ tr(t"Hello {display_name()}") # function call
306
+ ```
307
+
308
+ Compute a meaningful value first:
309
+
310
+ ```python
311
+ name = user.display_name()
312
+ tr(t"Hello {name}")
313
+ ```
314
+
315
+ This restriction produces stable catalog keys, gives translators useful names,
316
+ and prevents translated strings from becoming an expression language.
317
+
318
+ The safety guarantee is scoped to *structure and formatting*: a translation is
319
+ never evaluated, and can never add attribute access, calls, conversions, or
320
+ format specs. Two things stay the caller's responsibility, exactly as with
321
+ stdlib gettext: **escaping** rendered output for its sink (HTML, shell,
322
+ terminal), and **catalog integrity** — a hostile catalog can repeat a
323
+ placeholder to amplify output size, which is inherent to any placeholder-based
324
+ i18n and is not bounded here.
325
+
326
+ ## Templates and other tools
327
+
328
+ t-strings are Python syntax, so this library covers Python source. Template
329
+ languages (Jinja2, Django templates) keep using their own `{% trans %}` /
330
+ `{{ _(...) }}` i18n and Babel's template extractors; both feed the **same** PO
331
+ catalog, so one translation workflow covers a mixed codebase.
332
+
333
+ `pygettext` cannot parse t-strings today, so extraction goes through Babel. The
334
+ t-string→msgid convention is written down as a small, versioned contract in
335
+ [SPEC.md](SPEC.md) so that other extractors, IDEs, type checkers, or a future
336
+ `pygettext` can target it.
337
+
338
+ ## Status
339
+
340
+ The project is an alpha. Its core contract is small on purpose; the
341
+ [specification](SPEC.md) is the stable reference. Before a stable release it will
342
+ add broader language fixtures, sustained performance tracking, API review from
343
+ gettext/Babel users, and compatibility testing against every supported Python
344
+ and Babel release.
345
+
346
+ Contributions are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md).