xmemcli 0.0.1__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.
- xmemcli/__init__.py +3 -0
- xmemcli/_envelope.py +24 -0
- xmemcli/_help.py +720 -0
- xmemcli/_instance_schema.py +769 -0
- xmemcli/_login.py +593 -0
- xmemcli/_org_cluster.py +70 -0
- xmemcli/_output.py +274 -0
- xmemcli/_pretty.py +373 -0
- xmemcli/_rc.py +192 -0
- xmemcli/_version.py +4 -0
- xmemcli/_xmem_bridge.py +33 -0
- xmemcli/cli.py +1826 -0
- xmemcli-0.0.1.dist-info/METADATA +22 -0
- xmemcli-0.0.1.dist-info/RECORD +16 -0
- xmemcli-0.0.1.dist-info/WHEEL +4 -0
- xmemcli-0.0.1.dist-info/entry_points.txt +2 -0
xmemcli/_help.py
ADDED
|
@@ -0,0 +1,720 @@
|
|
|
1
|
+
"""Styled help pages for ``xmemcli`` (scsh-inspired layout, xmemory palette).
|
|
2
|
+
|
|
3
|
+
Stdlib-only ANSI styling — no ``rich``. Color is stripped when stdout is not a
|
|
4
|
+
TTY or ``NO_COLOR`` is set, so tests and pipes still see plain text.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import sys
|
|
11
|
+
from typing import Any, IO, NoReturn
|
|
12
|
+
|
|
13
|
+
from ._version import CLI_NAME, __version__
|
|
14
|
+
|
|
15
|
+
OPTIONS_HELP_TOPIC = "options"
|
|
16
|
+
_LABEL_WIDTH = 28 # overview command list only
|
|
17
|
+
_OPTION_DESC_INDENT = " " # description lines under a flag name
|
|
18
|
+
_INTERNAL_DESTS = frozenset(
|
|
19
|
+
{
|
|
20
|
+
"command",
|
|
21
|
+
"instance_command",
|
|
22
|
+
"org_command",
|
|
23
|
+
"org_list_target",
|
|
24
|
+
"cluster_command",
|
|
25
|
+
"cluster_list_target",
|
|
26
|
+
"auth_command",
|
|
27
|
+
"schema_command",
|
|
28
|
+
"schema_migrations_command",
|
|
29
|
+
"xmd_command",
|
|
30
|
+
"help",
|
|
31
|
+
"func",
|
|
32
|
+
}
|
|
33
|
+
)
|
|
34
|
+
# argparse alias names we hide from the auto-generated overview (canonical names only).
|
|
35
|
+
_HIDDEN_COMMAND_NAMES = frozenset(
|
|
36
|
+
{
|
|
37
|
+
"write_status",
|
|
38
|
+
"writestatus",
|
|
39
|
+
"async_write_status",
|
|
40
|
+
}
|
|
41
|
+
)
|
|
42
|
+
_RESET = "\x1b[0m"
|
|
43
|
+
_BOLD = "\x1b[1m"
|
|
44
|
+
_DIM = "\x1b[2m"
|
|
45
|
+
# Console-aligned accent green (matches public Console theme tokens).
|
|
46
|
+
_GREEN = "\x1b[38;2;159;232;112m" # --xm-green / agenda accent
|
|
47
|
+
|
|
48
|
+
# Short ``xmemcli help <topic>`` paths → canonical parser path (auth, write-status, quota aliases).
|
|
49
|
+
_HELP_TOPIC_ALIASES: dict[tuple[str, ...], tuple[str, ...]] = {
|
|
50
|
+
("login",): ("auth", "login"),
|
|
51
|
+
("logout",): ("auth", "logout"),
|
|
52
|
+
("signin",): ("auth", "login"),
|
|
53
|
+
("signout",): ("auth", "logout"),
|
|
54
|
+
("logoff",): ("auth", "logout"),
|
|
55
|
+
("sign", "in"): ("auth", "login"),
|
|
56
|
+
("sign", "out"): ("auth", "logout"),
|
|
57
|
+
("auth", "signin",): ("auth", "login"),
|
|
58
|
+
("auth", "signout",): ("auth", "logout"),
|
|
59
|
+
("auth", "logoff",): ("auth", "logout"),
|
|
60
|
+
("auth", "sign", "in"): ("auth", "login"),
|
|
61
|
+
("auth", "sign", "out"): ("auth", "logout"),
|
|
62
|
+
("auth", "log", "in"): ("auth", "login"),
|
|
63
|
+
("auth", "log", "out"): ("auth", "logout"),
|
|
64
|
+
("log", "in"): ("auth", "login"),
|
|
65
|
+
("log", "out"): ("auth", "logout"),
|
|
66
|
+
("write-status",): ("write-status",),
|
|
67
|
+
("write_status",): ("write-status",),
|
|
68
|
+
("writestatus",): ("write-status",),
|
|
69
|
+
("async_write_status",): ("write-status",),
|
|
70
|
+
("asyncwritestatus",): ("write-status",),
|
|
71
|
+
("async-write-status",): ("write-status",),
|
|
72
|
+
("quota", "get"): ("quota",),
|
|
73
|
+
("quota-get",): ("quota",),
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
# Top-level shortcuts → canonical command paths (also listed in the overview Shortcuts section).
|
|
77
|
+
|
|
78
|
+
# Curated overview — grouped; canonical command names only.
|
|
79
|
+
_OVERVIEW_SECTIONS: tuple[tuple[str, tuple[tuple[str, str], ...]], ...] = (
|
|
80
|
+
(
|
|
81
|
+
"Data",
|
|
82
|
+
(
|
|
83
|
+
("write", "Write a fact to the active instance"),
|
|
84
|
+
("write-status", "Poll async writes until they complete (one or more write_ids)"),
|
|
85
|
+
("read", "Read from the active instance"),
|
|
86
|
+
),
|
|
87
|
+
),
|
|
88
|
+
(
|
|
89
|
+
"Org",
|
|
90
|
+
(
|
|
91
|
+
("org list clusters", "Clusters visible to this credential"),
|
|
92
|
+
("org list instances", "Instances visible to this credential"),
|
|
93
|
+
("org list keys", "API keys visible to this credential"),
|
|
94
|
+
),
|
|
95
|
+
),
|
|
96
|
+
(
|
|
97
|
+
"Instances",
|
|
98
|
+
(
|
|
99
|
+
("instance list", "List instances visible to this credential (alias for org list instances)"),
|
|
100
|
+
("instance get", "Get a single instance by id"),
|
|
101
|
+
("instance create", "Create a new instance from an XMD/JSON schema"),
|
|
102
|
+
),
|
|
103
|
+
),
|
|
104
|
+
(
|
|
105
|
+
"Schema & XMD",
|
|
106
|
+
(
|
|
107
|
+
("schema get", "Get the live schema of an instance"),
|
|
108
|
+
("schema update", "Replace the schema of an existing instance"),
|
|
109
|
+
("schema dry-run", "Preview a schema migration without applying it"),
|
|
110
|
+
("schema migrations list", "Migration history for an instance (newest first)"),
|
|
111
|
+
("schema migrations get", "One applied migration record by id"),
|
|
112
|
+
("xmd generate", "Synthesize an XMD schema from a description"),
|
|
113
|
+
("xmd enhance", "Enhance (augment) an existing XMD schema"),
|
|
114
|
+
("xmd validate", "Validate an XMD schema (format inferred from file extension)"),
|
|
115
|
+
("xmd validate-yaml", "Validate an XMD YAML schema against the server"),
|
|
116
|
+
("xmd validate-json", "Validate a JSON Schema against the server"),
|
|
117
|
+
("xmd convert", "Convert JSON Schema to XMD YAML via the API"),
|
|
118
|
+
),
|
|
119
|
+
),
|
|
120
|
+
(
|
|
121
|
+
"Auth & meta",
|
|
122
|
+
(
|
|
123
|
+
("auth login", "Acquire a key via Console PKCE handoff → .xmemrc.json"),
|
|
124
|
+
("auth logout", "Remove the credential from the active .xmemrc.json"),
|
|
125
|
+
("auth status", "Report whose key the CLI is holding (via /whoami)"),
|
|
126
|
+
("version", "Print the CLI release version (no network)"),
|
|
127
|
+
("status", "Local health: version and whether .xmemrc.json is present"),
|
|
128
|
+
),
|
|
129
|
+
),
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _use_color(stream: IO[str]) -> bool:
|
|
134
|
+
if os.environ.get("NO_COLOR"):
|
|
135
|
+
return False
|
|
136
|
+
if os.environ.get("FORCE_COLOR") or os.environ.get("CLICOLOR_FORCE"):
|
|
137
|
+
return True
|
|
138
|
+
try:
|
|
139
|
+
return stream.isatty()
|
|
140
|
+
except (OSError, AttributeError, ValueError):
|
|
141
|
+
return False
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class _Style:
|
|
145
|
+
def __init__(self, enabled: bool) -> None:
|
|
146
|
+
self.enabled = enabled
|
|
147
|
+
|
|
148
|
+
def green_bold(self, text: str) -> str:
|
|
149
|
+
if not self.enabled:
|
|
150
|
+
return text
|
|
151
|
+
return f"{_BOLD}{_GREEN}{text}{_RESET}"
|
|
152
|
+
|
|
153
|
+
def bold(self, text: str) -> str:
|
|
154
|
+
if not self.enabled:
|
|
155
|
+
return text
|
|
156
|
+
return f"{_BOLD}{text}{_RESET}"
|
|
157
|
+
|
|
158
|
+
def dim(self, text: str) -> str:
|
|
159
|
+
if not self.enabled:
|
|
160
|
+
return text
|
|
161
|
+
return f"{_DIM}{text}{_RESET}"
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _subparsers_action(parser: argparse.ArgumentParser) -> argparse._SubParsersAction | None:
|
|
165
|
+
for action in parser._actions:
|
|
166
|
+
if isinstance(action, argparse._SubParsersAction):
|
|
167
|
+
return action
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _visible_actions(parser: argparse.ArgumentParser) -> list[argparse.Action]:
|
|
172
|
+
# argparse exposes actions only via private _actions; there is no stable public API.
|
|
173
|
+
return [a for a in parser._actions if a.help is not argparse.SUPPRESS and a.option_strings]
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _option_label(action: argparse.Action) -> str:
|
|
177
|
+
opts = [o for o in action.option_strings if o.startswith("-")]
|
|
178
|
+
if opts:
|
|
179
|
+
longest = opts[-1]
|
|
180
|
+
if action.metavar:
|
|
181
|
+
return f"{longest} {action.metavar}"
|
|
182
|
+
if action.nargs in (argparse.ONE_OR_MORE, argparse.ZERO_OR_MORE, "+", "*"):
|
|
183
|
+
return f"{longest} …"
|
|
184
|
+
return longest
|
|
185
|
+
if action.metavar:
|
|
186
|
+
return action.metavar
|
|
187
|
+
return action.dest
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _help_row(style: _Style, name: str, desc: str, *, width: int = _LABEL_WIDTH) -> str:
|
|
191
|
+
bullet = style.dim("•")
|
|
192
|
+
label = style.bold(f"{name:<{width}}")
|
|
193
|
+
return f" {bullet} {label} {style.dim(desc)}"
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _emit(stream: IO[str], text: str = "") -> None:
|
|
197
|
+
stream.write(text + "\n")
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _split_help_two_lines(text: str, *, first_max: int = 48) -> tuple[str, str]:
|
|
201
|
+
"""Split a help blurb across two lines for the options page."""
|
|
202
|
+
collapsed = " ".join(text.split())
|
|
203
|
+
for sep in (". ", "; "):
|
|
204
|
+
if sep in collapsed:
|
|
205
|
+
head, tail = collapsed.split(sep, 1)
|
|
206
|
+
tail = tail.strip()
|
|
207
|
+
if head and tail:
|
|
208
|
+
return head + ("" if sep == "; " else "."), tail
|
|
209
|
+
if len(collapsed) <= first_max:
|
|
210
|
+
return collapsed, ""
|
|
211
|
+
cut = collapsed.rfind(" ", 0, first_max + 1)
|
|
212
|
+
if cut < 20:
|
|
213
|
+
cut = collapsed.find(" ", first_max)
|
|
214
|
+
if cut < 0:
|
|
215
|
+
return collapsed, ""
|
|
216
|
+
return collapsed[:cut], collapsed[cut + 1:]
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
# Two-line blurbs for global flags (overview lists `help options` instead).
|
|
220
|
+
_GLOBAL_OPTION_LINES: dict[str, tuple[str, str]] = {
|
|
221
|
+
"api_url": (
|
|
222
|
+
"API origin.",
|
|
223
|
+
"Default $XMEM_API_URL or https://api.xmemory.ai.",
|
|
224
|
+
),
|
|
225
|
+
"api_key": (
|
|
226
|
+
"API key.",
|
|
227
|
+
"Default $XMEM_API_KEY.",
|
|
228
|
+
),
|
|
229
|
+
"instance_id": (
|
|
230
|
+
"Target instance id.",
|
|
231
|
+
"Default $XMEM_INSTANCE_ID.",
|
|
232
|
+
),
|
|
233
|
+
"rc_dir": (
|
|
234
|
+
"Directory for .xmemrc.json (auth login writes here).",
|
|
235
|
+
"Reads walk up toward $HOME.",
|
|
236
|
+
),
|
|
237
|
+
"json": (
|
|
238
|
+
"Force JSON output — one JSON document on stdout.",
|
|
239
|
+
"TTY default is pretty text; pipes and redirects already emit JSON.",
|
|
240
|
+
),
|
|
241
|
+
"verbose": (
|
|
242
|
+
"Add trace_id and console_url when the server sends them.",
|
|
243
|
+
"Never shows SQL, diff plans, or other server internals.",
|
|
244
|
+
),
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _option_desc_lines(action: argparse.Action) -> tuple[str, str]:
|
|
249
|
+
override = _GLOBAL_OPTION_LINES.get(action.dest)
|
|
250
|
+
if override is not None:
|
|
251
|
+
return override
|
|
252
|
+
return _split_help_two_lines(action.help or "")
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _emit_option_two_lines(
|
|
256
|
+
stream: IO[str],
|
|
257
|
+
style: _Style,
|
|
258
|
+
label: str,
|
|
259
|
+
line1: str,
|
|
260
|
+
line2: str,
|
|
261
|
+
) -> None:
|
|
262
|
+
bullet = style.dim("•")
|
|
263
|
+
_emit(stream, f" {bullet} {style.bold(label)}")
|
|
264
|
+
_emit(stream, f"{_OPTION_DESC_INDENT}{style.dim(line1)}")
|
|
265
|
+
if line2:
|
|
266
|
+
_emit(stream, f"{_OPTION_DESC_INDENT}{style.dim(line2)}")
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def normalize_help_path(path: tuple[str, ...]) -> tuple[str, ...]:
|
|
270
|
+
"""Map short help topics (``help login``, ``help write-status``) to parser paths."""
|
|
271
|
+
if not path:
|
|
272
|
+
return path
|
|
273
|
+
alias = _HELP_TOPIC_ALIASES.get(path)
|
|
274
|
+
if alias is not None:
|
|
275
|
+
return alias
|
|
276
|
+
if len(path) == 1:
|
|
277
|
+
single = _HELP_TOPIC_ALIASES.get((path[0],))
|
|
278
|
+
if single is not None:
|
|
279
|
+
return single
|
|
280
|
+
return path
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _global_options_document(root: argparse.ArgumentParser) -> dict[str, Any]:
|
|
284
|
+
return {
|
|
285
|
+
"kind": "options",
|
|
286
|
+
"cli": CLI_NAME,
|
|
287
|
+
"version": __version__,
|
|
288
|
+
"title": "Global flags for every command",
|
|
289
|
+
"usage": f"{CLI_NAME} help options",
|
|
290
|
+
"note": f"These flags apply before any subcommand, e.g. `{CLI_NAME} --json read q`.",
|
|
291
|
+
"options": [
|
|
292
|
+
{
|
|
293
|
+
"name": _option_label(action),
|
|
294
|
+
"dest": action.dest,
|
|
295
|
+
"description": list(filter(None, _option_desc_lines(action))),
|
|
296
|
+
}
|
|
297
|
+
for action in _visible_actions(root)
|
|
298
|
+
],
|
|
299
|
+
"see_also": [f"{CLI_NAME} for the command list."],
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _overview_document(root: argparse.ArgumentParser) -> dict[str, Any]:
|
|
304
|
+
del root # overview is curated; parser kept for API symmetry with command topics.
|
|
305
|
+
return {
|
|
306
|
+
"kind": "overview",
|
|
307
|
+
"cli": CLI_NAME,
|
|
308
|
+
"version": __version__,
|
|
309
|
+
"title": "xmemory CLI",
|
|
310
|
+
"tagline": "Read, write, and manage xmemory instances over the HTTP API.",
|
|
311
|
+
"credentials": "Credentials: `.xmemrc.json`, `$XMEM_API_KEY`, or `auth login`.",
|
|
312
|
+
"usage": f"{CLI_NAME} <command> [options]",
|
|
313
|
+
"prefix_commands": [
|
|
314
|
+
{
|
|
315
|
+
"name": "help options",
|
|
316
|
+
"description": "Global flags (`--api-url`, `--api-key`, `--json`, …).",
|
|
317
|
+
},
|
|
318
|
+
],
|
|
319
|
+
"sections": [
|
|
320
|
+
{
|
|
321
|
+
"name": section,
|
|
322
|
+
"commands": [{"name": name, "description": desc} for name, desc in commands],
|
|
323
|
+
}
|
|
324
|
+
for section, commands in _OVERVIEW_SECTIONS
|
|
325
|
+
],
|
|
326
|
+
"notes": [
|
|
327
|
+
f"Global flags: `{CLI_NAME} help options`.",
|
|
328
|
+
f"More: `{CLI_NAME} help <topic>` (e.g. `{CLI_NAME} help write-status`, `{CLI_NAME} help org`).",
|
|
329
|
+
"Output: interactive terminals get one-line summaries; pipes and `--json` emit a single JSON document.",
|
|
330
|
+
],
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _local_command_options(parser: argparse.ArgumentParser) -> list[argparse.Action]:
|
|
335
|
+
return [
|
|
336
|
+
a
|
|
337
|
+
for a in parser._actions
|
|
338
|
+
if a.help is not argparse.SUPPRESS
|
|
339
|
+
and a.dest not in _INTERNAL_DESTS
|
|
340
|
+
and not (
|
|
341
|
+
a.option_strings
|
|
342
|
+
and a.dest in ("json", "verbose", "api_url", "api_key", "instance_id", "rc_dir", "cluster_id")
|
|
343
|
+
)
|
|
344
|
+
]
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _command_usage(parser: argparse.ArgumentParser, path: tuple[str, ...]) -> str:
|
|
348
|
+
usage_parts = [CLI_NAME, *path]
|
|
349
|
+
sub = _subparsers_action(parser)
|
|
350
|
+
for action in parser._actions:
|
|
351
|
+
if action.dest in _INTERNAL_DESTS or action.option_strings:
|
|
352
|
+
continue
|
|
353
|
+
if action.metavar:
|
|
354
|
+
usage_parts.append(f"<{action.metavar}>")
|
|
355
|
+
elif action.nargs in (argparse.ONE_OR_MORE, "+"):
|
|
356
|
+
usage_parts.append("<…>")
|
|
357
|
+
else:
|
|
358
|
+
usage_parts.append(f"<{action.dest}>")
|
|
359
|
+
if sub is not None and not any(
|
|
360
|
+
a.dest not in _INTERNAL_DESTS and not a.option_strings for a in parser._actions
|
|
361
|
+
):
|
|
362
|
+
usage_suffix = " <command> [options]"
|
|
363
|
+
else:
|
|
364
|
+
usage_suffix = " [options]"
|
|
365
|
+
return " ".join(usage_parts) + usage_suffix
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _command_help_document(
|
|
369
|
+
root: argparse.ArgumentParser,
|
|
370
|
+
path: tuple[str, ...],
|
|
371
|
+
parser: argparse.ArgumentParser,
|
|
372
|
+
) -> dict[str, Any]:
|
|
373
|
+
command = " ".join(path)
|
|
374
|
+
sub = _subparsers_action(parser)
|
|
375
|
+
subcommands: list[dict[str, str]] = []
|
|
376
|
+
if sub is not None:
|
|
377
|
+
for name, child in sub.choices.items():
|
|
378
|
+
if name in _HIDDEN_COMMAND_NAMES:
|
|
379
|
+
continue
|
|
380
|
+
label = f"{command} {name}".strip()
|
|
381
|
+
subcommands.append(
|
|
382
|
+
{
|
|
383
|
+
"name": label,
|
|
384
|
+
"description": _choice_help(sub, name, child),
|
|
385
|
+
}
|
|
386
|
+
)
|
|
387
|
+
return {
|
|
388
|
+
"kind": "command",
|
|
389
|
+
"cli": CLI_NAME,
|
|
390
|
+
"command": command,
|
|
391
|
+
"title": _parser_title(root, path, parser),
|
|
392
|
+
"usage": _command_usage(parser, path),
|
|
393
|
+
"options": [
|
|
394
|
+
{"name": _option_label(action), "description": action.help or ""}
|
|
395
|
+
for action in _local_command_options(parser)
|
|
396
|
+
],
|
|
397
|
+
"subcommands": subcommands,
|
|
398
|
+
"see_also": f"{CLI_NAME} help options",
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def build_help_document(root: argparse.ArgumentParser, path: tuple[str, ...]) -> dict[str, Any]:
|
|
403
|
+
"""Single source of truth for help — rendered as human text or emitted as JSON."""
|
|
404
|
+
path = normalize_help_path(path)
|
|
405
|
+
if path == (OPTIONS_HELP_TOPIC,):
|
|
406
|
+
return _global_options_document(root)
|
|
407
|
+
if not path:
|
|
408
|
+
return _overview_document(root)
|
|
409
|
+
parser = navigate_parser(root, path)
|
|
410
|
+
if parser is None:
|
|
411
|
+
topic = " ".join(path)
|
|
412
|
+
return {
|
|
413
|
+
"kind": "unknown",
|
|
414
|
+
"cli": CLI_NAME,
|
|
415
|
+
"topic": list(path),
|
|
416
|
+
"message": f"Unknown help topic: {topic} (try `{CLI_NAME}` or `{CLI_NAME} help options`).",
|
|
417
|
+
}
|
|
418
|
+
return _command_help_document(root, path, parser)
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _render_overview_document(doc: dict[str, Any], stream: IO[str], style: _Style) -> None:
|
|
422
|
+
_emit(stream)
|
|
423
|
+
_emit(
|
|
424
|
+
stream,
|
|
425
|
+
f"{style.green_bold(doc['cli'])} {style.dim(doc['version'])} {style.bold('—')} {style.bold(doc['title'])}",
|
|
426
|
+
)
|
|
427
|
+
_emit(stream, style.dim(doc["tagline"]))
|
|
428
|
+
_emit(stream, style.dim(doc["credentials"]))
|
|
429
|
+
_emit(stream)
|
|
430
|
+
cli_name = doc["cli"]
|
|
431
|
+
_emit(
|
|
432
|
+
stream,
|
|
433
|
+
f"{style.green_bold('Usage:')} {style.bold(doc['usage'])} "
|
|
434
|
+
f"{style.dim(f'— a bare `{cli_name}` prints this help')}",
|
|
435
|
+
)
|
|
436
|
+
_emit(stream)
|
|
437
|
+
_emit(stream, style.green_bold("Commands:"))
|
|
438
|
+
for entry in doc["prefix_commands"]:
|
|
439
|
+
_emit(stream, _help_row(style, entry["name"], entry["description"]))
|
|
440
|
+
for section in doc["sections"]:
|
|
441
|
+
_emit(stream)
|
|
442
|
+
_emit(stream, style.green_bold(f"{section['name']}:"))
|
|
443
|
+
for entry in section["commands"]:
|
|
444
|
+
_emit(stream, _help_row(style, entry["name"], entry["description"]))
|
|
445
|
+
for note in doc["notes"]:
|
|
446
|
+
_emit(stream)
|
|
447
|
+
_emit(stream, style.dim(note))
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _render_options_document(doc: dict[str, Any], stream: IO[str], style: _Style) -> None:
|
|
451
|
+
_emit(stream)
|
|
452
|
+
_emit(
|
|
453
|
+
stream,
|
|
454
|
+
f"{style.green_bold('help options')} {style.bold('—')} {style.bold(doc['title'])}",
|
|
455
|
+
)
|
|
456
|
+
_emit(stream)
|
|
457
|
+
_emit(stream, f"{style.green_bold('Usage:')} {style.bold(doc['usage'])}")
|
|
458
|
+
_emit(stream, style.dim(doc["note"]))
|
|
459
|
+
_emit(stream)
|
|
460
|
+
_emit(stream, style.green_bold("Options:"))
|
|
461
|
+
for option in doc["options"]:
|
|
462
|
+
lines = option["description"]
|
|
463
|
+
line1 = lines[0] if lines else ""
|
|
464
|
+
line2 = lines[1] if len(lines) > 1 else ""
|
|
465
|
+
_emit_option_two_lines(stream, style, option["name"], line1, line2)
|
|
466
|
+
_emit(stream)
|
|
467
|
+
_emit(stream, f"{style.green_bold('More help:')} {style.dim(f'`{doc['see_also'][0]}`.')}")
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _render_command_document(doc: dict[str, Any], stream: IO[str], style: _Style) -> None:
|
|
471
|
+
command = doc["command"]
|
|
472
|
+
_emit(stream)
|
|
473
|
+
_emit(
|
|
474
|
+
stream,
|
|
475
|
+
f"{style.green_bold(command)} {style.bold('—')} {style.bold(doc['title'])}",
|
|
476
|
+
)
|
|
477
|
+
_emit(stream)
|
|
478
|
+
_emit(stream, f"{style.green_bold('Usage:')} {style.bold(doc['usage'])}")
|
|
479
|
+
_emit(stream)
|
|
480
|
+
if doc["options"]:
|
|
481
|
+
_emit(stream, style.green_bold("Options:"))
|
|
482
|
+
for option in doc["options"]:
|
|
483
|
+
_emit(stream, _help_row(style, option["name"], option["description"]))
|
|
484
|
+
if doc["subcommands"]:
|
|
485
|
+
_emit(stream)
|
|
486
|
+
_emit(stream, style.green_bold("Subcommands:"))
|
|
487
|
+
for entry in doc["subcommands"]:
|
|
488
|
+
_emit(stream, _help_row(style, entry["name"], entry["description"]))
|
|
489
|
+
_emit(stream)
|
|
490
|
+
_emit(
|
|
491
|
+
stream,
|
|
492
|
+
style.dim(f"Global flags (`--api-url`, `--api-key`, `--json`, …): `{doc['see_also']}`.")
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _render_unknown_document(doc: dict[str, Any], stream: IO[str], style: _Style) -> None:
|
|
497
|
+
_emit(stream, doc["message"])
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def render_help_document(doc: dict[str, Any], *, stream: IO[str] | None = None) -> None:
|
|
501
|
+
"""Render a :func:`build_help_document` payload for human terminals."""
|
|
502
|
+
out = stream if stream is not None else sys.stdout
|
|
503
|
+
style = _Style(_use_color(out))
|
|
504
|
+
kind = doc.get("kind")
|
|
505
|
+
if kind == "overview":
|
|
506
|
+
_render_overview_document(doc, out, style)
|
|
507
|
+
elif kind == "options":
|
|
508
|
+
_render_options_document(doc, out, style)
|
|
509
|
+
elif kind == "command":
|
|
510
|
+
_render_command_document(doc, out, style)
|
|
511
|
+
else:
|
|
512
|
+
_render_unknown_document(doc, out, style)
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
class CliParseError(Exception):
|
|
516
|
+
"""Raised instead of argparse ``SystemExit`` for human-friendly usage errors."""
|
|
517
|
+
|
|
518
|
+
def __init__(self, message: str) -> None:
|
|
519
|
+
self.message = message
|
|
520
|
+
super().__init__(message)
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
_INVALID_CHOICE_RE = re.compile(
|
|
524
|
+
r"argument (?P<arg>\S+): invalid choice: '(?P<choice>[^']+)'(?: \(choose from (?P<choices>[^)]+)\))?"
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _format_cli_parse_error(message: str) -> str:
|
|
529
|
+
match = _INVALID_CHOICE_RE.search(message)
|
|
530
|
+
if match:
|
|
531
|
+
arg = match.group("arg")
|
|
532
|
+
choice = match.group("choice")
|
|
533
|
+
if arg == "command":
|
|
534
|
+
return (
|
|
535
|
+
f'{CLI_NAME}: unknown command "{choice}"\n\n'
|
|
536
|
+
f"Run `{CLI_NAME}` for an overview, or `{CLI_NAME} help <topic>`."
|
|
537
|
+
)
|
|
538
|
+
choices_raw = match.group("choices") or ""
|
|
539
|
+
choices = [part.strip().strip("'\"") for part in choices_raw.split(",") if part.strip()]
|
|
540
|
+
if choices:
|
|
541
|
+
lines = [f'{CLI_NAME}: invalid {arg.replace("_", " ")} "{choice}"', "", "Choose one of:"]
|
|
542
|
+
lines.extend(f" {name}" for name in choices)
|
|
543
|
+
return "\n".join(lines)
|
|
544
|
+
return f'{CLI_NAME}: invalid {arg.replace("_", " ")} "{choice}"'
|
|
545
|
+
|
|
546
|
+
if message.startswith("the following arguments are required:"):
|
|
547
|
+
missing = message.split(":", 1)[1].strip()
|
|
548
|
+
return f"{CLI_NAME}: missing required argument {missing}"
|
|
549
|
+
|
|
550
|
+
if message.startswith("unrecognized arguments:"):
|
|
551
|
+
extra = message.split(":", 1)[1].strip()
|
|
552
|
+
return f"{CLI_NAME}: unrecognized argument(s): {extra}"
|
|
553
|
+
|
|
554
|
+
return f"{CLI_NAME}: {message}"
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
class XmemCliArgumentParser(argparse.ArgumentParser):
|
|
558
|
+
"""ArgumentParser that raises :class:`CliParseError` instead of dumping usage."""
|
|
559
|
+
|
|
560
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
561
|
+
kwargs.setdefault("usage", argparse.SUPPRESS)
|
|
562
|
+
kwargs.setdefault("add_help", False)
|
|
563
|
+
super().__init__(*args, **kwargs)
|
|
564
|
+
|
|
565
|
+
def error(self, message: str) -> NoReturn:
|
|
566
|
+
raise CliParseError(_format_cli_parse_error(message))
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def print_parse_error(message: str, *, stream: IO[str] | None = None) -> None:
|
|
570
|
+
out = stream if stream is not None else sys.stderr
|
|
571
|
+
style = _Style(_use_color(out))
|
|
572
|
+
for line in message.splitlines():
|
|
573
|
+
if not line:
|
|
574
|
+
_emit(out)
|
|
575
|
+
continue
|
|
576
|
+
if line.endswith(":") and line[:-1] in ("Shortcuts", "Choose one of"):
|
|
577
|
+
_emit(out, style.green_bold(line))
|
|
578
|
+
continue
|
|
579
|
+
if line.startswith(f"{CLI_NAME}:"):
|
|
580
|
+
_emit(out, style.bold(line))
|
|
581
|
+
continue
|
|
582
|
+
if line.startswith(" "):
|
|
583
|
+
_emit(out, line)
|
|
584
|
+
continue
|
|
585
|
+
if line.startswith("Run `"):
|
|
586
|
+
_emit(out, style.dim(line))
|
|
587
|
+
continue
|
|
588
|
+
_emit(out, line)
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def _strip_trailing_globals(argv: list[str]) -> list[str]:
|
|
592
|
+
"""Drop trailing ``--json`` / global flags from a help topic path."""
|
|
593
|
+
out = list(argv)
|
|
594
|
+
while out:
|
|
595
|
+
tok = out[-1]
|
|
596
|
+
if tok in _GLOBAL_STORE_TRUE_FLAGS:
|
|
597
|
+
out.pop()
|
|
598
|
+
continue
|
|
599
|
+
if tok in _GLOBAL_OPTION_FLAGS:
|
|
600
|
+
out.pop()
|
|
601
|
+
if out and not out[-1].startswith("-"):
|
|
602
|
+
out.pop()
|
|
603
|
+
continue
|
|
604
|
+
break
|
|
605
|
+
return out
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def help_target(argv: list[str]) -> tuple[str, ...] | None:
|
|
609
|
+
"""Return the command path before ``--help``/``-h``, ``()`` for overview, or ``None``."""
|
|
610
|
+
if not argv:
|
|
611
|
+
return ()
|
|
612
|
+
if argv in (["-h"], ["--help"]):
|
|
613
|
+
return ()
|
|
614
|
+
if argv[0] == "help":
|
|
615
|
+
return normalize_help_path(tuple(_strip_trailing_globals(argv[1:])))
|
|
616
|
+
for index, token in enumerate(argv):
|
|
617
|
+
if token in ("-h", "--help"):
|
|
618
|
+
return normalize_help_path(tuple(_strip_trailing_globals(argv[:index])))
|
|
619
|
+
return None
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
# Root-level flags that may appear before the subcommand (``xmemcli --api-url … write …``).
|
|
623
|
+
_GLOBAL_OPTION_FLAGS = frozenset(
|
|
624
|
+
{
|
|
625
|
+
"--api-url",
|
|
626
|
+
"--api-key",
|
|
627
|
+
"--instance-id",
|
|
628
|
+
"--rc-dir",
|
|
629
|
+
"--cluster-id",
|
|
630
|
+
}
|
|
631
|
+
)
|
|
632
|
+
_GLOBAL_STORE_TRUE_FLAGS = frozenset({"--json", "--verbose"})
|
|
633
|
+
|
|
634
|
+
# Public aliases for xmem._parse_argv (argparse has no stable global-flag API).
|
|
635
|
+
GLOBAL_OPTION_FLAGS = _GLOBAL_OPTION_FLAGS
|
|
636
|
+
GLOBAL_STORE_TRUE_FLAGS = _GLOBAL_STORE_TRUE_FLAGS
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def collect_global_flags(argv: list[str]) -> tuple[list[str], list[str]]:
|
|
640
|
+
"""Split *argv* into root global flags (any position) and the remaining tokens."""
|
|
641
|
+
globals_out: list[str] = []
|
|
642
|
+
command: list[str] = []
|
|
643
|
+
i = 0
|
|
644
|
+
while i < len(argv):
|
|
645
|
+
tok = argv[i]
|
|
646
|
+
if tok in _GLOBAL_STORE_TRUE_FLAGS:
|
|
647
|
+
globals_out.append(tok)
|
|
648
|
+
i += 1
|
|
649
|
+
continue
|
|
650
|
+
if tok in _GLOBAL_OPTION_FLAGS:
|
|
651
|
+
globals_out.append(tok)
|
|
652
|
+
if i + 1 < len(argv) and not argv[i + 1].startswith("-"):
|
|
653
|
+
globals_out.append(argv[i + 1])
|
|
654
|
+
i += 2
|
|
655
|
+
else:
|
|
656
|
+
i += 1
|
|
657
|
+
continue
|
|
658
|
+
command.append(tok)
|
|
659
|
+
i += 1
|
|
660
|
+
return globals_out, command
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def argv_without_globals(argv: list[str]) -> list[str]:
|
|
664
|
+
"""Drop *leading* root-level global flags so ``--rc-dir …`` alone can mean "show overview".
|
|
665
|
+
|
|
666
|
+
Trailing globals (e.g. ``read q --json``) are preserved so machine mode stays active.
|
|
667
|
+
"""
|
|
668
|
+
out: list[str] = []
|
|
669
|
+
i = 0
|
|
670
|
+
while i < len(argv):
|
|
671
|
+
tok = argv[i]
|
|
672
|
+
if tok in _GLOBAL_STORE_TRUE_FLAGS:
|
|
673
|
+
i += 1
|
|
674
|
+
continue
|
|
675
|
+
if tok in _GLOBAL_OPTION_FLAGS:
|
|
676
|
+
i += 2 if i + 1 < len(argv) and not argv[i + 1].startswith("-") else 1
|
|
677
|
+
continue
|
|
678
|
+
break
|
|
679
|
+
while i < len(argv):
|
|
680
|
+
out.append(argv[i])
|
|
681
|
+
i += 1
|
|
682
|
+
return out
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def navigate_parser(root: argparse.ArgumentParser, path: tuple[str, ...]) -> argparse.ArgumentParser | None:
|
|
686
|
+
current = root
|
|
687
|
+
for part in path:
|
|
688
|
+
sub = _subparsers_action(current)
|
|
689
|
+
if sub is None or part not in sub.choices:
|
|
690
|
+
return None
|
|
691
|
+
current = sub.choices[part]
|
|
692
|
+
return current
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def _choice_help(sub: argparse._SubParsersAction, name: str, child: argparse.ArgumentParser) -> str:
|
|
696
|
+
for pseudo in sub._choices_actions:
|
|
697
|
+
if pseudo.dest == name:
|
|
698
|
+
return pseudo.help or ""
|
|
699
|
+
return child.description or ""
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def _parser_title(root: argparse.ArgumentParser, path: tuple[str, ...], parser: argparse.ArgumentParser) -> str:
|
|
703
|
+
if parser.description:
|
|
704
|
+
return parser.description
|
|
705
|
+
if not path:
|
|
706
|
+
return ""
|
|
707
|
+
parent = navigate_parser(root, path[:-1]) if len(path) > 1 else root
|
|
708
|
+
sub = _subparsers_action(parent) if parent is not None else None
|
|
709
|
+
if sub is not None:
|
|
710
|
+
return _choice_help(sub, path[-1], parser)
|
|
711
|
+
return " ".join(path)
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def print_help(
|
|
715
|
+
root: argparse.ArgumentParser,
|
|
716
|
+
path: tuple[str, ...],
|
|
717
|
+
*,
|
|
718
|
+
stream: IO[str] | None = None,
|
|
719
|
+
) -> None:
|
|
720
|
+
render_help_document(build_help_document(root, path), stream=stream)
|