localizer-py 0.5.0__py3-none-any.whl

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/__init__.py ADDED
@@ -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"]
localizer/_api.py ADDED
@@ -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)
localizer/_catalog.py ADDED
@@ -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"
localizer/_dump.py ADDED
@@ -0,0 +1,116 @@
1
+ # Copyright (c) 2026 Snizyx Software LLC. All rights reserved.
2
+ # SPDX-License-Identifier: NCSA
3
+
4
+ """LOCALIZER_DUMP: every help string of the command tree, with whether it was translated, written once
5
+ per process as JSON in the same shape the Go runtime writes (for coverage reports)."""
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+
11
+ from . import _api
12
+ from ._engine import Mode
13
+
14
+ _done: set[str] = set()
15
+
16
+
17
+ def _write(entries: list[dict]) -> None:
18
+ st = _api.state()
19
+ if st is None or not st.dump:
20
+ return
21
+ doc = {"language": st.lang, "entries": entries}
22
+ with open(st.dump, "w", encoding="utf-8") as f:
23
+ json.dump(doc, f, ensure_ascii=False, indent=2)
24
+ f.write("\n")
25
+
26
+
27
+ def _entry(entries: list[dict], kind: str, command: str, text, flag: str = "") -> None:
28
+ st = _api.state()
29
+ if not isinstance(text, str) or not text.strip():
30
+ return
31
+ e = {"kind": kind, "command": command, "text": text}
32
+ if flag:
33
+ e["flag"] = flag
34
+ if st is not None:
35
+ e["translated"] = st.engine.translate(text, Mode.HELP) != text
36
+ entries.append(e)
37
+
38
+
39
+ def maybe_dump_click(ctx) -> None:
40
+ """Dumps the tree reachable from the root of a Click context, the first time help renders."""
41
+ st = _api.state()
42
+ if st is None or not st.dump or "click" in _done:
43
+ return
44
+ _done.add("click")
45
+ root_ctx = ctx.find_root()
46
+ entries: list[dict] = []
47
+ _walk_click(root_ctx.command, root_ctx, entries)
48
+ _write(entries)
49
+
50
+
51
+ def _walk_click(cmd, ctx, entries: list[dict]) -> None:
52
+ path = ctx.command_path
53
+ _entry(entries, "short", path, getattr(cmd, "short_help", None) or _first_paragraph(getattr(cmd, "help", None)))
54
+ _entry(entries, "long", path, getattr(cmd, "help", None))
55
+ _entry(entries, "epilog", path, getattr(cmd, "epilog", None))
56
+ if isinstance(getattr(cmd, "deprecated", None), str):
57
+ _entry(entries, "deprecated", path, cmd.deprecated)
58
+ if isinstance(getattr(cmd, "rich_help_panel", None), str):
59
+ _entry(entries, "group", path, cmd.rich_help_panel)
60
+ seen = set()
61
+ try:
62
+ params = cmd.get_params(ctx)
63
+ except Exception:
64
+ params = getattr(cmd, "params", [])
65
+ for p in params:
66
+ name = getattr(p, "name", "") or ""
67
+ if name in seen:
68
+ continue
69
+ seen.add(name)
70
+ _entry(entries, "flag", path, getattr(p, "help", None), name)
71
+ if isinstance(getattr(p, "rich_help_panel", None), str):
72
+ _entry(entries, "group", path, p.rich_help_panel, name)
73
+ if hasattr(cmd, "list_commands") and hasattr(cmd, "get_command"):
74
+ for name in cmd.list_commands(ctx):
75
+ sub = cmd.get_command(ctx, name)
76
+ if sub is None:
77
+ continue
78
+ try:
79
+ sub_ctx = type(ctx)(sub, info_name=name, parent=ctx)
80
+ except Exception:
81
+ continue
82
+ _walk_click(sub, sub_ctx, entries)
83
+
84
+
85
+ def _first_paragraph(text):
86
+ if not isinstance(text, str):
87
+ return None
88
+ return text.strip().split("\n\n", 1)[0]
89
+
90
+
91
+ def maybe_dump_argparse(parser) -> None:
92
+ st = _api.state()
93
+ if st is None or not st.dump or "argparse" in _done:
94
+ return
95
+ _done.add("argparse")
96
+ entries: list[dict] = []
97
+ _walk_argparse(parser, parser.prog, entries)
98
+ _write(entries)
99
+
100
+
101
+ def _walk_argparse(parser, path: str, entries: list[dict]) -> None:
102
+ import argparse
103
+
104
+ _entry(entries, "long", path, parser.description)
105
+ _entry(entries, "epilog", path, parser.epilog)
106
+ for group in getattr(parser, "_action_groups", ()):
107
+ _entry(entries, "group", path, group.title)
108
+ for action in getattr(parser, "_actions", ()):
109
+ if action.help is argparse.SUPPRESS:
110
+ continue
111
+ flag = action.option_strings[0] if action.option_strings else action.dest
112
+ _entry(entries, "flag", path, action.help, flag)
113
+ choices = getattr(action, "choices", None)
114
+ if isinstance(action, argparse._SubParsersAction) and isinstance(choices, dict):
115
+ for name, sub in choices.items():
116
+ _walk_argparse(sub, f"{path} {name}", entries)