i18n-keyless 3.6.1__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.
- i18n_keyless-3.6.1/.gitignore +6 -0
- i18n_keyless-3.6.1/CHANGELOG.md +21 -0
- i18n_keyless-3.6.1/LICENSE.md +21 -0
- i18n_keyless-3.6.1/PKG-INFO +231 -0
- i18n_keyless-3.6.1/README.md +208 -0
- i18n_keyless-3.6.1/SKILL.md +107 -0
- i18n_keyless-3.6.1/llms.txt +95 -0
- i18n_keyless-3.6.1/pyproject.toml +43 -0
- i18n_keyless-3.6.1/src/i18n_keyless/__init__.py +168 -0
- i18n_keyless-3.6.1/src/i18n_keyless/client.py +605 -0
- i18n_keyless-3.6.1/src/i18n_keyless/http.py +283 -0
- i18n_keyless-3.6.1/src/i18n_keyless/langs.py +99 -0
- i18n_keyless-3.6.1/src/i18n_keyless/py.typed +0 -0
- i18n_keyless-3.6.1/src/i18n_keyless/text.py +62 -0
- i18n_keyless-3.6.1/src/i18n_keyless/version.py +8 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 3.6.1
|
|
4
|
+
|
|
5
|
+
First release of the Python port. The version tracks the JavaScript SDKs and the protocol
|
|
6
|
+
revision it implements: `docs/PROTOCOL.md` reference 3.3.0+ (i18n-keyless-core 3.6.1).
|
|
7
|
+
|
|
8
|
+
- `i18n_keyless.init()`, `t()`, `t_or_raise()`: the node SDK's behaviour in Python. One
|
|
9
|
+
in-memory store per language loaded by `GET /translate/` at init, a synchronous miss
|
|
10
|
+
that POSTs `/translate` and returns the answer (read from `data.translation.languages`),
|
|
11
|
+
concurrent misses of one key collapsed into one request, at most 30 in flight, a bulk
|
|
12
|
+
refetch of the namespaces that missed once the batch drains, ETag replay (`304` keeps the
|
|
13
|
+
store), the 10 s timeout and the 500 / 1500 ms backoff on network error, timeout, `429`,
|
|
14
|
+
`5xx` and unparsable bodies, nothing ever raised by `t()`.
|
|
15
|
+
- Usage analytics on the node rules: recorded per call, POSTed on a 10 s debounce from a
|
|
16
|
+
daemon thread, cumulative, `flush_usage()` for scripts.
|
|
17
|
+
- `sdk: python` (a server label, registered on the API), `Version: 3.6.1`, no `unique_id`.
|
|
18
|
+
- The three network modes: custom handlers, `api_url`, the official service.
|
|
19
|
+
- `resolve_lang`, `to_app_store_locale`, `AVAILABLE_LANGS`, `apply_replace`,
|
|
20
|
+
`storage_key_for`: the pure rules, replayed against every conformance vector.
|
|
21
|
+
- Zero dependency, Python >= 3.9, typed (`py.typed`).
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Arnaud Ambroselli
|
|
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,231 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: i18n-keyless
|
|
3
|
+
Version: 3.6.1
|
|
4
|
+
Summary: Keyless translations for Python servers, scripts and build steps: write the source string where a key would go, the i18n-keyless API translates it once, for every language.
|
|
5
|
+
Project-URL: Homepage, https://i18n-keyless.com
|
|
6
|
+
Project-URL: Documentation, https://docs.i18n-keyless.com
|
|
7
|
+
Project-URL: Repository, https://github.com/arnaudambro/i18n-keyless
|
|
8
|
+
Project-URL: Changelog, https://github.com/arnaudambro/i18n-keyless/blob/main/ports/python/CHANGELOG.md
|
|
9
|
+
Author-email: Arnaud Ambroselli <arnaud.ambroselli.io@gmail.com>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE.md
|
|
12
|
+
Keywords: django,fastapi,flask,i18n,keyless,l10n,localization,translation
|
|
13
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Topic :: Software Development :: Internationalization
|
|
19
|
+
Classifier: Topic :: Software Development :: Localization
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.9
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# i18n-keyless for Python
|
|
25
|
+
|
|
26
|
+
Keyless translations for a Python server, a script or a build step. Write the source string
|
|
27
|
+
where a key would go, `t("Welcome to our app", lang)`, and it resolves through the
|
|
28
|
+
i18n-keyless API: a missing string is translated by AI once, for every language, and served
|
|
29
|
+
from memory from then on. No `.po` files, no message catalogue, no key to name.
|
|
30
|
+
|
|
31
|
+
## Quick start
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install i18n-keyless # or: uv add i18n-keyless
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
import i18n_keyless as i18n
|
|
39
|
+
|
|
40
|
+
i18n.init(api_key="your-key", primary="en", supported=["en", "fr", "es"]) # once, at start
|
|
41
|
+
print(i18n.t("Welcome to our app", "fr")) # "Bienvenue dans notre application"
|
|
42
|
+
print(i18n.t("Welcome to our app", "es")) # switch language: pass another code
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Done. Run it: the first call POSTs the string to the API and returns the translation; every
|
|
46
|
+
later call, in any language, is served from memory. Python >= 3.9, no dependency.
|
|
47
|
+
|
|
48
|
+
Get a key at https://i18n-keyless.com/#get-api-key.
|
|
49
|
+
|
|
50
|
+
## How it works
|
|
51
|
+
|
|
52
|
+
1. `init()` validates the config and loads every language of the default namespace in one
|
|
53
|
+
request (`GET /translate/`), like the node SDK. Call it once, at process start. Another
|
|
54
|
+
namespace is loaded after its first miss.
|
|
55
|
+
2. `t(key, lang)` in the primary language returns the key: no lookup, no request.
|
|
56
|
+
3. In another language, a string the store has is returned at once. A string it does not
|
|
57
|
+
have is POSTed to `/translate` **synchronously** with every language of `supported`; the
|
|
58
|
+
API answers with the whole row, the answer is cached for every language, and the
|
|
59
|
+
translation is returned. The page is complete when it is served: no flash of source
|
|
60
|
+
text, no second render.
|
|
61
|
+
4. Concurrent misses of one string (N request handlers rendering the same page) share one
|
|
62
|
+
request; at most 30 requests are in flight at once, process-wide.
|
|
63
|
+
5. When the batch drains, the namespaces that missed are refetched on a daemon thread
|
|
64
|
+
(`GET /translate/`, with `If-None-Match`: an unchanged namespace costs a bodyless 304),
|
|
65
|
+
so a cell the AI filled later or a dashboard edit reaches the process.
|
|
66
|
+
6. Usage analytics, like the node SDK: the UTC date each string was last served is
|
|
67
|
+
recorded on every call and the cumulative map is POSTed at most once every 10 s from a
|
|
68
|
+
daemon thread that never keeps the process alive. It feeds the dashboard's "last used"
|
|
69
|
+
column so unused strings can be pruned. Call `flush_usage()` before a script exits.
|
|
70
|
+
|
|
71
|
+
Every request has a 10 s timeout and is retried twice with backoff (500 ms, 1500 ms) on a
|
|
72
|
+
network error, a timeout, a `429`, a `5xx` or an unparsable body. Any other `4xx` is not
|
|
73
|
+
retried. `t()` never raises: a failed request logs an error and returns the source text.
|
|
74
|
+
`t_or_raise()` is the same call for a script or a build step, where a wrong language is
|
|
75
|
+
worse than a crash: it raises `TranslationError`.
|
|
76
|
+
|
|
77
|
+
## Configuration
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
i18n.init(
|
|
81
|
+
api_key="...", # required (sent as `Authorization: Bearer`)
|
|
82
|
+
primary="en", # the language the source strings are written in
|
|
83
|
+
supported=["en", "fr", "es"], # every language the app serves; stored by the API as the project's list
|
|
84
|
+
api_url=None, # a self-hosted backend or a proxy, no trailing slash
|
|
85
|
+
default_namespace=None, # the namespace of every call that passes none
|
|
86
|
+
debug=False, # DEBUG lines on the `i18n_keyless` logger
|
|
87
|
+
on_init=None, # called once with the primary language
|
|
88
|
+
handle_translate=None, # custom handlers, see below
|
|
89
|
+
get_all_translations_for_all_languages=None,
|
|
90
|
+
send_translations_usage=None,
|
|
91
|
+
)
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
`init()` also takes a `Config` dataclass (`i18n.Config(...)`). For several projects in one
|
|
95
|
+
process, build your own client: `client = i18n.I18nKeyless(); client.init(config)`.
|
|
96
|
+
|
|
97
|
+
### The three network modes, in priority order
|
|
98
|
+
|
|
99
|
+
1. **Custom handlers.** `handle_translate(key)` replaces `POST /translate` and receives the
|
|
100
|
+
key only; it returns `{"ok": True, "data": {"translation": {"en": "...", "fr": "..."}}}`.
|
|
101
|
+
`get_all_translations_for_all_languages()` replaces `GET /translate/` and returns the
|
|
102
|
+
all-languages envelope. `send_translations_usage(default_bucket)` replaces the usage
|
|
103
|
+
POST and receives the `default` namespace's map only.
|
|
104
|
+
2. **Self-hosted**: `api_url="https://your.server"`, a backend or a proxy that speaks the
|
|
105
|
+
wire format (https://docs.i18n-keyless.com/docs/guides/proxy-mode).
|
|
106
|
+
3. **The official service**, `https://api.i18n-keyless.com`.
|
|
107
|
+
|
|
108
|
+
## Per-call options
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
i18n.t("8 hours", "fr", context="duration") # one string, two meanings: stored as "8 hours__duration"
|
|
112
|
+
i18n.t("Hello {{name}}", "fr", replace={"{{name}}": "Ada"}) # placeholders, replaced after the translation
|
|
113
|
+
i18n.t("Pay", "fr", namespace="checkout") # an i18n-keyless namespace
|
|
114
|
+
i18n.t("Hi", "fr", namespace="chat-42", unpersisted_namespace=True) # transient: never reported in usage
|
|
115
|
+
i18n.t("Hola mundo", "en", origin_language="es") # user generated content written in Spanish
|
|
116
|
+
i18n.t("Hello", "fr", force_temporary={"fr": "Salut"}) # overwrite the stored French cell, permanently
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
`replace`: every placeholder is a literal, all are replaced in one pass, and a placeholder
|
|
120
|
+
whose replacement is empty is left in place, exactly like the JavaScript SDKs. The
|
|
121
|
+
imperative function never trims the source string.
|
|
122
|
+
|
|
123
|
+
### Languages
|
|
124
|
+
|
|
125
|
+
`supported` and `lang` take the 48 codes of the API (`i18n.AVAILABLE_LANGS`):
|
|
126
|
+
`ar bn ca zh-Hans zh-Hant hr cs da nl en en-GB fi fr fr-CA de el gu he hi hu id it ja kn ko
|
|
127
|
+
ms ml mr no or pl pt pt-BR pa ro ru sk sl es es-MX sv ta te th tr uk ur vi`. To turn an
|
|
128
|
+
`Accept-Language` value or a user setting into one of them:
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
i18n.resolve_lang("pt_BR", supported=["pt", "en"], fallback="en") # "pt"
|
|
132
|
+
i18n.resolve_lang("zh-TW") # "zh-Hant"
|
|
133
|
+
i18n.to_app_store_locale("fr") # "fr-FR"
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## In a web framework
|
|
137
|
+
|
|
138
|
+
The port is framework-free: call `t()` with the request's language. `init()` runs once at
|
|
139
|
+
process start (a module import, an app factory, a `ready()` hook).
|
|
140
|
+
|
|
141
|
+
**Django**: a template tag in `yourapp/templatetags/keyless.py`, then `{% load keyless %}`
|
|
142
|
+
and `{% t "Welcome to our app" %}` in a template. The language comes from Django's own
|
|
143
|
+
`translation.get_language()`, so `LocaleMiddleware` and `?lang=` keep working.
|
|
144
|
+
|
|
145
|
+
```python
|
|
146
|
+
from django import template
|
|
147
|
+
from django.utils import translation
|
|
148
|
+
import i18n_keyless as i18n
|
|
149
|
+
|
|
150
|
+
register = template.Library()
|
|
151
|
+
|
|
152
|
+
@register.simple_tag
|
|
153
|
+
def t(text, context=None):
|
|
154
|
+
lang = i18n.resolve_lang(translation.get_language(), supported=i18n.get_supported_languages(), fallback="en")
|
|
155
|
+
return i18n.t(text, lang, context=context)
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
**Flask**:
|
|
159
|
+
|
|
160
|
+
```python
|
|
161
|
+
import i18n_keyless as i18n
|
|
162
|
+
from flask import Flask, request
|
|
163
|
+
|
|
164
|
+
app = Flask(__name__)
|
|
165
|
+
i18n.init(api_key=..., primary="en", supported=["en", "fr", "es"])
|
|
166
|
+
|
|
167
|
+
@app.get("/")
|
|
168
|
+
def home():
|
|
169
|
+
lang = i18n.resolve_lang(request.args.get("lang") or request.accept_languages.best, supported=["en", "fr", "es"], fallback="en")
|
|
170
|
+
return f"<h1>{i18n.t('Welcome to our app', lang)}</h1>"
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
**FastAPI**: the same, with `i18n.init()` in the lifespan and `t()` in a dependency that
|
|
174
|
+
resolves the language from the `Accept-Language` header. `t()` blocks for the request on a
|
|
175
|
+
miss (once per string per process): call it in a threadpool (`run_in_threadpool`) in an
|
|
176
|
+
async handler if a first render must never wait on the network.
|
|
177
|
+
|
|
178
|
+
## Self-hosted backend or proxy
|
|
179
|
+
|
|
180
|
+
Point `api_url` at a server that speaks the four-route wire format (`GET /translate/`,
|
|
181
|
+
`GET /translate/{lang}`, `POST /translate`, `POST /translate/last-used-translations`).
|
|
182
|
+
Every request carries `Content-Type: application/json`, `Authorization: Bearer <api_key>`,
|
|
183
|
+
`Version: 3.6.1` (the wire dialect: v3 language codes) and `sdk: python` (a server label,
|
|
184
|
+
counted like `node`: by its connection, never by a device id). Nothing else, no cookie.
|
|
185
|
+
|
|
186
|
+
## Limitations
|
|
187
|
+
|
|
188
|
+
- **Plurals.** One source string per form, with a `context` (`context="one"`,
|
|
189
|
+
`context="other"`): the API translates strings, not ICU messages.
|
|
190
|
+
- **Misses block the call** (once per string per process), like `awaitForTranslation` in the
|
|
191
|
+
node SDK. A miss costs one round trip, capped at 3 × 10 s.
|
|
192
|
+
- **One store per process.** Workers (gunicorn, uvicorn) each fetch the dictionaries at
|
|
193
|
+
boot; a dashboard edit reaches a process at its next refetch, after a miss.
|
|
194
|
+
- **`force_temporary` in the primary language sends nothing**, like the client SDKs (the
|
|
195
|
+
node SDK sends it). Pass another language.
|
|
196
|
+
- A source string is capped at 2000 characters (`context` and `namespace` at 200).
|
|
197
|
+
Long-form content is one translation per Markdown block:
|
|
198
|
+
https://docs.i18n-keyless.com/docs/guides/long-form-content
|
|
199
|
+
|
|
200
|
+
## Publishing to PyPI
|
|
201
|
+
|
|
202
|
+
This directory lives inside the `i18n-keyless` monorepo and is not an npm workspace. The
|
|
203
|
+
package builds from here, with the version read from `src/i18n_keyless/version.py`
|
|
204
|
+
(written by `scripts/set-version.mjs`, shared by every SDK):
|
|
205
|
+
|
|
206
|
+
```bash
|
|
207
|
+
cd ports/python
|
|
208
|
+
uv build
|
|
209
|
+
uv publish # PyPI: https://pypi.org/project/i18n-keyless/
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
## Development
|
|
213
|
+
|
|
214
|
+
```bash
|
|
215
|
+
cd ports/python
|
|
216
|
+
uv run pytest
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
Tests run on a scripted transport: no network, no key. `tests/test_vectors.py` replays
|
|
220
|
+
every file of the monorepo's shared protocol vectors (`conformance/vectors/*.json`):
|
|
221
|
+
language codes, locale resolution, storage key, `replace`, namespace resolution, retry
|
|
222
|
+
decisions, backoff scenarios, translate, dictionary and usage requests and responses, the
|
|
223
|
+
queue (dedupe and the 30-in-flight cap), the runtime label. `tests/test_integration.py`
|
|
224
|
+
drives the client end to end.
|
|
225
|
+
|
|
226
|
+
Deliberate differences from the JavaScript SDKs: a miss is translated on the spot and
|
|
227
|
+
returned (the node rule), not queued and rendered on the next pass (the client rule), so two
|
|
228
|
+
contexts of one key in one batch are two requests (the SDK queue makes one); the store has a
|
|
229
|
+
namespace dimension (the node store has none); ETags are remembered per (API key, `all`,
|
|
230
|
+
namespace). Usage analytics follow the node SDK
|
|
231
|
+
(`sdk: python` is a server label with the `node` rules).
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# i18n-keyless for Python
|
|
2
|
+
|
|
3
|
+
Keyless translations for a Python server, a script or a build step. Write the source string
|
|
4
|
+
where a key would go, `t("Welcome to our app", lang)`, and it resolves through the
|
|
5
|
+
i18n-keyless API: a missing string is translated by AI once, for every language, and served
|
|
6
|
+
from memory from then on. No `.po` files, no message catalogue, no key to name.
|
|
7
|
+
|
|
8
|
+
## Quick start
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
pip install i18n-keyless # or: uv add i18n-keyless
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
```python
|
|
15
|
+
import i18n_keyless as i18n
|
|
16
|
+
|
|
17
|
+
i18n.init(api_key="your-key", primary="en", supported=["en", "fr", "es"]) # once, at start
|
|
18
|
+
print(i18n.t("Welcome to our app", "fr")) # "Bienvenue dans notre application"
|
|
19
|
+
print(i18n.t("Welcome to our app", "es")) # switch language: pass another code
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Done. Run it: the first call POSTs the string to the API and returns the translation; every
|
|
23
|
+
later call, in any language, is served from memory. Python >= 3.9, no dependency.
|
|
24
|
+
|
|
25
|
+
Get a key at https://i18n-keyless.com/#get-api-key.
|
|
26
|
+
|
|
27
|
+
## How it works
|
|
28
|
+
|
|
29
|
+
1. `init()` validates the config and loads every language of the default namespace in one
|
|
30
|
+
request (`GET /translate/`), like the node SDK. Call it once, at process start. Another
|
|
31
|
+
namespace is loaded after its first miss.
|
|
32
|
+
2. `t(key, lang)` in the primary language returns the key: no lookup, no request.
|
|
33
|
+
3. In another language, a string the store has is returned at once. A string it does not
|
|
34
|
+
have is POSTed to `/translate` **synchronously** with every language of `supported`; the
|
|
35
|
+
API answers with the whole row, the answer is cached for every language, and the
|
|
36
|
+
translation is returned. The page is complete when it is served: no flash of source
|
|
37
|
+
text, no second render.
|
|
38
|
+
4. Concurrent misses of one string (N request handlers rendering the same page) share one
|
|
39
|
+
request; at most 30 requests are in flight at once, process-wide.
|
|
40
|
+
5. When the batch drains, the namespaces that missed are refetched on a daemon thread
|
|
41
|
+
(`GET /translate/`, with `If-None-Match`: an unchanged namespace costs a bodyless 304),
|
|
42
|
+
so a cell the AI filled later or a dashboard edit reaches the process.
|
|
43
|
+
6. Usage analytics, like the node SDK: the UTC date each string was last served is
|
|
44
|
+
recorded on every call and the cumulative map is POSTed at most once every 10 s from a
|
|
45
|
+
daemon thread that never keeps the process alive. It feeds the dashboard's "last used"
|
|
46
|
+
column so unused strings can be pruned. Call `flush_usage()` before a script exits.
|
|
47
|
+
|
|
48
|
+
Every request has a 10 s timeout and is retried twice with backoff (500 ms, 1500 ms) on a
|
|
49
|
+
network error, a timeout, a `429`, a `5xx` or an unparsable body. Any other `4xx` is not
|
|
50
|
+
retried. `t()` never raises: a failed request logs an error and returns the source text.
|
|
51
|
+
`t_or_raise()` is the same call for a script or a build step, where a wrong language is
|
|
52
|
+
worse than a crash: it raises `TranslationError`.
|
|
53
|
+
|
|
54
|
+
## Configuration
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
i18n.init(
|
|
58
|
+
api_key="...", # required (sent as `Authorization: Bearer`)
|
|
59
|
+
primary="en", # the language the source strings are written in
|
|
60
|
+
supported=["en", "fr", "es"], # every language the app serves; stored by the API as the project's list
|
|
61
|
+
api_url=None, # a self-hosted backend or a proxy, no trailing slash
|
|
62
|
+
default_namespace=None, # the namespace of every call that passes none
|
|
63
|
+
debug=False, # DEBUG lines on the `i18n_keyless` logger
|
|
64
|
+
on_init=None, # called once with the primary language
|
|
65
|
+
handle_translate=None, # custom handlers, see below
|
|
66
|
+
get_all_translations_for_all_languages=None,
|
|
67
|
+
send_translations_usage=None,
|
|
68
|
+
)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`init()` also takes a `Config` dataclass (`i18n.Config(...)`). For several projects in one
|
|
72
|
+
process, build your own client: `client = i18n.I18nKeyless(); client.init(config)`.
|
|
73
|
+
|
|
74
|
+
### The three network modes, in priority order
|
|
75
|
+
|
|
76
|
+
1. **Custom handlers.** `handle_translate(key)` replaces `POST /translate` and receives the
|
|
77
|
+
key only; it returns `{"ok": True, "data": {"translation": {"en": "...", "fr": "..."}}}`.
|
|
78
|
+
`get_all_translations_for_all_languages()` replaces `GET /translate/` and returns the
|
|
79
|
+
all-languages envelope. `send_translations_usage(default_bucket)` replaces the usage
|
|
80
|
+
POST and receives the `default` namespace's map only.
|
|
81
|
+
2. **Self-hosted**: `api_url="https://your.server"`, a backend or a proxy that speaks the
|
|
82
|
+
wire format (https://docs.i18n-keyless.com/docs/guides/proxy-mode).
|
|
83
|
+
3. **The official service**, `https://api.i18n-keyless.com`.
|
|
84
|
+
|
|
85
|
+
## Per-call options
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
i18n.t("8 hours", "fr", context="duration") # one string, two meanings: stored as "8 hours__duration"
|
|
89
|
+
i18n.t("Hello {{name}}", "fr", replace={"{{name}}": "Ada"}) # placeholders, replaced after the translation
|
|
90
|
+
i18n.t("Pay", "fr", namespace="checkout") # an i18n-keyless namespace
|
|
91
|
+
i18n.t("Hi", "fr", namespace="chat-42", unpersisted_namespace=True) # transient: never reported in usage
|
|
92
|
+
i18n.t("Hola mundo", "en", origin_language="es") # user generated content written in Spanish
|
|
93
|
+
i18n.t("Hello", "fr", force_temporary={"fr": "Salut"}) # overwrite the stored French cell, permanently
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`replace`: every placeholder is a literal, all are replaced in one pass, and a placeholder
|
|
97
|
+
whose replacement is empty is left in place, exactly like the JavaScript SDKs. The
|
|
98
|
+
imperative function never trims the source string.
|
|
99
|
+
|
|
100
|
+
### Languages
|
|
101
|
+
|
|
102
|
+
`supported` and `lang` take the 48 codes of the API (`i18n.AVAILABLE_LANGS`):
|
|
103
|
+
`ar bn ca zh-Hans zh-Hant hr cs da nl en en-GB fi fr fr-CA de el gu he hi hu id it ja kn ko
|
|
104
|
+
ms ml mr no or pl pt pt-BR pa ro ru sk sl es es-MX sv ta te th tr uk ur vi`. To turn an
|
|
105
|
+
`Accept-Language` value or a user setting into one of them:
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
i18n.resolve_lang("pt_BR", supported=["pt", "en"], fallback="en") # "pt"
|
|
109
|
+
i18n.resolve_lang("zh-TW") # "zh-Hant"
|
|
110
|
+
i18n.to_app_store_locale("fr") # "fr-FR"
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## In a web framework
|
|
114
|
+
|
|
115
|
+
The port is framework-free: call `t()` with the request's language. `init()` runs once at
|
|
116
|
+
process start (a module import, an app factory, a `ready()` hook).
|
|
117
|
+
|
|
118
|
+
**Django**: a template tag in `yourapp/templatetags/keyless.py`, then `{% load keyless %}`
|
|
119
|
+
and `{% t "Welcome to our app" %}` in a template. The language comes from Django's own
|
|
120
|
+
`translation.get_language()`, so `LocaleMiddleware` and `?lang=` keep working.
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
from django import template
|
|
124
|
+
from django.utils import translation
|
|
125
|
+
import i18n_keyless as i18n
|
|
126
|
+
|
|
127
|
+
register = template.Library()
|
|
128
|
+
|
|
129
|
+
@register.simple_tag
|
|
130
|
+
def t(text, context=None):
|
|
131
|
+
lang = i18n.resolve_lang(translation.get_language(), supported=i18n.get_supported_languages(), fallback="en")
|
|
132
|
+
return i18n.t(text, lang, context=context)
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
**Flask**:
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
import i18n_keyless as i18n
|
|
139
|
+
from flask import Flask, request
|
|
140
|
+
|
|
141
|
+
app = Flask(__name__)
|
|
142
|
+
i18n.init(api_key=..., primary="en", supported=["en", "fr", "es"])
|
|
143
|
+
|
|
144
|
+
@app.get("/")
|
|
145
|
+
def home():
|
|
146
|
+
lang = i18n.resolve_lang(request.args.get("lang") or request.accept_languages.best, supported=["en", "fr", "es"], fallback="en")
|
|
147
|
+
return f"<h1>{i18n.t('Welcome to our app', lang)}</h1>"
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
**FastAPI**: the same, with `i18n.init()` in the lifespan and `t()` in a dependency that
|
|
151
|
+
resolves the language from the `Accept-Language` header. `t()` blocks for the request on a
|
|
152
|
+
miss (once per string per process): call it in a threadpool (`run_in_threadpool`) in an
|
|
153
|
+
async handler if a first render must never wait on the network.
|
|
154
|
+
|
|
155
|
+
## Self-hosted backend or proxy
|
|
156
|
+
|
|
157
|
+
Point `api_url` at a server that speaks the four-route wire format (`GET /translate/`,
|
|
158
|
+
`GET /translate/{lang}`, `POST /translate`, `POST /translate/last-used-translations`).
|
|
159
|
+
Every request carries `Content-Type: application/json`, `Authorization: Bearer <api_key>`,
|
|
160
|
+
`Version: 3.6.1` (the wire dialect: v3 language codes) and `sdk: python` (a server label,
|
|
161
|
+
counted like `node`: by its connection, never by a device id). Nothing else, no cookie.
|
|
162
|
+
|
|
163
|
+
## Limitations
|
|
164
|
+
|
|
165
|
+
- **Plurals.** One source string per form, with a `context` (`context="one"`,
|
|
166
|
+
`context="other"`): the API translates strings, not ICU messages.
|
|
167
|
+
- **Misses block the call** (once per string per process), like `awaitForTranslation` in the
|
|
168
|
+
node SDK. A miss costs one round trip, capped at 3 × 10 s.
|
|
169
|
+
- **One store per process.** Workers (gunicorn, uvicorn) each fetch the dictionaries at
|
|
170
|
+
boot; a dashboard edit reaches a process at its next refetch, after a miss.
|
|
171
|
+
- **`force_temporary` in the primary language sends nothing**, like the client SDKs (the
|
|
172
|
+
node SDK sends it). Pass another language.
|
|
173
|
+
- A source string is capped at 2000 characters (`context` and `namespace` at 200).
|
|
174
|
+
Long-form content is one translation per Markdown block:
|
|
175
|
+
https://docs.i18n-keyless.com/docs/guides/long-form-content
|
|
176
|
+
|
|
177
|
+
## Publishing to PyPI
|
|
178
|
+
|
|
179
|
+
This directory lives inside the `i18n-keyless` monorepo and is not an npm workspace. The
|
|
180
|
+
package builds from here, with the version read from `src/i18n_keyless/version.py`
|
|
181
|
+
(written by `scripts/set-version.mjs`, shared by every SDK):
|
|
182
|
+
|
|
183
|
+
```bash
|
|
184
|
+
cd ports/python
|
|
185
|
+
uv build
|
|
186
|
+
uv publish # PyPI: https://pypi.org/project/i18n-keyless/
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
## Development
|
|
190
|
+
|
|
191
|
+
```bash
|
|
192
|
+
cd ports/python
|
|
193
|
+
uv run pytest
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Tests run on a scripted transport: no network, no key. `tests/test_vectors.py` replays
|
|
197
|
+
every file of the monorepo's shared protocol vectors (`conformance/vectors/*.json`):
|
|
198
|
+
language codes, locale resolution, storage key, `replace`, namespace resolution, retry
|
|
199
|
+
decisions, backoff scenarios, translate, dictionary and usage requests and responses, the
|
|
200
|
+
queue (dedupe and the 30-in-flight cap), the runtime label. `tests/test_integration.py`
|
|
201
|
+
drives the client end to end.
|
|
202
|
+
|
|
203
|
+
Deliberate differences from the JavaScript SDKs: a miss is translated on the spot and
|
|
204
|
+
returned (the node rule), not queued and rendered on the next pass (the client rule), so two
|
|
205
|
+
contexts of one key in one batch are two requests (the SDK queue makes one); the store has a
|
|
206
|
+
namespace dimension (the node store has none); ETags are remembered per (API key, `all`,
|
|
207
|
+
namespace). Usage analytics follow the node SDK
|
|
208
|
+
(`sdk: python` is a server label with the `node` rules).
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: i18n-keyless-python
|
|
3
|
+
description: Install and use i18n-keyless in a Python server, script or build step (Django, Flask, FastAPI, plain Python). t("Welcome to our app", lang) (the source string where a key would go) resolves through the i18n-keyless API with one package and one init() call. Use when adding, configuring or debugging translations / localization / multi-language support in a Python project, or when the project already depends on `i18n-keyless`.
|
|
4
|
+
license: MIT
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# i18n-keyless for Python
|
|
8
|
+
|
|
9
|
+
gettext takes a key and a catalogue. This package takes the source string: a
|
|
10
|
+
`t("Welcome to our app", "fr")` call is translated by AI once, for every language, cached in
|
|
11
|
+
memory, and served from there. No `.po` file, no message id, no key to name.
|
|
12
|
+
|
|
13
|
+
**Version covered: `i18n-keyless` 3.x on PyPI, Python >= 3.9, no dependency.**
|
|
14
|
+
|
|
15
|
+
## Install in one step
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pip install i18n-keyless # or: uv add i18n-keyless
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
import i18n_keyless as i18n
|
|
23
|
+
|
|
24
|
+
i18n.init(api_key=os.environ["I18N_KEYLESS_API_KEY"], primary="en", supported=["en", "fr", "es"])
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Once, at process start (a module import, the app factory, Django's `AppConfig.ready()`, the
|
|
28
|
+
FastAPI lifespan). It loads every language in one request.
|
|
29
|
+
|
|
30
|
+
## Rules
|
|
31
|
+
|
|
32
|
+
- Source strings are written in the primary language. `t("Bonjour", lang)` in a
|
|
33
|
+
French-first app, `t("Hello", lang)` in an English-first one. Never invent a key name.
|
|
34
|
+
- The language is an argument: `t(text, lang)`. Resolve it from the request with
|
|
35
|
+
`i18n.resolve_lang(tag, supported=..., fallback=...)` (`pt_BR` is `pt-BR`, `zh-TW` is
|
|
36
|
+
`zh-Hant`, `en-US` is `en`, an unknown tag is the fallback). Never pass a raw
|
|
37
|
+
`Accept-Language` value.
|
|
38
|
+
- Placeholders: write `{{name}}` (any literal) in the source string and pass
|
|
39
|
+
`replace={"{{name}}": value}`. It is applied after the translation. Do not format the
|
|
40
|
+
string before calling `t()`: a formatted string is a new key on every call.
|
|
41
|
+
- Ambiguous strings take a context: `t("8 hours", lang, context="duration")`. Stored as
|
|
42
|
+
`8 hours__duration`, the same entry the other SDKs use. `namespace=` travels the same way.
|
|
43
|
+
- Plurals: one call per form with a `context` (`context="one"`, `context="other"`).
|
|
44
|
+
- `t()` never raises: a failed request returns the source text and logs an error on the
|
|
45
|
+
`i18n_keyless` logger. `t_or_raise()` raises `TranslationError` instead: use it in a
|
|
46
|
+
script or a build step, never in a request handler.
|
|
47
|
+
- A miss blocks the call once per string per process (one round trip, 10 s timeout, two
|
|
48
|
+
retries). In an async handler that must never wait on the network, run `t()` in a
|
|
49
|
+
threadpool.
|
|
50
|
+
- Always pass the full list of served languages in `supported`: a new string is
|
|
51
|
+
translated into all of them, and the API stores the list as the project's languages
|
|
52
|
+
(it replaces the previous one).
|
|
53
|
+
- Every request carries `Authorization: Bearer`, `Version: 3.6.1` and `sdk: python` (a
|
|
54
|
+
server label: counted by connection, no device id, usage analytics like the node SDK).
|
|
55
|
+
- Usage analytics are on, like the node SDK: one `POST /translate/last-used-translations`
|
|
56
|
+
at most every 10 s from a daemon thread. A script that exits sooner calls
|
|
57
|
+
`i18n.flush_usage()` first.
|
|
58
|
+
- A source string is capped at 2000 characters (`context` and `namespace` at 200). Long-form
|
|
59
|
+
content is allowed, but a blog post is one translation **per Markdown block** of about 1000
|
|
60
|
+
characters: keep the Markdown inside each block, give every block of the document the same
|
|
61
|
+
`context` (one very short summary of it) and one `namespace` per document.
|
|
62
|
+
https://docs.i18n-keyless.com/docs/guides/long-form-content
|
|
63
|
+
|
|
64
|
+
## Configuration
|
|
65
|
+
|
|
66
|
+
`i18n.init(api_key, primary, supported, api_url=None, default_namespace=None, debug=False,
|
|
67
|
+
on_init=None, handle_translate=None, get_all_translations_for_all_languages=None,
|
|
68
|
+
send_translations_usage=None)`. Or `i18n.init(i18n.Config(...))`. Several projects in one
|
|
69
|
+
process: `client = i18n.I18nKeyless(); client.init(config); client.t(...)`.
|
|
70
|
+
|
|
71
|
+
Three network modes, in priority order: the custom handlers, then `api_url` (a self-hosted
|
|
72
|
+
backend or a proxy), then the official service. `api_key` is sent in every mode.
|
|
73
|
+
|
|
74
|
+
## Frameworks
|
|
75
|
+
|
|
76
|
+
- **Django**: a `simple_tag` in `templatetags/` that calls `i18n.t(text, lang, context=...)`
|
|
77
|
+
with `lang = resolve_lang(translation.get_language(), ...)`; `{% load keyless %}` then
|
|
78
|
+
`{% t "Welcome to our app" %}`. `LocaleMiddleware` keeps choosing the language.
|
|
79
|
+
- **Flask**: `i18n.init()` next to `Flask(__name__)`; in a view,
|
|
80
|
+
`lang = resolve_lang(request.args.get("lang") or request.accept_languages.best, ...)`.
|
|
81
|
+
- **FastAPI**: `i18n.init()` in the lifespan; a dependency resolves the language from the
|
|
82
|
+
`Accept-Language` header; `await run_in_threadpool(i18n.t, text, lang)` in an async route.
|
|
83
|
+
|
|
84
|
+
## Debug
|
|
85
|
+
|
|
86
|
+
- `t()` returns the source text in a non-primary language: check `api_key`, that `lang` is
|
|
87
|
+
one of the 48 codes and is in `supported`, and the `i18n_keyless` logger for lines
|
|
88
|
+
starting with `i18n-keyless:` (set `debug=True` for one line per resolution).
|
|
89
|
+
- `t()` returns the source text before `init()`: one warning is logged; call `init()` at
|
|
90
|
+
process start.
|
|
91
|
+
- A translation does not update after a dashboard edit: the process holds its dictionary
|
|
92
|
+
until the next refetch, which follows a miss. Restart the process, or make one call for
|
|
93
|
+
a new string.
|
|
94
|
+
- Every call POSTs the same string: the answer had no text for `lang` (the AI failed for
|
|
95
|
+
that cell, or `lang` is not in `supported`). Check the dashboard row.
|
|
96
|
+
|
|
97
|
+
## Offline try-out
|
|
98
|
+
|
|
99
|
+
Run `examples/_mock-server` (`node server.mjs`, port 8787) and
|
|
100
|
+
`i18n.init(api_key="demo", api_url="http://localhost:8787", primary="fr", supported=["fr", "en", "es"])`.
|
|
101
|
+
See `examples/python/README.md`.
|
|
102
|
+
|
|
103
|
+
## Go deeper
|
|
104
|
+
|
|
105
|
+
- Package README: `ports/python/README.md`
|
|
106
|
+
- The whole i18n-keyless documentation as one file: https://docs.i18n-keyless.com/llms.txt
|
|
107
|
+
- Dashboard: https://i18n-keyless.com/dashboard
|