eosh 0.2.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.
- eosh/__init__.py +34 -0
- eosh/__main__.py +23 -0
- eosh/_config.py +367 -0
- eosh/colors.py +63 -0
- eosh/commands.py +785 -0
- eosh/completion.py +952 -0
- eosh/completion_cache.py +77 -0
- eosh/context.py +189 -0
- eosh/decorators/__init__.py +263 -0
- eosh/decorators/bg.py +39 -0
- eosh/decorators/quiet.py +39 -0
- eosh/decorators/retry.py +46 -0
- eosh/decorators/time.py +44 -0
- eosh/decorators/watch.py +660 -0
- eosh/history.py +36 -0
- eosh/lineedit.py +1442 -0
- eosh/notify.py +404 -0
- eosh/parsing.py +226 -0
- eosh/paths.py +18 -0
- eosh/pipeline.py +668 -0
- eosh/process.py +465 -0
- eosh/prompt.py +77 -0
- eosh/recipes/__init__.py +208 -0
- eosh/recipes/_awsut_agentcore/__init__.py +46 -0
- eosh/recipes/_awsut_agentcore/harness.py +721 -0
- eosh/recipes/_awsut_agentcore/memory.py +1209 -0
- eosh/recipes/_awsut_agentcore/render.py +337 -0
- eosh/recipes/_awsut_common.py +533 -0
- eosh/recipes/_awsut_sagemaker/__init__.py +36 -0
- eosh/recipes/_awsut_sagemaker/hub.py +866 -0
- eosh/recipes/_awsut_sagemaker/hyperpod.py +1948 -0
- eosh/recipes/_awsut_sagemaker/jobs.py +1041 -0
- eosh/recipes/_awsut_sagemaker/render.py +300 -0
- eosh/recipes/_awsut_sagemaker/studio.py +1584 -0
- eosh/recipes/_missing.py +168 -0
- eosh/recipes/aws.py +239 -0
- eosh/recipes/awsut.py +1649 -0
- eosh/recipes/chmod.py +58 -0
- eosh/recipes/chown.py +117 -0
- eosh/recipes/cp.py +80 -0
- eosh/recipes/curl.py +108 -0
- eosh/recipes/df.py +33 -0
- eosh/recipes/du.py +82 -0
- eosh/recipes/find.py +60 -0
- eosh/recipes/git.py +295 -0
- eosh/recipes/grep.py +54 -0
- eosh/recipes/kill.py +92 -0
- eosh/recipes/ls.py +44 -0
- eosh/recipes/lsof.py +56 -0
- eosh/recipes/make.py +219 -0
- eosh/recipes/mv.py +52 -0
- eosh/recipes/ps.py +73 -0
- eosh/recipes/rm.py +55 -0
- eosh/recipes/rsync.py +143 -0
- eosh/recipes/scp.py +97 -0
- eosh/recipes/ssh.py +92 -0
- eosh/recipes/tail.py +23 -0
- eosh/recipes/tar.py +170 -0
- eosh/recipes/terraform.py +374 -0
- eosh/recipes/top.py +84 -0
- eosh/recipes/unzip.py +96 -0
- eosh/recipes/zip.py +53 -0
- eosh/scrollbar.py +122 -0
- eosh/shell.py +4253 -0
- eosh/terminal.py +446 -0
- eosh/tui.py +1082 -0
- eosh/variables.py +253 -0
- eosh-0.2.0.dist-info/METADATA +637 -0
- eosh-0.2.0.dist-info/RECORD +73 -0
- eosh-0.2.0.dist-info/WHEEL +5 -0
- eosh-0.2.0.dist-info/entry_points.txt +2 -0
- eosh-0.2.0.dist-info/licenses/LICENSE +21 -0
- eosh-0.2.0.dist-info/top_level.txt +1 -0
eosh/commands.py
ADDED
|
@@ -0,0 +1,785 @@
|
|
|
1
|
+
"""Command registry, @command decorator, CmdParser, and arg() descriptor."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import inspect
|
|
7
|
+
import sys
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Callable
|
|
10
|
+
|
|
11
|
+
from .completion import Completer, ChoiceCompleter, OptionsCompleter
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class _HelpOrError(Exception):
|
|
15
|
+
"""Internal sentinel raised by CmdParser to avoid sys.exit()."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CmdParser(argparse.ArgumentParser):
|
|
19
|
+
"""ArgumentParser that is safe to use inside eosh commands.
|
|
20
|
+
|
|
21
|
+
Standard ``ArgumentParser`` calls ``sys.exit()`` on ``--help`` and on
|
|
22
|
+
parse errors, which would terminate the shell. ``CmdParser`` intercepts
|
|
23
|
+
both and returns ``None`` from :meth:`parse_args` instead — after printing
|
|
24
|
+
the help or error message exactly as argparse normally would.
|
|
25
|
+
|
|
26
|
+
Argparse handles combined short boolean flags automatically (``-nv`` is
|
|
27
|
+
treated as ``-n -v``), so commands that expose eosh's multi-select
|
|
28
|
+
option TUI work without any extra effort.
|
|
29
|
+
|
|
30
|
+
Typical usage inside a command function::
|
|
31
|
+
|
|
32
|
+
def deploy(*args):
|
|
33
|
+
parser = CmdParser("deploy")
|
|
34
|
+
parser.add_argument("environment", choices=["prod", "staging", "dev"])
|
|
35
|
+
parser.add_argument("-n", "--dry-run", action="store_true")
|
|
36
|
+
parser.add_argument("-t", "--timeout", type=int, default=60)
|
|
37
|
+
ns = parser.parse_args(args)
|
|
38
|
+
if ns is None:
|
|
39
|
+
return # error or --help already printed
|
|
40
|
+
# use ns.environment, ns.dry_run, ns.timeout, ...
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def exit(self, status: int = 0, message: str | None = None) -> None:
|
|
44
|
+
# Called by --help and --version; don't let it kill the shell.
|
|
45
|
+
if message:
|
|
46
|
+
print(message, end="", file=sys.stderr)
|
|
47
|
+
raise _HelpOrError()
|
|
48
|
+
|
|
49
|
+
def error(self, message: str) -> None:
|
|
50
|
+
# Called for parse errors (missing args, unknown flags, wrong types).
|
|
51
|
+
self.print_usage(sys.stderr)
|
|
52
|
+
print(f"{self.prog}: error: {message}", file=sys.stderr)
|
|
53
|
+
raise _HelpOrError()
|
|
54
|
+
|
|
55
|
+
def parse_args(self, args=None, namespace=None):
|
|
56
|
+
try:
|
|
57
|
+
return super().parse_args(args, namespace)
|
|
58
|
+
except _HelpOrError:
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class Arg:
|
|
64
|
+
"""Descriptor for one positional argument or flag.
|
|
65
|
+
|
|
66
|
+
Created with :func:`arg`. ``kwargs`` are forwarded verbatim to
|
|
67
|
+
``argparse.ArgumentParser.add_argument()``; ``completer`` is a
|
|
68
|
+
eosh-specific completion hint that is consumed by the registry and
|
|
69
|
+
never passed to argparse.
|
|
70
|
+
"""
|
|
71
|
+
names: tuple[str, ...]
|
|
72
|
+
kwargs: dict
|
|
73
|
+
completer: Completer | None = None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def arg(*names: str, completer: Completer | None = None, **kwargs) -> Arg:
|
|
77
|
+
"""Declare one positional argument or flag for a ``params=`` command.
|
|
78
|
+
|
|
79
|
+
Keyword arguments mirror ``argparse.add_argument()`` exactly, plus one
|
|
80
|
+
extra eosh-specific keyword:
|
|
81
|
+
|
|
82
|
+
``completer``
|
|
83
|
+
A :class:`~eosh.completion.Completer` instance used for TAB
|
|
84
|
+
completion of this argument's *value*.
|
|
85
|
+
|
|
86
|
+
- For a **positional arg**, it completes the arg itself. If omitted
|
|
87
|
+
and ``choices=`` is set, a :class:`ChoiceCompleter` is derived
|
|
88
|
+
automatically.
|
|
89
|
+
- For a **value-taking flag** (``-t``, ``--timeout``, …), it
|
|
90
|
+
completes the value the user types after the flag.
|
|
91
|
+
- Ignored for **boolean flags** (``action="store_true"`` etc.).
|
|
92
|
+
|
|
93
|
+
Examples::
|
|
94
|
+
|
|
95
|
+
arg("environment", choices=["prod", "staging", "dev"])
|
|
96
|
+
arg("instance", completer=CallbackCompleter(fetch_instances))
|
|
97
|
+
arg("-n", "--dry-run", action="store_true", help="skip execution")
|
|
98
|
+
arg("-t", "--timeout", type=int, default=60, metavar="SECONDS",
|
|
99
|
+
help="timeout in seconds",
|
|
100
|
+
completer=ChoiceCompleter(["30", "60", "120", "300"]))
|
|
101
|
+
|
|
102
|
+
The resulting :class:`Arg` is passed to ``@registry.command(params=[…])``.
|
|
103
|
+
The registry derives both the argparse parser **and** the TAB-completion
|
|
104
|
+
dict from the same list.
|
|
105
|
+
"""
|
|
106
|
+
return Arg(names=names, kwargs=kwargs, completer=completer)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# argparse actions that consume a following value token
|
|
110
|
+
_VALUE_ACTIONS = {"store", "append", "extend"}
|
|
111
|
+
|
|
112
|
+
# Tokens with these prefixes are treated as flags by the completion engine.
|
|
113
|
+
# ``-`` covers short and long flags (-c, --verbose). ``+`` is rare but used
|
|
114
|
+
# by tools like ``lsof`` for SysV-style enable/disable pairs (-c / +c).
|
|
115
|
+
_FLAG_PREFIXES = ("-", "+")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _is_flag_name(name: str) -> bool:
|
|
119
|
+
return name.startswith(_FLAG_PREFIXES)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# Sentinel key meaning "any positional index ≥ this slot". Used for arg() with
|
|
123
|
+
# nargs="*" or nargs="+" — that completer becomes the source of truth for every
|
|
124
|
+
# positional slot from its declared position onward.
|
|
125
|
+
WILDCARD = ...
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _build_completers(params: list[Arg]) -> dict:
|
|
129
|
+
"""Derive a completers dict from a list of :class:`Arg` descriptors.
|
|
130
|
+
|
|
131
|
+
Rules:
|
|
132
|
+
|
|
133
|
+
* **Positional arg** — if ``completer=`` is set, use it; otherwise if
|
|
134
|
+
``choices=`` is set derive a :class:`ChoiceCompleter` automatically.
|
|
135
|
+
Keyed by zero-based positional index.
|
|
136
|
+
|
|
137
|
+
* **Wildcard positional** — when ``nargs="*"`` or ``nargs="+"`` is set,
|
|
138
|
+
the completer is stored under the :data:`WILDCARD` (``...``) key instead
|
|
139
|
+
of an integer. It serves every positional slot from that point on, so
|
|
140
|
+
a single ``arg("targets", nargs="*", ...)`` replaces the old ``0: …,
|
|
141
|
+
1: …, 2: …`` boilerplate.
|
|
142
|
+
|
|
143
|
+
* **Flag** — all flag names are collected into an
|
|
144
|
+
:class:`OptionsCompleter` under the ``None`` key. ``help=`` text
|
|
145
|
+
becomes the description shown in the completion menu. Value-taking
|
|
146
|
+
flags (action="store" / "append" / "extend") are also registered in
|
|
147
|
+
the ``args=`` dict of the ``OptionsCompleter`` with their ``metavar``
|
|
148
|
+
and optional ``completer`` for the value.
|
|
149
|
+
"""
|
|
150
|
+
all_options: dict[str, str] = {}
|
|
151
|
+
args_hints: dict[str, str | tuple] = {}
|
|
152
|
+
pos_completers: dict = {}
|
|
153
|
+
pos_idx = 0
|
|
154
|
+
|
|
155
|
+
for a in params:
|
|
156
|
+
is_flag = _is_flag_name(a.names[0])
|
|
157
|
+
|
|
158
|
+
if not is_flag:
|
|
159
|
+
# ── positional arg ────────────────────────────────────────────
|
|
160
|
+
comp = a.completer
|
|
161
|
+
if comp is None and "choices" in a.kwargs:
|
|
162
|
+
comp = ChoiceCompleter(list(a.kwargs["choices"]))
|
|
163
|
+
if comp is not None:
|
|
164
|
+
if a.kwargs.get("nargs") in ("*", "+"):
|
|
165
|
+
pos_completers[WILDCARD] = comp
|
|
166
|
+
else:
|
|
167
|
+
pos_completers[pos_idx] = comp
|
|
168
|
+
pos_idx += 1
|
|
169
|
+
|
|
170
|
+
else:
|
|
171
|
+
# ── flag ──────────────────────────────────────────────────────
|
|
172
|
+
desc = a.kwargs.get("help", "")
|
|
173
|
+
for name in a.names:
|
|
174
|
+
all_options[name] = desc
|
|
175
|
+
|
|
176
|
+
action = a.kwargs.get("action", "store")
|
|
177
|
+
if action in _VALUE_ACTIONS:
|
|
178
|
+
# Derive metavar from --long-name → LONG_NAME, or metavar=
|
|
179
|
+
long_names = [n for n in a.names if n.startswith("--")]
|
|
180
|
+
dest = (long_names[0].lstrip("-").replace("-", "_")
|
|
181
|
+
if long_names else a.names[0].lstrip("".join(_FLAG_PREFIXES)))
|
|
182
|
+
metavar = a.kwargs.get("metavar", dest.upper())
|
|
183
|
+
hint: str | tuple = (metavar, a.completer) if a.completer else metavar
|
|
184
|
+
for name in a.names:
|
|
185
|
+
args_hints[name] = hint
|
|
186
|
+
|
|
187
|
+
result: dict = {}
|
|
188
|
+
if all_options:
|
|
189
|
+
result[None] = OptionsCompleter(all_options, args=args_hints or None)
|
|
190
|
+
result.update(pos_completers)
|
|
191
|
+
return result
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def get_positional_completer(completers: dict, pos_idx: int) -> Completer | None:
|
|
195
|
+
"""Look up a positional completer by index, falling back to the wildcard slot.
|
|
196
|
+
|
|
197
|
+
External recipes (and Python commands using ``nargs="*"``) commonly attach
|
|
198
|
+
a single completer at :data:`WILDCARD` to serve every positional slot.
|
|
199
|
+
Callers should use this helper rather than ``completers.get(pos_idx)``
|
|
200
|
+
directly so the wildcard fallback is honoured uniformly.
|
|
201
|
+
"""
|
|
202
|
+
if pos_idx in completers:
|
|
203
|
+
return completers[pos_idx]
|
|
204
|
+
return completers.get(WILDCARD)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _build_usage(cmd_name: str, params: list[Arg]) -> str:
|
|
208
|
+
"""Generate a compact usage line from a params list.
|
|
209
|
+
|
|
210
|
+
Format mirrors conventional shell usage notation:
|
|
211
|
+
|
|
212
|
+
* Required positional → ``<name>``
|
|
213
|
+
* Optional positional (``nargs="?"`` or ``"*"``) → ``[name]``
|
|
214
|
+
* Boolean flag → ``[-n]`` (shortest form)
|
|
215
|
+
* Value-taking flag → ``[-n METAVAR]`` (shortest form + metavar)
|
|
216
|
+
"""
|
|
217
|
+
parts = [cmd_name]
|
|
218
|
+
for a in params:
|
|
219
|
+
is_flag = _is_flag_name(a.names[0])
|
|
220
|
+
if not is_flag:
|
|
221
|
+
is_optional = a.kwargs.get("nargs") in ("?", "*")
|
|
222
|
+
dest = a.names[0]
|
|
223
|
+
parts.append(f"[{dest}]" if is_optional else f"<{dest}>")
|
|
224
|
+
else:
|
|
225
|
+
short = next((n for n in a.names if len(n) == 2), a.names[0])
|
|
226
|
+
action = a.kwargs.get("action", "store")
|
|
227
|
+
if action in _VALUE_ACTIONS:
|
|
228
|
+
long_names = [n for n in a.names if n.startswith("--")]
|
|
229
|
+
dest = (long_names[0].lstrip("-").replace("-", "_")
|
|
230
|
+
if long_names else a.names[0].lstrip("".join(_FLAG_PREFIXES)))
|
|
231
|
+
metavar = a.kwargs.get("metavar", dest.upper())
|
|
232
|
+
parts.append(f"[{short} {metavar}]")
|
|
233
|
+
else:
|
|
234
|
+
parts.append(f"[{short}]")
|
|
235
|
+
return "Usage: " + " ".join(parts)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _effective_description(help: str | None, func: Callable) -> str:
|
|
239
|
+
"""Return the description string: explicit *help* wins, then the docstring."""
|
|
240
|
+
if help is not None:
|
|
241
|
+
return help
|
|
242
|
+
return inspect.getdoc(func) or ""
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _build_help_text(
|
|
246
|
+
help: str | None,
|
|
247
|
+
func: Callable,
|
|
248
|
+
cmd_name: str,
|
|
249
|
+
params: list[Arg] | None,
|
|
250
|
+
) -> str:
|
|
251
|
+
"""Assemble the full help text stored on a Command.
|
|
252
|
+
|
|
253
|
+
Structure when both *help* and *params* are present::
|
|
254
|
+
|
|
255
|
+
<description>
|
|
256
|
+
|
|
257
|
+
Usage: cmd <pos> [opt] [-f] [-v VAL]
|
|
258
|
+
|
|
259
|
+
The first line is always the short description (used in command listings).
|
|
260
|
+
When neither *help* nor *params* is given, falls back to the function
|
|
261
|
+
signature as a minimal hint (legacy behaviour).
|
|
262
|
+
"""
|
|
263
|
+
desc = _effective_description(help, func)
|
|
264
|
+
if params is not None:
|
|
265
|
+
usage = _build_usage(cmd_name, params)
|
|
266
|
+
return (desc + "\n\n" + usage).strip() if desc else usage
|
|
267
|
+
return desc or _signature_help(func, cmd_name)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _signature_help(func: Callable, cmd_name: str) -> str:
|
|
271
|
+
"""Generate a ``Usage: cmd_name [args]`` hint from the function signature.
|
|
272
|
+
|
|
273
|
+
Shown as the fallback help text when a command has no docstring.
|
|
274
|
+
Required parameters are shown as ``<name>``, optional ones as ``[name]``,
|
|
275
|
+
and ``*args`` as ``[args...]``. Returns an empty string when the signature
|
|
276
|
+
cannot be determined.
|
|
277
|
+
"""
|
|
278
|
+
try:
|
|
279
|
+
sig = inspect.signature(func)
|
|
280
|
+
except (ValueError, TypeError):
|
|
281
|
+
return ""
|
|
282
|
+
|
|
283
|
+
parts: list[str] = []
|
|
284
|
+
for param_name, param in sig.parameters.items():
|
|
285
|
+
if param.kind == param.VAR_POSITIONAL:
|
|
286
|
+
parts.append(f"[{param_name}...]")
|
|
287
|
+
elif param.kind == param.VAR_KEYWORD:
|
|
288
|
+
pass # **kwargs — not useful to surface in usage
|
|
289
|
+
elif param.default is inspect.Parameter.empty:
|
|
290
|
+
parts.append(f"<{param_name}>")
|
|
291
|
+
else:
|
|
292
|
+
parts.append(f"[{param_name}]")
|
|
293
|
+
|
|
294
|
+
usage = " ".join([cmd_name] + parts)
|
|
295
|
+
return f"Usage: {usage}"
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
@dataclass
|
|
299
|
+
class Command:
|
|
300
|
+
"""A node in the command tree.
|
|
301
|
+
|
|
302
|
+
Roles are inferred from structure, not declared:
|
|
303
|
+
|
|
304
|
+
* Has handler, no children → Python leaf (parses args, calls handler).
|
|
305
|
+
* Has children, no handler → interior group (prints child list when run bare).
|
|
306
|
+
* Tree contains no handler anywhere → external recipe (completion only;
|
|
307
|
+
execution shells out via PTY).
|
|
308
|
+
|
|
309
|
+
Both Python commands and external recipes are built through the same
|
|
310
|
+
``.command()`` builder method, so every node is just a ``Command``.
|
|
311
|
+
"""
|
|
312
|
+
|
|
313
|
+
name: str
|
|
314
|
+
func: Callable | None = None
|
|
315
|
+
completers: dict[int | None, Completer] = field(default_factory=dict)
|
|
316
|
+
help_text: str = ""
|
|
317
|
+
params: list[Arg] | None = None
|
|
318
|
+
description: str = "" # raw description line (from help=), used as CmdParser description
|
|
319
|
+
parent: "Command | None" = None
|
|
320
|
+
children: dict[str, "Command"] = field(default_factory=dict)
|
|
321
|
+
|
|
322
|
+
# ── Tree-shape predicates ────────────────────────────────────────────
|
|
323
|
+
|
|
324
|
+
@property
|
|
325
|
+
def is_leaf(self) -> bool:
|
|
326
|
+
return not self.children
|
|
327
|
+
|
|
328
|
+
@property
|
|
329
|
+
def is_group(self) -> bool:
|
|
330
|
+
return bool(self.children)
|
|
331
|
+
|
|
332
|
+
def has_any_handler(self) -> bool:
|
|
333
|
+
"""True if this node or any descendant carries a Python handler."""
|
|
334
|
+
if self.func is not None:
|
|
335
|
+
return True
|
|
336
|
+
return any(c.has_any_handler() for c in self.children.values())
|
|
337
|
+
|
|
338
|
+
# ── Tree builder ─────────────────────────────────────────────────────
|
|
339
|
+
|
|
340
|
+
def command(
|
|
341
|
+
self,
|
|
342
|
+
name: str | None = None,
|
|
343
|
+
params: list[Arg] | None = None,
|
|
344
|
+
help: str | None = None,
|
|
345
|
+
) -> "Command":
|
|
346
|
+
"""Create a child node — used bare for groups, as a decorator for leaves.
|
|
347
|
+
|
|
348
|
+
Bare (returns the new child node)::
|
|
349
|
+
|
|
350
|
+
s3 = aws.command("s3", help="Amazon S3")
|
|
351
|
+
|
|
352
|
+
Decorator (still returns the node; ``__call__`` attaches the handler)::
|
|
353
|
+
|
|
354
|
+
@s3.command("ls", params=[arg("path", nargs="?")])
|
|
355
|
+
def s3_ls(path=None):
|
|
356
|
+
...
|
|
357
|
+
|
|
358
|
+
When *name* is omitted the return value is a decorator that uses
|
|
359
|
+
the wrapped function's ``__name__`` for the child name — mirrors
|
|
360
|
+
the ``@registry.command`` flat-command syntax.
|
|
361
|
+
"""
|
|
362
|
+
if name is not None:
|
|
363
|
+
child = self._make_child(name, params=params, help=help)
|
|
364
|
+
child._pending_help = help # used when a handler is attached later
|
|
365
|
+
return child
|
|
366
|
+
|
|
367
|
+
def decorator(func: Callable) -> "Command":
|
|
368
|
+
child = self._make_child(func.__name__, params=params, help=help)
|
|
369
|
+
child._attach_handler(func, help)
|
|
370
|
+
return child
|
|
371
|
+
return decorator
|
|
372
|
+
|
|
373
|
+
def __call__(self, func: Callable) -> "Command":
|
|
374
|
+
"""Decorator hook — ``@parent.command("ls", params=[...])`` flow.
|
|
375
|
+
|
|
376
|
+
The first call ``parent.command("ls", ...)`` returns *this* node;
|
|
377
|
+
Python then calls ``this_node(func)`` to apply the decorator. We
|
|
378
|
+
attach the handler and return self so callers can keep the
|
|
379
|
+
reference (``my_leaf = @s3.command("ls", ...)`` works).
|
|
380
|
+
"""
|
|
381
|
+
self._attach_handler(func, getattr(self, "_pending_help", None))
|
|
382
|
+
return self
|
|
383
|
+
|
|
384
|
+
def _attach_handler(self, func: Callable, help: str | None) -> None:
|
|
385
|
+
self.func = func
|
|
386
|
+
self.description = _effective_description(help, func)
|
|
387
|
+
self.help_text = _build_help_text(help, func, self.name, self.params)
|
|
388
|
+
|
|
389
|
+
def _make_child(
|
|
390
|
+
self,
|
|
391
|
+
name: str,
|
|
392
|
+
params: list[Arg] | None,
|
|
393
|
+
help: str | None,
|
|
394
|
+
) -> "Command":
|
|
395
|
+
if name in self.children:
|
|
396
|
+
# Re-declaration: update params/help on the existing node so a
|
|
397
|
+
# later `.command("foo", ...)` call can populate a node first
|
|
398
|
+
# created as a group.
|
|
399
|
+
child = self.children[name]
|
|
400
|
+
if params is not None:
|
|
401
|
+
child.params = params
|
|
402
|
+
child.completers = _build_completers(params)
|
|
403
|
+
if help is not None:
|
|
404
|
+
child.description = help
|
|
405
|
+
child.help_text = _build_help_text(help, child.func or _noop_for_help,
|
|
406
|
+
child.name, child.params)
|
|
407
|
+
return child
|
|
408
|
+
|
|
409
|
+
completers = _build_completers(params) if params else {}
|
|
410
|
+
child = Command(
|
|
411
|
+
name=name,
|
|
412
|
+
completers=completers,
|
|
413
|
+
help_text=_build_help_text(help, _noop_for_help, name, params),
|
|
414
|
+
params=params,
|
|
415
|
+
description=help or "",
|
|
416
|
+
parent=self,
|
|
417
|
+
)
|
|
418
|
+
self.children[name] = child
|
|
419
|
+
return child
|
|
420
|
+
|
|
421
|
+
# ── Resolution & dispatch ────────────────────────────────────────────
|
|
422
|
+
|
|
423
|
+
def resolve(self, tokens: list[str]) -> tuple["Command", list[str]]:
|
|
424
|
+
"""Walk down the tree consuming sub-command names from *tokens*.
|
|
425
|
+
|
|
426
|
+
Flags (and their values, if a value-taking flag is reachable) are
|
|
427
|
+
skipped during the walk so they may appear at any position.
|
|
428
|
+
|
|
429
|
+
Returns ``(node, remaining)`` where *remaining* are the tokens that
|
|
430
|
+
were **not** consumed as sub-command names — flag tokens stay,
|
|
431
|
+
positional args stay, only the matched sub-command name tokens are
|
|
432
|
+
dropped. The resolved leaf's argparse parses *remaining* directly.
|
|
433
|
+
"""
|
|
434
|
+
node = self
|
|
435
|
+
consumed_indices: set[int] = set()
|
|
436
|
+
i = 0
|
|
437
|
+
while i < len(tokens):
|
|
438
|
+
tok = tokens[i]
|
|
439
|
+
if tok.startswith("-"):
|
|
440
|
+
if _flag_takes_value(node, tok):
|
|
441
|
+
i += 2
|
|
442
|
+
else:
|
|
443
|
+
i += 1
|
|
444
|
+
continue
|
|
445
|
+
if tok in node.children:
|
|
446
|
+
node = node.children[tok]
|
|
447
|
+
consumed_indices.add(i)
|
|
448
|
+
i += 1
|
|
449
|
+
continue
|
|
450
|
+
break
|
|
451
|
+
remaining = [t for k, t in enumerate(tokens) if k not in consumed_indices]
|
|
452
|
+
return node, remaining
|
|
453
|
+
|
|
454
|
+
def invoke(self, args: list[str] | tuple[str, ...]) -> None:
|
|
455
|
+
"""Dispatch a command invocation.
|
|
456
|
+
|
|
457
|
+
For tree-shaped commands (``self.children`` is non-empty) the method
|
|
458
|
+
walks the tree and dispatches to the resolved node. For flat
|
|
459
|
+
commands it preserves the original ``func(**parsed_kwargs)`` or
|
|
460
|
+
``func(*args)`` behaviour.
|
|
461
|
+
"""
|
|
462
|
+
args = list(args)
|
|
463
|
+
if self.children:
|
|
464
|
+
self._invoke_tree(args)
|
|
465
|
+
return
|
|
466
|
+
self._invoke_self(args)
|
|
467
|
+
|
|
468
|
+
def _invoke_self(self, args: list[str]) -> None:
|
|
469
|
+
if self.params is not None and self.func is not None:
|
|
470
|
+
parser = CmdParser(self._full_name(), description=self.description or None)
|
|
471
|
+
for a in self.params:
|
|
472
|
+
parser.add_argument(*a.names, **a.kwargs)
|
|
473
|
+
ns = parser.parse_args(args)
|
|
474
|
+
if ns is None:
|
|
475
|
+
return
|
|
476
|
+
self.func(**vars(ns))
|
|
477
|
+
elif self.func is not None:
|
|
478
|
+
self.func(*args)
|
|
479
|
+
else:
|
|
480
|
+
# No handler — print group-style help (or signal no-op).
|
|
481
|
+
print(self.help_text or f"{self.name}: no handler")
|
|
482
|
+
|
|
483
|
+
def _invoke_tree(self, tokens: list[str]) -> None:
|
|
484
|
+
node, remaining = self.resolve(tokens)
|
|
485
|
+
if node.is_group and node.func is None:
|
|
486
|
+
# Hit an interior group with no default handler — print its help.
|
|
487
|
+
_print_group_help(node)
|
|
488
|
+
return
|
|
489
|
+
|
|
490
|
+
# node has a handler (leaf or group-with-default). Combine its own
|
|
491
|
+
# params with inherited ancestor flags so leaf handlers can declare
|
|
492
|
+
# ancestor-flag kwargs (e.g. region=, profile=).
|
|
493
|
+
merged_params = _collect_inherited_params(node)
|
|
494
|
+
parser = CmdParser(node._full_name(), description=node.description or None)
|
|
495
|
+
for a in merged_params:
|
|
496
|
+
parser.add_argument(*a.names, **a.kwargs)
|
|
497
|
+
ns = parser.parse_args(remaining)
|
|
498
|
+
if ns is None:
|
|
499
|
+
return
|
|
500
|
+
kwargs = vars(ns)
|
|
501
|
+
# Filter to kwargs the handler accepts (so leaves can ignore
|
|
502
|
+
# unused ancestor flags by simply omitting them from their signature).
|
|
503
|
+
accepted = _accepted_kwargs(node.func, kwargs)
|
|
504
|
+
node.func(**accepted)
|
|
505
|
+
|
|
506
|
+
def _full_name(self) -> str:
|
|
507
|
+
"""Dotted-or-spaced full path from root, used in error messages."""
|
|
508
|
+
parts: list[str] = []
|
|
509
|
+
n: Command | None = self
|
|
510
|
+
while n is not None:
|
|
511
|
+
parts.append(n.name)
|
|
512
|
+
n = n.parent
|
|
513
|
+
return " ".join(reversed(parts))
|
|
514
|
+
|
|
515
|
+
# ── Completion helpers ───────────────────────────────────────────────
|
|
516
|
+
|
|
517
|
+
def merged_options_completer(self) -> "OptionsCompleter | None":
|
|
518
|
+
"""Return an :class:`OptionsCompleter` merging this node's flags with
|
|
519
|
+
every ancestor's flags. Deepest definition wins on conflict.
|
|
520
|
+
"""
|
|
521
|
+
merged_options: dict[str, str] = {}
|
|
522
|
+
merged_args: dict[str, str | tuple] = {}
|
|
523
|
+
merged_value_completers: dict[str, Completer] = {}
|
|
524
|
+
chain: list[Command] = []
|
|
525
|
+
n: Command | None = self
|
|
526
|
+
while n is not None:
|
|
527
|
+
chain.append(n)
|
|
528
|
+
n = n.parent
|
|
529
|
+
# Apply ancestors first so the deeper node overwrites (last-write-wins).
|
|
530
|
+
for nd in reversed(chain):
|
|
531
|
+
oc = nd.completers.get(None)
|
|
532
|
+
if oc is None:
|
|
533
|
+
continue
|
|
534
|
+
for flag, desc in oc.options.items():
|
|
535
|
+
merged_options[flag] = desc
|
|
536
|
+
for flag, hint in oc.args.items():
|
|
537
|
+
merged_args[flag] = hint
|
|
538
|
+
for flag, vc in getattr(oc, "_value_completers", {}).items():
|
|
539
|
+
merged_value_completers[flag] = vc
|
|
540
|
+
if not merged_options and not merged_args:
|
|
541
|
+
return None
|
|
542
|
+
# Re-pack into OptionsCompleter. Convert (hint, completer) tuples
|
|
543
|
+
# to the form OptionsCompleter() expects when constructed.
|
|
544
|
+
args_arg: dict[str, str | tuple] = {}
|
|
545
|
+
for flag, hint in merged_args.items():
|
|
546
|
+
vc = merged_value_completers.get(flag)
|
|
547
|
+
args_arg[flag] = (hint, vc) if vc else hint
|
|
548
|
+
return OptionsCompleter(merged_options, args=args_arg or None)
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def _noop_for_help(*args, **kwargs): # pragma: no cover — placeholder for builders
|
|
552
|
+
pass
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def _flag_takes_value(node: Command, flag: str) -> bool:
|
|
556
|
+
"""True if *flag* is a value-taking flag at *node* or any ancestor."""
|
|
557
|
+
n: Command | None = node
|
|
558
|
+
while n is not None:
|
|
559
|
+
oc = n.completers.get(None)
|
|
560
|
+
if oc is not None and hasattr(oc, "args") and flag in oc.args:
|
|
561
|
+
return True
|
|
562
|
+
n = n.parent
|
|
563
|
+
return False
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def _collect_inherited_params(node: Command) -> list[Arg]:
|
|
567
|
+
"""Combine ancestor flags with this node's own params.
|
|
568
|
+
|
|
569
|
+
Positional args come from the resolved node only. Flags are inherited
|
|
570
|
+
from all ancestors; if the same flag name appears at multiple levels
|
|
571
|
+
the deepest definition wins.
|
|
572
|
+
"""
|
|
573
|
+
own_params = list(node.params or [])
|
|
574
|
+
own_flag_names: set[str] = set()
|
|
575
|
+
for a in own_params:
|
|
576
|
+
if a.names and a.names[0].startswith("-"):
|
|
577
|
+
own_flag_names.update(a.names)
|
|
578
|
+
|
|
579
|
+
# Walk up collecting ancestor flags not already declared at the leaf.
|
|
580
|
+
inherited_flags: list[Arg] = []
|
|
581
|
+
n = node.parent
|
|
582
|
+
while n is not None:
|
|
583
|
+
for a in n.params or []:
|
|
584
|
+
if not a.names or not a.names[0].startswith("-"):
|
|
585
|
+
continue # ancestor positionals are not inherited
|
|
586
|
+
if any(name in own_flag_names for name in a.names):
|
|
587
|
+
continue
|
|
588
|
+
own_flag_names.update(a.names)
|
|
589
|
+
inherited_flags.append(a)
|
|
590
|
+
n = n.parent
|
|
591
|
+
|
|
592
|
+
# Preserve order: own positionals + own flags first, then inherited flags.
|
|
593
|
+
return own_params + inherited_flags
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def _accepted_kwargs(func: Callable, kwargs: dict) -> dict:
|
|
597
|
+
"""Filter *kwargs* down to the names *func* accepts.
|
|
598
|
+
|
|
599
|
+
Allows leaf handlers to declare only the inherited flags they care about
|
|
600
|
+
by omitting the rest from their signature.
|
|
601
|
+
"""
|
|
602
|
+
try:
|
|
603
|
+
sig = inspect.signature(func)
|
|
604
|
+
except (ValueError, TypeError):
|
|
605
|
+
return kwargs
|
|
606
|
+
if any(p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()):
|
|
607
|
+
return kwargs
|
|
608
|
+
accepted_names = {p.name for p in sig.parameters.values()}
|
|
609
|
+
return {k: v for k, v in kwargs.items() if k in accepted_names}
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def _print_group_help(node: Command) -> None:
|
|
613
|
+
"""Print a sub-command list for a group with no default handler."""
|
|
614
|
+
if node.description:
|
|
615
|
+
print(node.description)
|
|
616
|
+
print()
|
|
617
|
+
print(f"Usage: {node._full_name()} <subcommand> [args...]")
|
|
618
|
+
if node.children:
|
|
619
|
+
print()
|
|
620
|
+
print("Subcommands:")
|
|
621
|
+
width = max(len(c) for c in node.children) + 2
|
|
622
|
+
for child_name in sorted(node.children):
|
|
623
|
+
child = node.children[child_name]
|
|
624
|
+
desc = child.description or ""
|
|
625
|
+
print(f" {child_name:<{width}}{desc}")
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
class CommandRegistry:
|
|
629
|
+
def __init__(self):
|
|
630
|
+
self._commands: dict[str, Command] = {}
|
|
631
|
+
self._builtin_names: set[str] = set()
|
|
632
|
+
self._aliases: dict[str, str] = {}
|
|
633
|
+
self._builtin_aliases: set[str] = set()
|
|
634
|
+
|
|
635
|
+
def command(
|
|
636
|
+
self,
|
|
637
|
+
name: str | None = None,
|
|
638
|
+
params: list[Arg] | None = None,
|
|
639
|
+
help: str | None = None,
|
|
640
|
+
delegate: Completer | None = None,
|
|
641
|
+
options_completer: "OptionsCompleter | None" = None,
|
|
642
|
+
):
|
|
643
|
+
"""Register a top-level command, group, or external recipe.
|
|
644
|
+
|
|
645
|
+
Three call forms:
|
|
646
|
+
|
|
647
|
+
* **Bare** — ``aws = registry.command("aws", help="...", params=[...])``
|
|
648
|
+
creates a :class:`Command` node and returns it; chain
|
|
649
|
+
``.command(...)`` on the result to add children. When the entire
|
|
650
|
+
tree has no handler anywhere it is treated as an external-command
|
|
651
|
+
recipe (completion only).
|
|
652
|
+
|
|
653
|
+
* **Decorator with name** — ``@registry.command("greet", params=[...])``
|
|
654
|
+
wraps a function and registers it as a flat Python command.
|
|
655
|
+
|
|
656
|
+
* **Decorator no name** — ``@registry.command(params=[...])`` (or
|
|
657
|
+
plain ``@registry.command``) uses the function's ``__name__``.
|
|
658
|
+
|
|
659
|
+
Decorator forms return the function (back-compat with the prior
|
|
660
|
+
decorator behaviour); bare form returns the :class:`Command` node.
|
|
661
|
+
|
|
662
|
+
``delegate`` — install a single :class:`Completer` as the source of
|
|
663
|
+
truth for **every** completion slot (flags, all positional indices).
|
|
664
|
+
Used when an external tool ships its own completer protocol that
|
|
665
|
+
decides per-call what to return (e.g. ``aws_completer``). Cannot be
|
|
666
|
+
combined with ``params``.
|
|
667
|
+
|
|
668
|
+
``options_completer`` — override the auto-built flag completer at
|
|
669
|
+
the ``None`` slot. Used when a custom subclass is needed (e.g. tar's
|
|
670
|
+
bundle-letter handling).
|
|
671
|
+
"""
|
|
672
|
+
# Form: @registry.command (no parens at all)
|
|
673
|
+
if callable(name):
|
|
674
|
+
func = name
|
|
675
|
+
self._make_root(func.__name__, params=None, help=None, func=func)
|
|
676
|
+
return func
|
|
677
|
+
|
|
678
|
+
# Form: registry.command("name", ...) — bare or @decorator-with-name
|
|
679
|
+
if isinstance(name, str):
|
|
680
|
+
node = self._make_root(
|
|
681
|
+
name, params=params, help=help, func=None,
|
|
682
|
+
delegate=delegate, options_completer=options_completer,
|
|
683
|
+
)
|
|
684
|
+
node._pending_help = help
|
|
685
|
+
return node
|
|
686
|
+
|
|
687
|
+
# Form: @registry.command(params=[...]) (no positional name)
|
|
688
|
+
def decorator(func: Callable) -> Callable:
|
|
689
|
+
cmd_name = func.__name__
|
|
690
|
+
self._make_root(
|
|
691
|
+
cmd_name, params=params, help=help, func=func,
|
|
692
|
+
delegate=delegate, options_completer=options_completer,
|
|
693
|
+
)
|
|
694
|
+
return func
|
|
695
|
+
return decorator
|
|
696
|
+
|
|
697
|
+
def _make_root(
|
|
698
|
+
self,
|
|
699
|
+
name: str,
|
|
700
|
+
params: list[Arg] | None,
|
|
701
|
+
help: str | None,
|
|
702
|
+
func: Callable | None,
|
|
703
|
+
delegate: Completer | None = None,
|
|
704
|
+
options_completer: "OptionsCompleter | None" = None,
|
|
705
|
+
) -> Command:
|
|
706
|
+
completers = _build_completers(params) if params else {}
|
|
707
|
+
if delegate is not None:
|
|
708
|
+
# Same completer at None (flags) and WILDCARD (all positional slots).
|
|
709
|
+
completers[None] = delegate
|
|
710
|
+
completers[WILDCARD] = delegate
|
|
711
|
+
if options_completer is not None:
|
|
712
|
+
completers[None] = options_completer
|
|
713
|
+
cmd = Command(
|
|
714
|
+
name=name,
|
|
715
|
+
func=func,
|
|
716
|
+
completers=completers,
|
|
717
|
+
help_text=_build_help_text(help, func or _noop_for_help, name, params),
|
|
718
|
+
params=params,
|
|
719
|
+
description=_effective_description(help, func or _noop_for_help) if (help or func) else "",
|
|
720
|
+
parent=None,
|
|
721
|
+
)
|
|
722
|
+
self._commands[name] = cmd
|
|
723
|
+
return cmd
|
|
724
|
+
|
|
725
|
+
def register(self, cmd: Command) -> None:
|
|
726
|
+
"""Register a pre-built :class:`Command` object.
|
|
727
|
+
|
|
728
|
+
Mirrors ``var_registry.register(var_object)``. Use ``command(...)``
|
|
729
|
+
instead when you want the registry to build the :class:`Command` for
|
|
730
|
+
you from ``params`` / ``help`` / a function.
|
|
731
|
+
"""
|
|
732
|
+
self._commands[cmd.name] = cmd
|
|
733
|
+
|
|
734
|
+
def get(self, name: str) -> Command | None:
|
|
735
|
+
return self._commands.get(name)
|
|
736
|
+
|
|
737
|
+
def list_commands(self) -> list[str]:
|
|
738
|
+
return list(self._commands.keys())
|
|
739
|
+
|
|
740
|
+
def has(self, name: str) -> bool:
|
|
741
|
+
return name in self._commands
|
|
742
|
+
|
|
743
|
+
def mark_builtins(self) -> None:
|
|
744
|
+
"""Snapshot current commands as builtins (won't be removed on reload)."""
|
|
745
|
+
self._builtin_names = set(self._commands.keys())
|
|
746
|
+
self._builtin_aliases = set(self._aliases.keys())
|
|
747
|
+
|
|
748
|
+
def clear_user_commands(self) -> None:
|
|
749
|
+
"""Remove all non-builtin commands and aliases."""
|
|
750
|
+
self._commands = {
|
|
751
|
+
k: v for k, v in self._commands.items() if k in self._builtin_names
|
|
752
|
+
}
|
|
753
|
+
self._aliases = {
|
|
754
|
+
k: v for k, v in self._aliases.items() if k in self._builtin_aliases
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
# ── Aliases ──────────────────────────────────────────────────────────────
|
|
758
|
+
|
|
759
|
+
def alias(self, name: str, expansion: str) -> None:
|
|
760
|
+
"""Register *name* as a shorthand that expands to *expansion*.
|
|
761
|
+
|
|
762
|
+
Expansion is applied to the first token of a command line (bash-style)
|
|
763
|
+
so ``hp create`` becomes ``awsut sagemaker hyperpod create``. The
|
|
764
|
+
expansion text
|
|
765
|
+
is tokenized at use time, so it may contain multiple words and flags.
|
|
766
|
+
|
|
767
|
+
Aliases do not chain — the first token of the expansion is never itself
|
|
768
|
+
re-expanded as an alias, which prevents infinite loops.
|
|
769
|
+
"""
|
|
770
|
+
self._aliases[name] = expansion
|
|
771
|
+
|
|
772
|
+
def unalias(self, name: str) -> bool:
|
|
773
|
+
"""Remove an alias. Returns True if it existed."""
|
|
774
|
+
return self._aliases.pop(name, None) is not None
|
|
775
|
+
|
|
776
|
+
def get_alias(self, name: str) -> str | None:
|
|
777
|
+
"""Return the expansion for *name*, or None if not aliased."""
|
|
778
|
+
return self._aliases.get(name)
|
|
779
|
+
|
|
780
|
+
def list_aliases(self) -> dict[str, str]:
|
|
781
|
+
"""Return a copy of the alias table."""
|
|
782
|
+
return dict(self._aliases)
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
registry = CommandRegistry()
|