localizer-py 0.5.0__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.
- localizer_py-0.5.0/.gitignore +13 -0
- localizer_py-0.5.0/LICENSE +35 -0
- localizer_py-0.5.0/PKG-INFO +153 -0
- localizer_py-0.5.0/README.md +123 -0
- localizer_py-0.5.0/localizer/__init__.py +28 -0
- localizer_py-0.5.0/localizer/_api.py +216 -0
- localizer_py-0.5.0/localizer/_catalog.py +91 -0
- localizer_py-0.5.0/localizer/_dump.py +116 -0
- localizer_py-0.5.0/localizer/_engine.py +333 -0
- localizer_py-0.5.0/localizer/_format.py +602 -0
- localizer_py-0.5.0/localizer/_hooks.py +98 -0
- localizer_py-0.5.0/localizer/_hooks_argparse.py +123 -0
- localizer_py-0.5.0/localizer/_hooks_click.py +313 -0
- localizer_py-0.5.0/localizer/_hooks_typer.py +101 -0
- localizer_py-0.5.0/localizer/_locale.py +292 -0
- localizer_py-0.5.0/localizer/_version.py +24 -0
- localizer_py-0.5.0/localizer/builtin/__init__.py +46 -0
- localizer_py-0.5.0/localizer/builtin/_keys.json +661 -0
- localizer_py-0.5.0/localizer/builtin/de.json +140 -0
- localizer_py-0.5.0/localizer/builtin/es.json +140 -0
- localizer_py-0.5.0/localizer/builtin/fr.json +140 -0
- localizer_py-0.5.0/localizer/builtin/ja.json +140 -0
- localizer_py-0.5.0/localizer/builtin/ko.json +140 -0
- localizer_py-0.5.0/localizer/builtin/pt-BR.json +140 -0
- localizer_py-0.5.0/localizer/builtin/zh-Hans.json +140 -0
- localizer_py-0.5.0/pyproject.toml +55 -0
- localizer_py-0.5.0/tests/conftest.py +22 -0
- localizer_py-0.5.0/tests/test_api.py +109 -0
- localizer_py-0.5.0/tests/test_catalog.py +53 -0
- localizer_py-0.5.0/tests/test_conformance.py +82 -0
- localizer_py-0.5.0/tests/test_engine_debug.py +16 -0
- localizer_py-0.5.0/tests/test_hooks_argparse.py +97 -0
- localizer_py-0.5.0/tests/test_hooks_click.py +122 -0
- localizer_py-0.5.0/tests/test_hooks_typer.py +216 -0
- localizer_py-0.5.0/tests/test_locale.py +89 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
University of Illinois/NCSA Open Source License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Snizyx Software LLC. All rights reserved.
|
|
4
|
+
|
|
5
|
+
Developed by: Localizer
|
|
6
|
+
Snizyx Software LLC
|
|
7
|
+
https://locale.dev
|
|
8
|
+
|
|
9
|
+
Permission is hereby granted, free of charge, to any person
|
|
10
|
+
obtaining a copy of this software and associated documentation files
|
|
11
|
+
(the "Software"), to deal with the Software without restriction,
|
|
12
|
+
including without limitation the rights to use, copy, modify, merge,
|
|
13
|
+
publish, distribute, sublicense, and/or sell copies of the Software,
|
|
14
|
+
and to permit persons to whom the Software is furnished to do so,
|
|
15
|
+
subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
* Redistributions of source code must retain the above copyright notice,
|
|
18
|
+
this list of conditions and the following disclaimers.
|
|
19
|
+
|
|
20
|
+
* Redistributions in binary form must reproduce the above copyright
|
|
21
|
+
notice, this list of conditions and the following disclaimers in the
|
|
22
|
+
documentation and/or other materials provided with the distribution.
|
|
23
|
+
|
|
24
|
+
* Neither the names of Snizyx Software LLC, Localizer nor the names of its
|
|
25
|
+
contributors may be used to endorse or promote products derived from
|
|
26
|
+
this Software without specific prior written permission.
|
|
27
|
+
|
|
28
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
|
|
29
|
+
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
30
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
31
|
+
CONTRIBUTORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
32
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
33
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS WITH
|
|
34
|
+
THE SOFTWARE.
|
|
35
|
+
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: localizer-py
|
|
3
|
+
Version: 0.5.0
|
|
4
|
+
Import-Name: localizer
|
|
5
|
+
Summary: Render your Typer, Click or argparse CLI in the user's language with Localizer catalogs.
|
|
6
|
+
Project-URL: Homepage, https://locale.dev/
|
|
7
|
+
Project-URL: Documentation, https://locale.dev/reference/runtime/
|
|
8
|
+
Project-URL: Source, https://github.com/DABH/localizer
|
|
9
|
+
Project-URL: Changelog, https://github.com/DABH/localizer/releases
|
|
10
|
+
Author: Snizyx Software LLC
|
|
11
|
+
License-Expression: NCSA
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: argparse,cli,click,i18n,localization,typer
|
|
14
|
+
Classifier: Development Status :: 4 - Beta
|
|
15
|
+
Classifier: Environment :: Console
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
24
|
+
Classifier: Topic :: Software Development :: Internationalization
|
|
25
|
+
Classifier: Topic :: Software Development :: Localization
|
|
26
|
+
Requires-Python: >=3.10
|
|
27
|
+
Provides-Extra: test
|
|
28
|
+
Requires-Dist: pytest>=8; extra == 'test'
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# localizer-py
|
|
32
|
+
|
|
33
|
+
Render a Typer, Click or argparse CLI's own strings — help text, option descriptions, messages,
|
|
34
|
+
errors, prompts — in the user's language. Translations come from JSON catalogs committed to your
|
|
35
|
+
repository and shipped inside your package; nothing is downloaded or executed at runtime, and a CLI
|
|
36
|
+
without a matching catalog behaves exactly as before.
|
|
37
|
+
|
|
38
|
+
The catalogs are written by [Localizer](https://locale.dev/): install the GitHub App or add the
|
|
39
|
+
GitHub Action to your repository and it keeps a pull request up to date with translations of every
|
|
40
|
+
string it finds in your code. This package is the runtime half — the part that runs on your users'
|
|
41
|
+
machines.
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
pip install localizer-py # not "localizer", which is an unrelated project
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Python 3.10 or newer, no dependencies. Works with Click 8.1+, Typer 0.17+ (including Typer's
|
|
48
|
+
bundled Click) and the standard library's argparse.
|
|
49
|
+
|
|
50
|
+
## One line
|
|
51
|
+
|
|
52
|
+
Call `localize` after every command and option is registered, right before the app runs:
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
import localizer
|
|
56
|
+
|
|
57
|
+
# Typer
|
|
58
|
+
app = typer.Typer()
|
|
59
|
+
...
|
|
60
|
+
if __name__ == "__main__":
|
|
61
|
+
localizer.localize(app, "yourcli.locales")
|
|
62
|
+
app()
|
|
63
|
+
|
|
64
|
+
# Click
|
|
65
|
+
@click.group()
|
|
66
|
+
def cli(): ...
|
|
67
|
+
|
|
68
|
+
def main():
|
|
69
|
+
localizer.localize(cli, "yourcli.locales")
|
|
70
|
+
cli()
|
|
71
|
+
|
|
72
|
+
# argparse
|
|
73
|
+
parser = argparse.ArgumentParser(prog="yourcli")
|
|
74
|
+
...
|
|
75
|
+
localizer.localize(parser, "yourcli.locales")
|
|
76
|
+
args = parser.parse_args()
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
`"yourcli.locales"` names the package holding your catalogs (`yourcli/locales/ja.json`, …); a
|
|
80
|
+
directory path or an `importlib.resources` Traversable works too. If your CLI builds its command
|
|
81
|
+
tree in a factory, call `localize` where the finished app object is handed out — translation happens
|
|
82
|
+
when help and errors are rendered, so commands registered later (plugins, lazy groups) are covered.
|
|
83
|
+
|
|
84
|
+
What is translated: your help text and option descriptions, the framework's own messages (`Usage:`,
|
|
85
|
+
`Show this message and exit.`, `Missing argument 'NAME'.`, `Aborted!`, prompts, argparse's `error:`
|
|
86
|
+
lines — built-in catalogs for these ship in this package), the messages of exceptions your CLI
|
|
87
|
+
raises, and whatever your program passes through the helpers below. Nothing else changes: command and
|
|
88
|
+
option names, values, JSON/YAML output and logs stay as they are.
|
|
89
|
+
|
|
90
|
+
## Your own messages
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
from localizer import t, tf, error
|
|
94
|
+
|
|
95
|
+
print(t("Nothing to do.")) # exact lookup, or the English text
|
|
96
|
+
print(tf("Added task {n}: {title!r}", n=3, title=s)) # translated format string, then .format()
|
|
97
|
+
console.print(t(f"Deleted {count} files")) # formatted text is matched against the catalog
|
|
98
|
+
raise click.ClickException(error(exc)) # an exception's message, for display
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
`t` on a format string looks the template up exactly (call it before formatting, or use `tf`); on
|
|
102
|
+
finished text it reverse-matches the catalog's templates, so `f"Deleted {count} files"` finds
|
|
103
|
+
`Deleted {count} files` and keeps the number. `localizer.translate(text, localizer.Mode.ERROR)`
|
|
104
|
+
does the same for chokepoints that receive already-formatted messages. Every helper returns its input
|
|
105
|
+
unchanged when there is no translation and never raises.
|
|
106
|
+
|
|
107
|
+
## Catalogs
|
|
108
|
+
|
|
109
|
+
One file per language, named by BCP 47 tag, keyed by the exact English source string:
|
|
110
|
+
|
|
111
|
+
```json
|
|
112
|
+
{
|
|
113
|
+
"version": 1,
|
|
114
|
+
"language": "ja",
|
|
115
|
+
"format": "python",
|
|
116
|
+
"messages": {
|
|
117
|
+
"Add a task.": "タスクを追加します。",
|
|
118
|
+
"Added task {n}: {title!r}": "タスク {n} を追加しました: {title!r}"
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Localizer generates and maintains these; you review them like any other pull request. Make sure the
|
|
124
|
+
JSON files are packaged: hatchling, poetry, flit, pdm and uv include them automatically, setuptools
|
|
125
|
+
needs `[tool.setuptools.package-data] yourcli = ["locales/*.json"]`.
|
|
126
|
+
|
|
127
|
+
## Language selection
|
|
128
|
+
|
|
129
|
+
`LOCALIZER_LANG`, then `LC_ALL`, `LC_MESSAGES`, `LANG` (and `LANGUAGE`), then the operating system's
|
|
130
|
+
preferred languages on macOS and Windows; the best available catalog wins, English is the default.
|
|
131
|
+
`localize(app, ..., env_var="YOURCLI_LANG")` adds an application-specific override checked first;
|
|
132
|
+
`language="de"` forces a language. Setting the variable to `off` keeps a CLI in English;
|
|
133
|
+
`LOCALIZER_LANG=qps` pseudo-localizes every translatable string (`⟦Ûšáĝé:⟧`) so you can see what is
|
|
134
|
+
covered without a catalog. `LOCALIZER_DEBUG=1` reports untranslated strings and swallowed hook errors
|
|
135
|
+
on stderr; `LOCALIZER_DUMP=path.json` writes the help tree with a translated/untranslated flag per
|
|
136
|
+
entry.
|
|
137
|
+
|
|
138
|
+
Pin your tests to English so snapshots don't depend on the machine's locale:
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
# conftest.py
|
|
142
|
+
import os
|
|
143
|
+
|
|
144
|
+
def pytest_configure(config):
|
|
145
|
+
os.environ.setdefault("LOCALIZER_LANG", "en")
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
## More
|
|
149
|
+
|
|
150
|
+
- Documentation: https://locale.dev/ — [runtime reference](https://locale.dev/reference/runtime/),
|
|
151
|
+
[integration guide](https://locale.dev/guides/integration/)
|
|
152
|
+
- Coding agents: point yours at https://locale.dev/AGENTS.md to integrate Localizer into a CLI
|
|
153
|
+
- Source: https://github.com/DABH/localizer (`python/`); license: NCSA
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# localizer-py
|
|
2
|
+
|
|
3
|
+
Render a Typer, Click or argparse CLI's own strings — help text, option descriptions, messages,
|
|
4
|
+
errors, prompts — in the user's language. Translations come from JSON catalogs committed to your
|
|
5
|
+
repository and shipped inside your package; nothing is downloaded or executed at runtime, and a CLI
|
|
6
|
+
without a matching catalog behaves exactly as before.
|
|
7
|
+
|
|
8
|
+
The catalogs are written by [Localizer](https://locale.dev/): install the GitHub App or add the
|
|
9
|
+
GitHub Action to your repository and it keeps a pull request up to date with translations of every
|
|
10
|
+
string it finds in your code. This package is the runtime half — the part that runs on your users'
|
|
11
|
+
machines.
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
pip install localizer-py # not "localizer", which is an unrelated project
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Python 3.10 or newer, no dependencies. Works with Click 8.1+, Typer 0.17+ (including Typer's
|
|
18
|
+
bundled Click) and the standard library's argparse.
|
|
19
|
+
|
|
20
|
+
## One line
|
|
21
|
+
|
|
22
|
+
Call `localize` after every command and option is registered, right before the app runs:
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
import localizer
|
|
26
|
+
|
|
27
|
+
# Typer
|
|
28
|
+
app = typer.Typer()
|
|
29
|
+
...
|
|
30
|
+
if __name__ == "__main__":
|
|
31
|
+
localizer.localize(app, "yourcli.locales")
|
|
32
|
+
app()
|
|
33
|
+
|
|
34
|
+
# Click
|
|
35
|
+
@click.group()
|
|
36
|
+
def cli(): ...
|
|
37
|
+
|
|
38
|
+
def main():
|
|
39
|
+
localizer.localize(cli, "yourcli.locales")
|
|
40
|
+
cli()
|
|
41
|
+
|
|
42
|
+
# argparse
|
|
43
|
+
parser = argparse.ArgumentParser(prog="yourcli")
|
|
44
|
+
...
|
|
45
|
+
localizer.localize(parser, "yourcli.locales")
|
|
46
|
+
args = parser.parse_args()
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
`"yourcli.locales"` names the package holding your catalogs (`yourcli/locales/ja.json`, …); a
|
|
50
|
+
directory path or an `importlib.resources` Traversable works too. If your CLI builds its command
|
|
51
|
+
tree in a factory, call `localize` where the finished app object is handed out — translation happens
|
|
52
|
+
when help and errors are rendered, so commands registered later (plugins, lazy groups) are covered.
|
|
53
|
+
|
|
54
|
+
What is translated: your help text and option descriptions, the framework's own messages (`Usage:`,
|
|
55
|
+
`Show this message and exit.`, `Missing argument 'NAME'.`, `Aborted!`, prompts, argparse's `error:`
|
|
56
|
+
lines — built-in catalogs for these ship in this package), the messages of exceptions your CLI
|
|
57
|
+
raises, and whatever your program passes through the helpers below. Nothing else changes: command and
|
|
58
|
+
option names, values, JSON/YAML output and logs stay as they are.
|
|
59
|
+
|
|
60
|
+
## Your own messages
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
from localizer import t, tf, error
|
|
64
|
+
|
|
65
|
+
print(t("Nothing to do.")) # exact lookup, or the English text
|
|
66
|
+
print(tf("Added task {n}: {title!r}", n=3, title=s)) # translated format string, then .format()
|
|
67
|
+
console.print(t(f"Deleted {count} files")) # formatted text is matched against the catalog
|
|
68
|
+
raise click.ClickException(error(exc)) # an exception's message, for display
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`t` on a format string looks the template up exactly (call it before formatting, or use `tf`); on
|
|
72
|
+
finished text it reverse-matches the catalog's templates, so `f"Deleted {count} files"` finds
|
|
73
|
+
`Deleted {count} files` and keeps the number. `localizer.translate(text, localizer.Mode.ERROR)`
|
|
74
|
+
does the same for chokepoints that receive already-formatted messages. Every helper returns its input
|
|
75
|
+
unchanged when there is no translation and never raises.
|
|
76
|
+
|
|
77
|
+
## Catalogs
|
|
78
|
+
|
|
79
|
+
One file per language, named by BCP 47 tag, keyed by the exact English source string:
|
|
80
|
+
|
|
81
|
+
```json
|
|
82
|
+
{
|
|
83
|
+
"version": 1,
|
|
84
|
+
"language": "ja",
|
|
85
|
+
"format": "python",
|
|
86
|
+
"messages": {
|
|
87
|
+
"Add a task.": "タスクを追加します。",
|
|
88
|
+
"Added task {n}: {title!r}": "タスク {n} を追加しました: {title!r}"
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Localizer generates and maintains these; you review them like any other pull request. Make sure the
|
|
94
|
+
JSON files are packaged: hatchling, poetry, flit, pdm and uv include them automatically, setuptools
|
|
95
|
+
needs `[tool.setuptools.package-data] yourcli = ["locales/*.json"]`.
|
|
96
|
+
|
|
97
|
+
## Language selection
|
|
98
|
+
|
|
99
|
+
`LOCALIZER_LANG`, then `LC_ALL`, `LC_MESSAGES`, `LANG` (and `LANGUAGE`), then the operating system's
|
|
100
|
+
preferred languages on macOS and Windows; the best available catalog wins, English is the default.
|
|
101
|
+
`localize(app, ..., env_var="YOURCLI_LANG")` adds an application-specific override checked first;
|
|
102
|
+
`language="de"` forces a language. Setting the variable to `off` keeps a CLI in English;
|
|
103
|
+
`LOCALIZER_LANG=qps` pseudo-localizes every translatable string (`⟦Ûšáĝé:⟧`) so you can see what is
|
|
104
|
+
covered without a catalog. `LOCALIZER_DEBUG=1` reports untranslated strings and swallowed hook errors
|
|
105
|
+
on stderr; `LOCALIZER_DUMP=path.json` writes the help tree with a translated/untranslated flag per
|
|
106
|
+
entry.
|
|
107
|
+
|
|
108
|
+
Pin your tests to English so snapshots don't depend on the machine's locale:
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
# conftest.py
|
|
112
|
+
import os
|
|
113
|
+
|
|
114
|
+
def pytest_configure(config):
|
|
115
|
+
os.environ.setdefault("LOCALIZER_LANG", "en")
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## More
|
|
119
|
+
|
|
120
|
+
- Documentation: https://locale.dev/ — [runtime reference](https://locale.dev/reference/runtime/),
|
|
121
|
+
[integration guide](https://locale.dev/guides/integration/)
|
|
122
|
+
- Coding agents: point yours at https://locale.dev/AGENTS.md to integrate Localizer into a CLI
|
|
123
|
+
- Source: https://github.com/DABH/localizer (`python/`); license: NCSA
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Copyright (c) 2026 Snizyx Software LLC. All rights reserved.
|
|
2
|
+
# SPDX-License-Identifier: NCSA
|
|
3
|
+
|
|
4
|
+
"""Render a Typer, Click or argparse CLI's own strings in the user's language.
|
|
5
|
+
|
|
6
|
+
The usual integration is one line before the app runs::
|
|
7
|
+
|
|
8
|
+
import localizer
|
|
9
|
+
localizer.localize(app, "yourcli.locales")
|
|
10
|
+
|
|
11
|
+
where ``yourcli/locales/`` holds one ``<language>.json`` catalog per language (see
|
|
12
|
+
https://locale.dev/reference/catalogs/). Language selection follows ``LOCALIZER_LANG`` (or an
|
|
13
|
+
application-specific variable given as ``env_var``), then ``LC_ALL``, ``LC_MESSAGES``, ``LANG``, then the
|
|
14
|
+
operating system's preferred languages; ``LOCALIZER_LANG=off`` disables localization and
|
|
15
|
+
``LOCALIZER_LANG=qps`` pseudo-localizes every known string.
|
|
16
|
+
|
|
17
|
+
Messages your own code prints go through :func:`t` (or :func:`tf` for format strings) at your output
|
|
18
|
+
chokepoints; :func:`error` translates an exception for display. Everything fails open to English.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from ._api import ENV_LANG, Mode, error, init, lang, localize, t, tf, translate, uninstall
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
from ._version import __version__
|
|
25
|
+
except ImportError: # a source checkout without the build hook
|
|
26
|
+
__version__ = "0.0.0"
|
|
27
|
+
|
|
28
|
+
__all__ = ["ENV_LANG", "Mode", "__version__", "error", "init", "lang", "localize", "t", "tf", "translate", "uninstall"]
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
# Copyright (c) 2026 Snizyx Software LLC. All rights reserved.
|
|
2
|
+
# SPDX-License-Identifier: NCSA
|
|
3
|
+
|
|
4
|
+
"""The public API: language selection, the engine, and the helpers a CLI calls at its output points.
|
|
5
|
+
|
|
6
|
+
Everything here fails open: an internal error leaves the CLI in English and never raises into the
|
|
7
|
+
host program.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
import traceback
|
|
15
|
+
from collections.abc import Callable, Sequence
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
|
|
18
|
+
from . import _catalog, _locale, builtin
|
|
19
|
+
from ._catalog import Traversable
|
|
20
|
+
from ._engine import Engine, Mode
|
|
21
|
+
|
|
22
|
+
__all__ = ["ENV_LANG", "Mode", "State", "state", "init", "localize", "t", "tf", "error", "translate", "lang", "uninstall"]
|
|
23
|
+
|
|
24
|
+
ENV_LANG = "LOCALIZER_LANG"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class State:
|
|
29
|
+
engine: Engine
|
|
30
|
+
lang: str
|
|
31
|
+
debug: bool
|
|
32
|
+
dump: str # LOCALIZER_DUMP path or ""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
_state: State | None = None
|
|
36
|
+
_hooks_installed = False
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def state() -> State | None:
|
|
40
|
+
"""The active state, or None when output stays in English."""
|
|
41
|
+
return _state
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def debug_enabled() -> bool:
|
|
45
|
+
return (os.environ.get("LOCALIZER_DEBUG") or "").strip().lower() in ("1", "true", "yes", "on")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def debugf(msg: str) -> None:
|
|
49
|
+
if _state is not None and _state.debug or debug_enabled():
|
|
50
|
+
print("localizer: " + msg, file=sys.stderr)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def debug_exc(where: str) -> None:
|
|
54
|
+
"""Reports a swallowed exception when LOCALIZER_DEBUG is on."""
|
|
55
|
+
if debug_enabled():
|
|
56
|
+
print(f"localizer: {where}: {traceback.format_exc().strip().splitlines()[-1]}", file=sys.stderr)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
_catalog_cache: dict[tuple[object, str], dict[str, str]] = {}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _load_messages(root: Traversable, lang: str) -> dict[str, str]:
|
|
63
|
+
key = (str(root), lang)
|
|
64
|
+
msgs = _catalog_cache.get(key)
|
|
65
|
+
if msgs is None:
|
|
66
|
+
msgs = _catalog.load(root, lang).messages
|
|
67
|
+
_catalog_cache[key] = msgs
|
|
68
|
+
return msgs
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _setup(locales, env_var: str | Sequence[str] | None, language: str | None) -> State | None:
|
|
72
|
+
override = [ENV_LANG]
|
|
73
|
+
if isinstance(env_var, str):
|
|
74
|
+
override.insert(0, env_var)
|
|
75
|
+
elif env_var:
|
|
76
|
+
override = [*env_var, ENV_LANG]
|
|
77
|
+
if language is not None:
|
|
78
|
+
res = _locale.detect(["_forced"], lambda _name: language)
|
|
79
|
+
else:
|
|
80
|
+
res = _locale.detect(override)
|
|
81
|
+
if res.off:
|
|
82
|
+
return None
|
|
83
|
+
root = _catalog.resolve(locales)
|
|
84
|
+
available = _catalog.languages(root)
|
|
85
|
+
debug = debug_enabled()
|
|
86
|
+
dump = os.environ.get("LOCALIZER_DUMP") or ""
|
|
87
|
+
if res.pseudo:
|
|
88
|
+
catalogs = [_load_messages(root, l) for l in available]
|
|
89
|
+
catalogs.extend(builtin.messages(l) for l in builtin.languages())
|
|
90
|
+
eng = Engine.pseudo(*catalogs)
|
|
91
|
+
return State(eng, "qps", debug, dump)
|
|
92
|
+
if not available:
|
|
93
|
+
return None
|
|
94
|
+
lang = _locale.match(res.tags, available)
|
|
95
|
+
if not lang:
|
|
96
|
+
return None
|
|
97
|
+
try:
|
|
98
|
+
app = _load_messages(root, lang)
|
|
99
|
+
except Exception:
|
|
100
|
+
debug_exc(f"loading the {lang} catalog")
|
|
101
|
+
return None
|
|
102
|
+
eng = Engine(lang, builtin.messages(lang), app)
|
|
103
|
+
if debug:
|
|
104
|
+
|
|
105
|
+
def on_miss(s: str, _mode: Mode) -> None:
|
|
106
|
+
first = s.strip().splitlines()[0] if s.strip() else s
|
|
107
|
+
print(f"localizer: untranslated ({lang}): {first!r}", file=sys.stderr)
|
|
108
|
+
|
|
109
|
+
eng.on_miss = on_miss
|
|
110
|
+
return State(eng, lang, debug, dump)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def init(locales, *, env_var: str | Sequence[str] | None = None, language: str | None = None) -> str:
|
|
114
|
+
"""Detects the user's language and loads the matching catalog from ``locales`` (a package name such
|
|
115
|
+
as "yourcli.locales", a directory, or a Traversable) for :func:`t`, :func:`tf`, :func:`error` and
|
|
116
|
+
:func:`translate`. Returns the selected language, or "" when output stays in English. Never raises.
|
|
117
|
+
|
|
118
|
+
``env_var`` names an application-specific override variable (or several), consulted before
|
|
119
|
+
``LOCALIZER_LANG``; ``language`` forces a language ("en" or "off" disables localization).
|
|
120
|
+
"""
|
|
121
|
+
global _state
|
|
122
|
+
try:
|
|
123
|
+
_state = _setup(locales, env_var, language)
|
|
124
|
+
except Exception:
|
|
125
|
+
debug_exc("init")
|
|
126
|
+
_state = None
|
|
127
|
+
return _state.lang if _state else ""
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def localize(app, locales, *, env_var: str | Sequence[str] | None = None, language: str | None = None,
|
|
131
|
+
without_error_hook: bool = False, without_prompt_hook: bool = False) -> str:
|
|
132
|
+
"""Localizes a Typer app, a Click command or an argparse parser: its help text, its framework's
|
|
133
|
+
own messages, the errors it prints and the prompts it shows, plus whatever the program passes
|
|
134
|
+
through :func:`t`. Call it after all commands and options are registered, right before the app
|
|
135
|
+
runs. Returns the selected language ("" for English). Never raises."""
|
|
136
|
+
global _hooks_installed
|
|
137
|
+
lang = init(locales, env_var=env_var, language=language)
|
|
138
|
+
if not _state:
|
|
139
|
+
return ""
|
|
140
|
+
try:
|
|
141
|
+
from . import _hooks
|
|
142
|
+
|
|
143
|
+
_hooks.install(app, error_hook=not without_error_hook, prompt_hook=not without_prompt_hook)
|
|
144
|
+
_hooks_installed = True
|
|
145
|
+
except Exception:
|
|
146
|
+
debug_exc("installing hooks")
|
|
147
|
+
return lang
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def uninstall() -> None:
|
|
151
|
+
"""Removes every hook and forgets the language (for tests)."""
|
|
152
|
+
global _state, _hooks_installed
|
|
153
|
+
_state = None
|
|
154
|
+
if _hooks_installed:
|
|
155
|
+
try:
|
|
156
|
+
from . import _hooks
|
|
157
|
+
|
|
158
|
+
_hooks.uninstall()
|
|
159
|
+
except Exception:
|
|
160
|
+
debug_exc("uninstall")
|
|
161
|
+
_hooks_installed = False
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def lang() -> str:
|
|
165
|
+
"""The active language tag ("qps" for pseudo-localization), or "" when output is English."""
|
|
166
|
+
return _state.lang if _state else ""
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def t(s: str) -> str:
|
|
170
|
+
"""The translation of ``s``, or ``s`` unchanged. A format string is looked up exactly, so call
|
|
171
|
+
``t`` before formatting: ``t("Created {name}").format(name=n)`` (or use :func:`tf`)."""
|
|
172
|
+
st = _state
|
|
173
|
+
if st is None or not isinstance(s, str) or s == "":
|
|
174
|
+
return s
|
|
175
|
+
try:
|
|
176
|
+
from . import _format
|
|
177
|
+
|
|
178
|
+
if _format.has_fields(s):
|
|
179
|
+
return st.engine.lookup(s)[0]
|
|
180
|
+
return st.engine.translate(s, Mode.OUTPUT)
|
|
181
|
+
except Exception:
|
|
182
|
+
debug_exc("t")
|
|
183
|
+
return s
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def tf(fmt: str, *args, **kwargs) -> str:
|
|
187
|
+
"""``str.format`` with a translated format string; falls back to the English format if the
|
|
188
|
+
translation cannot be formatted with these arguments."""
|
|
189
|
+
tr = t(fmt)
|
|
190
|
+
try:
|
|
191
|
+
return tr.format(*args, **kwargs)
|
|
192
|
+
except Exception:
|
|
193
|
+
return fmt.format(*args, **kwargs)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def translate(s: str, mode: Mode = Mode.OUTPUT) -> str:
|
|
197
|
+
"""Translates text the CLI is about to print, splitting composite text as ``mode`` allows. Use it
|
|
198
|
+
at output chokepoints that receive already formatted messages."""
|
|
199
|
+
st = _state
|
|
200
|
+
if st is None or not isinstance(s, str) or s == "":
|
|
201
|
+
return s
|
|
202
|
+
try:
|
|
203
|
+
return st.engine.translate(s, mode)
|
|
204
|
+
except Exception:
|
|
205
|
+
debug_exc("translate")
|
|
206
|
+
return s
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def error(exc) -> str:
|
|
210
|
+
"""The message of an exception (or a string) translated for display; the CLI's own parts of a
|
|
211
|
+
message are translated, text from servers and libraries stays as it is."""
|
|
212
|
+
try:
|
|
213
|
+
msg = exc.format_message() if hasattr(exc, "format_message") else str(exc)
|
|
214
|
+
except Exception:
|
|
215
|
+
msg = str(exc)
|
|
216
|
+
return translate(msg, Mode.ERROR)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# Copyright (c) 2026 Snizyx Software LLC. All rights reserved.
|
|
2
|
+
# SPDX-License-Identifier: NCSA
|
|
3
|
+
|
|
4
|
+
"""Translation catalogs: one JSON file per language, named "<BCP 47 tag>.json", mapping each English
|
|
5
|
+
source string to its translation. Catalogs ship inside the CLI's package and are read with
|
|
6
|
+
importlib.resources, so they work from wheels, zip apps and source checkouts alike."""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from importlib import resources
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
from importlib.resources.abc import Traversable
|
|
18
|
+
except ImportError: # Python 3.10
|
|
19
|
+
from importlib.abc import Traversable
|
|
20
|
+
|
|
21
|
+
__all__ = ["File", "VERSION", "resolve", "languages", "load", "dumps"]
|
|
22
|
+
|
|
23
|
+
VERSION = 1
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class File:
|
|
28
|
+
"""One language's catalog."""
|
|
29
|
+
|
|
30
|
+
language: str
|
|
31
|
+
messages: dict[str, str] = field(default_factory=dict)
|
|
32
|
+
version: int = VERSION
|
|
33
|
+
format: str = "python" # placeholder syntax of the keys
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def resolve(locales: str | os.PathLike[str] | Traversable) -> Traversable:
|
|
37
|
+
"""Turns a package name ("yourcli.locales"), a path or a Traversable into a Traversable."""
|
|
38
|
+
if isinstance(locales, str):
|
|
39
|
+
if os.sep in locales or "/" in locales or os.path.isdir(locales):
|
|
40
|
+
return Path(locales)
|
|
41
|
+
return resources.files(locales)
|
|
42
|
+
if isinstance(locales, os.PathLike):
|
|
43
|
+
return Path(locales)
|
|
44
|
+
return locales
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def languages(root: Traversable) -> list[str]:
|
|
48
|
+
"""The catalog languages in ``root``: "<tag>.json" files, ignoring "_"- and "."-prefixed names."""
|
|
49
|
+
try:
|
|
50
|
+
entries = list(root.iterdir())
|
|
51
|
+
except (OSError, TypeError, AttributeError):
|
|
52
|
+
return []
|
|
53
|
+
out = []
|
|
54
|
+
for e in entries:
|
|
55
|
+
name = e.name
|
|
56
|
+
if not name.endswith(".json") or name.startswith(("_", ".")):
|
|
57
|
+
continue
|
|
58
|
+
try:
|
|
59
|
+
if not e.is_file():
|
|
60
|
+
continue
|
|
61
|
+
except OSError:
|
|
62
|
+
continue
|
|
63
|
+
out.append(name[: -len(".json")])
|
|
64
|
+
return sorted(out)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def load(root: Traversable, lang: str) -> File:
|
|
68
|
+
"""Reads the catalog for ``lang`` from ``root``."""
|
|
69
|
+
data = (root / (lang + ".json")).read_text(encoding="utf-8")
|
|
70
|
+
raw = json.loads(data)
|
|
71
|
+
if not isinstance(raw, dict):
|
|
72
|
+
raise ValueError("catalog: not an object")
|
|
73
|
+
version = raw.get("version", VERSION)
|
|
74
|
+
if not isinstance(version, int) or version > VERSION:
|
|
75
|
+
raise ValueError(f"catalog: unsupported version {version!r} (this build understands up to {VERSION})")
|
|
76
|
+
messages = raw.get("messages") or {}
|
|
77
|
+
if not isinstance(messages, dict) or not all(isinstance(k, str) and isinstance(v, str) for k, v in messages.items()):
|
|
78
|
+
raise ValueError("catalog: messages must map strings to strings")
|
|
79
|
+
return File(language=str(raw.get("language", lang)), messages=messages, version=version, format=str(raw.get("format", "")))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def dumps(f: File) -> str:
|
|
83
|
+
"""Renders a catalog canonically, byte for byte as the Go tooling does: version, language, format
|
|
84
|
+
(when set), then messages with sorted keys, two-space indent, raw UTF-8 and a trailing newline."""
|
|
85
|
+
doc: dict[str, object] = {"version": f.version or VERSION, "language": f.language}
|
|
86
|
+
if f.format:
|
|
87
|
+
doc["format"] = f.format
|
|
88
|
+
doc["messages"] = dict(sorted(f.messages.items(), key=lambda kv: kv[0].encode("utf-8")))
|
|
89
|
+
out = json.dumps(doc, ensure_ascii=False, indent=2)
|
|
90
|
+
# Go's encoder always escapes these two, which JSON parsers otherwise accept raw.
|
|
91
|
+
return out.replace("\u2028", "\\u2028").replace("\u2029", "\\u2029") + "\n"
|