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.
Files changed (73) hide show
  1. eosh/__init__.py +34 -0
  2. eosh/__main__.py +23 -0
  3. eosh/_config.py +367 -0
  4. eosh/colors.py +63 -0
  5. eosh/commands.py +785 -0
  6. eosh/completion.py +952 -0
  7. eosh/completion_cache.py +77 -0
  8. eosh/context.py +189 -0
  9. eosh/decorators/__init__.py +263 -0
  10. eosh/decorators/bg.py +39 -0
  11. eosh/decorators/quiet.py +39 -0
  12. eosh/decorators/retry.py +46 -0
  13. eosh/decorators/time.py +44 -0
  14. eosh/decorators/watch.py +660 -0
  15. eosh/history.py +36 -0
  16. eosh/lineedit.py +1442 -0
  17. eosh/notify.py +404 -0
  18. eosh/parsing.py +226 -0
  19. eosh/paths.py +18 -0
  20. eosh/pipeline.py +668 -0
  21. eosh/process.py +465 -0
  22. eosh/prompt.py +77 -0
  23. eosh/recipes/__init__.py +208 -0
  24. eosh/recipes/_awsut_agentcore/__init__.py +46 -0
  25. eosh/recipes/_awsut_agentcore/harness.py +721 -0
  26. eosh/recipes/_awsut_agentcore/memory.py +1209 -0
  27. eosh/recipes/_awsut_agentcore/render.py +337 -0
  28. eosh/recipes/_awsut_common.py +533 -0
  29. eosh/recipes/_awsut_sagemaker/__init__.py +36 -0
  30. eosh/recipes/_awsut_sagemaker/hub.py +866 -0
  31. eosh/recipes/_awsut_sagemaker/hyperpod.py +1948 -0
  32. eosh/recipes/_awsut_sagemaker/jobs.py +1041 -0
  33. eosh/recipes/_awsut_sagemaker/render.py +300 -0
  34. eosh/recipes/_awsut_sagemaker/studio.py +1584 -0
  35. eosh/recipes/_missing.py +168 -0
  36. eosh/recipes/aws.py +239 -0
  37. eosh/recipes/awsut.py +1649 -0
  38. eosh/recipes/chmod.py +58 -0
  39. eosh/recipes/chown.py +117 -0
  40. eosh/recipes/cp.py +80 -0
  41. eosh/recipes/curl.py +108 -0
  42. eosh/recipes/df.py +33 -0
  43. eosh/recipes/du.py +82 -0
  44. eosh/recipes/find.py +60 -0
  45. eosh/recipes/git.py +295 -0
  46. eosh/recipes/grep.py +54 -0
  47. eosh/recipes/kill.py +92 -0
  48. eosh/recipes/ls.py +44 -0
  49. eosh/recipes/lsof.py +56 -0
  50. eosh/recipes/make.py +219 -0
  51. eosh/recipes/mv.py +52 -0
  52. eosh/recipes/ps.py +73 -0
  53. eosh/recipes/rm.py +55 -0
  54. eosh/recipes/rsync.py +143 -0
  55. eosh/recipes/scp.py +97 -0
  56. eosh/recipes/ssh.py +92 -0
  57. eosh/recipes/tail.py +23 -0
  58. eosh/recipes/tar.py +170 -0
  59. eosh/recipes/terraform.py +374 -0
  60. eosh/recipes/top.py +84 -0
  61. eosh/recipes/unzip.py +96 -0
  62. eosh/recipes/zip.py +53 -0
  63. eosh/scrollbar.py +122 -0
  64. eosh/shell.py +4253 -0
  65. eosh/terminal.py +446 -0
  66. eosh/tui.py +1082 -0
  67. eosh/variables.py +253 -0
  68. eosh-0.2.0.dist-info/METADATA +637 -0
  69. eosh-0.2.0.dist-info/RECORD +73 -0
  70. eosh-0.2.0.dist-info/WHEEL +5 -0
  71. eosh-0.2.0.dist-info/entry_points.txt +2 -0
  72. eosh-0.2.0.dist-info/licenses/LICENSE +21 -0
  73. 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()