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.
- gettext_tstrings-0.1.0a1/.github/workflows/ci.yml +44 -0
- gettext_tstrings-0.1.0a1/.github/workflows/release.yml +52 -0
- gettext_tstrings-0.1.0a1/.gitignore +11 -0
- gettext_tstrings-0.1.0a1/.python-version +1 -0
- gettext_tstrings-0.1.0a1/CHANGELOG.md +26 -0
- gettext_tstrings-0.1.0a1/CONTRIBUTING.md +42 -0
- gettext_tstrings-0.1.0a1/LICENSE +21 -0
- gettext_tstrings-0.1.0a1/PKG-INFO +346 -0
- gettext_tstrings-0.1.0a1/README.md +325 -0
- gettext_tstrings-0.1.0a1/RELEASING.md +34 -0
- gettext_tstrings-0.1.0a1/SPEC.md +126 -0
- gettext_tstrings-0.1.0a1/babel.cfg +2 -0
- gettext_tstrings-0.1.0a1/benchmarks/runtime.py +66 -0
- gettext_tstrings-0.1.0a1/examples/app.py +41 -0
- gettext_tstrings-0.1.0a1/pyproject.toml +75 -0
- gettext_tstrings-0.1.0a1/src/gettext_tstrings/__init__.py +43 -0
- gettext_tstrings-0.1.0a1/src/gettext_tstrings/_patterns.py +93 -0
- gettext_tstrings-0.1.0a1/src/gettext_tstrings/checkers.py +42 -0
- gettext_tstrings-0.1.0a1/src/gettext_tstrings/core.py +593 -0
- gettext_tstrings-0.1.0a1/src/gettext_tstrings/errors.py +13 -0
- gettext_tstrings-0.1.0a1/src/gettext_tstrings/extract.py +327 -0
- gettext_tstrings-0.1.0a1/src/gettext_tstrings/lazy.py +57 -0
- gettext_tstrings-0.1.0a1/src/gettext_tstrings/py.typed +1 -0
- gettext_tstrings-0.1.0a1/tests/conftest.py +25 -0
- gettext_tstrings-0.1.0a1/tests/fixtures/locales/ja/LC_MESSAGES/messages.po +28 -0
- gettext_tstrings-0.1.0a1/tests/fixtures/locales/ru/LC_MESSAGES/messages.po +16 -0
- gettext_tstrings-0.1.0a1/tests/test_checkers.py +91 -0
- gettext_tstrings-0.1.0a1/tests/test_core.py +371 -0
- gettext_tstrings-0.1.0a1/tests/test_extract.py +273 -0
- gettext_tstrings-0.1.0a1/tests/test_gettext_integration.py +72 -0
- gettext_tstrings-0.1.0a1/tests/test_runtime_binding.py +112 -0
- 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 @@
|
|
|
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).
|