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.
Files changed (35) hide show
  1. localizer_py-0.5.0/.gitignore +13 -0
  2. localizer_py-0.5.0/LICENSE +35 -0
  3. localizer_py-0.5.0/PKG-INFO +153 -0
  4. localizer_py-0.5.0/README.md +123 -0
  5. localizer_py-0.5.0/localizer/__init__.py +28 -0
  6. localizer_py-0.5.0/localizer/_api.py +216 -0
  7. localizer_py-0.5.0/localizer/_catalog.py +91 -0
  8. localizer_py-0.5.0/localizer/_dump.py +116 -0
  9. localizer_py-0.5.0/localizer/_engine.py +333 -0
  10. localizer_py-0.5.0/localizer/_format.py +602 -0
  11. localizer_py-0.5.0/localizer/_hooks.py +98 -0
  12. localizer_py-0.5.0/localizer/_hooks_argparse.py +123 -0
  13. localizer_py-0.5.0/localizer/_hooks_click.py +313 -0
  14. localizer_py-0.5.0/localizer/_hooks_typer.py +101 -0
  15. localizer_py-0.5.0/localizer/_locale.py +292 -0
  16. localizer_py-0.5.0/localizer/_version.py +24 -0
  17. localizer_py-0.5.0/localizer/builtin/__init__.py +46 -0
  18. localizer_py-0.5.0/localizer/builtin/_keys.json +661 -0
  19. localizer_py-0.5.0/localizer/builtin/de.json +140 -0
  20. localizer_py-0.5.0/localizer/builtin/es.json +140 -0
  21. localizer_py-0.5.0/localizer/builtin/fr.json +140 -0
  22. localizer_py-0.5.0/localizer/builtin/ja.json +140 -0
  23. localizer_py-0.5.0/localizer/builtin/ko.json +140 -0
  24. localizer_py-0.5.0/localizer/builtin/pt-BR.json +140 -0
  25. localizer_py-0.5.0/localizer/builtin/zh-Hans.json +140 -0
  26. localizer_py-0.5.0/pyproject.toml +55 -0
  27. localizer_py-0.5.0/tests/conftest.py +22 -0
  28. localizer_py-0.5.0/tests/test_api.py +109 -0
  29. localizer_py-0.5.0/tests/test_catalog.py +53 -0
  30. localizer_py-0.5.0/tests/test_conformance.py +82 -0
  31. localizer_py-0.5.0/tests/test_engine_debug.py +16 -0
  32. localizer_py-0.5.0/tests/test_hooks_argparse.py +97 -0
  33. localizer_py-0.5.0/tests/test_hooks_click.py +122 -0
  34. localizer_py-0.5.0/tests/test_hooks_typer.py +216 -0
  35. localizer_py-0.5.0/tests/test_locale.py +89 -0
@@ -0,0 +1,13 @@
1
+ /bin/
2
+ *.test
3
+ *.out
4
+ coverage.txt
5
+ .DS_Store
6
+ python/localizer/_version.py
7
+ python/dist/
8
+ .venv/
9
+ __pycache__/
10
+ *.egg-info/
11
+ .pytest_cache/
12
+ python/**/uv.lock
13
+ site/public/AGENTS.md
@@ -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"