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 +28 -0
- localizer/_api.py +216 -0
- localizer/_catalog.py +91 -0
- localizer/_dump.py +116 -0
- localizer/_engine.py +333 -0
- localizer/_format.py +602 -0
- localizer/_hooks.py +98 -0
- localizer/_hooks_argparse.py +123 -0
- localizer/_hooks_click.py +313 -0
- localizer/_hooks_typer.py +101 -0
- localizer/_locale.py +292 -0
- localizer/_version.py +24 -0
- localizer/builtin/__init__.py +46 -0
- localizer/builtin/_keys.json +661 -0
- localizer/builtin/de.json +140 -0
- localizer/builtin/es.json +140 -0
- localizer/builtin/fr.json +140 -0
- localizer/builtin/ja.json +140 -0
- localizer/builtin/ko.json +140 -0
- localizer/builtin/pt-BR.json +140 -0
- localizer/builtin/zh-Hans.json +140 -0
- localizer_py-0.5.0.dist-info/METADATA +153 -0
- localizer_py-0.5.0.dist-info/RECORD +25 -0
- localizer_py-0.5.0.dist-info/WHEEL +4 -0
- localizer_py-0.5.0.dist-info/licenses/LICENSE +35 -0
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)
|