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.
@@ -0,0 +1,6 @@
1
+ .venv/
2
+ dist/
3
+ __pycache__/
4
+ *.egg-info/
5
+ .pytest_cache/
6
+ uv.lock
@@ -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