contree-cli 0.2.3__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.
- contree_cli/__init__.py +27 -0
- contree_cli/__main__.py +62 -0
- contree_cli/arguments.py +141 -0
- contree_cli/cli/__init__.py +0 -0
- contree_cli/cli/auth.py +124 -0
- contree_cli/cli/cat.py +74 -0
- contree_cli/cli/cd.py +49 -0
- contree_cli/cli/cp.py +107 -0
- contree_cli/cli/file.py +179 -0
- contree_cli/cli/images.py +88 -0
- contree_cli/cli/kill.py +86 -0
- contree_cli/cli/ls.py +83 -0
- contree_cli/cli/ps.py +120 -0
- contree_cli/cli/run.py +438 -0
- contree_cli/cli/session.py +282 -0
- contree_cli/cli/show.py +97 -0
- contree_cli/cli/tag.py +50 -0
- contree_cli/cli/use.py +119 -0
- contree_cli/client.py +222 -0
- contree_cli/config.py +116 -0
- contree_cli/log.py +40 -0
- contree_cli/mapped_file.py +112 -0
- contree_cli/output.py +376 -0
- contree_cli/session.py +761 -0
- contree_cli/shell/__init__.py +59 -0
- contree_cli/shell/completer.py +465 -0
- contree_cli/shell/history.py +53 -0
- contree_cli/shell/parser.py +107 -0
- contree_cli/shell/repl.py +486 -0
- contree_cli/shell/trie.py +113 -0
- contree_cli/types.py +87 -0
- contree_cli-0.2.3.dist-info/METADATA +323 -0
- contree_cli-0.2.3.dist-info/RECORD +35 -0
- contree_cli-0.2.3.dist-info/WHEEL +4 -0
- contree_cli-0.2.3.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
"""Interactive REPL for the contree shell."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import contextlib
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import shlex
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
from contree_cli import FORMATTER, IN_SHELL, SESSION_STORE, ArgumentsProtocol
|
|
11
|
+
from contree_cli.client import ApiError
|
|
12
|
+
from contree_cli.output import FORMATTERS, OutputFormatter
|
|
13
|
+
from contree_cli.shell.completer import ShellCompleter
|
|
14
|
+
from contree_cli.shell.parser import ShellArgumentParser, ShellParseError
|
|
15
|
+
from contree_cli.types import Colors
|
|
16
|
+
|
|
17
|
+
log = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
_PROMPT_BASE = "contree"
|
|
20
|
+
|
|
21
|
+
# Bare names that are forwarded as contree management commands.
|
|
22
|
+
_CONTREE_ALIASES = frozenset({"ls", "cat"})
|
|
23
|
+
|
|
24
|
+
# Bare editor names that map to ``contree file edit`` with EDITOR override.
|
|
25
|
+
_EDITOR_ALIASES = frozenset({"vim", "vi", "nano"})
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ContreeShell:
|
|
29
|
+
"""Interactive REPL that dispatches to existing command handlers.
|
|
30
|
+
|
|
31
|
+
Commands prefixed with ``contree`` are dispatched as management
|
|
32
|
+
commands via argparse (e.g. ``contree ls /etc``). Everything else
|
|
33
|
+
is treated as an implicit ``run`` — the tokens are joined into a
|
|
34
|
+
shell expression and executed inside the current session container.
|
|
35
|
+
|
|
36
|
+
Several bare names are intercepted for convenience:
|
|
37
|
+
|
|
38
|
+
* ``ls``, ``cat`` — forwarded as the corresponding contree commands.
|
|
39
|
+
* ``vim``, ``vi``, ``nano`` — open ``contree file edit`` with the
|
|
40
|
+
host editor.
|
|
41
|
+
* ``cd`` — change the working directory for subsequent ``run``
|
|
42
|
+
commands (tracked in memory).
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
parser: ShellArgumentParser,
|
|
48
|
+
completer: ShellCompleter,
|
|
49
|
+
) -> None:
|
|
50
|
+
self._parser = parser
|
|
51
|
+
self._completer = completer
|
|
52
|
+
self._cwd = ""
|
|
53
|
+
self._prev_cwd = ""
|
|
54
|
+
|
|
55
|
+
def _print_status_line(self) -> None:
|
|
56
|
+
"""Print the info line (session/image) above the input prompt."""
|
|
57
|
+
session_key = ""
|
|
58
|
+
image_uuid = ""
|
|
59
|
+
depth = 0
|
|
60
|
+
try:
|
|
61
|
+
store = SESSION_STORE.get()
|
|
62
|
+
session = store.session
|
|
63
|
+
if session is not None:
|
|
64
|
+
session_key = session.session_key
|
|
65
|
+
image_uuid = session.current_image
|
|
66
|
+
depth = store.history_depth()
|
|
67
|
+
except (LookupError, Exception):
|
|
68
|
+
pass
|
|
69
|
+
counter = Colors.CYAN(f"[{depth}]")
|
|
70
|
+
line = (
|
|
71
|
+
Colors.GRAY("session:") + Colors.YELLOW(session_key)
|
|
72
|
+
+ " " + counter + " "
|
|
73
|
+
+ Colors.GRAY("image:") + Colors.GREEN(image_uuid)
|
|
74
|
+
)
|
|
75
|
+
print(line)
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def _prompt(self) -> str:
|
|
79
|
+
"""Short input prompt — only cwd and branch, no ANSI length issues."""
|
|
80
|
+
cwd = self._cwd or "/"
|
|
81
|
+
branch = ""
|
|
82
|
+
try:
|
|
83
|
+
session = SESSION_STORE.get().session
|
|
84
|
+
if session is not None:
|
|
85
|
+
branch = session.active_branch
|
|
86
|
+
except (LookupError, Exception):
|
|
87
|
+
pass
|
|
88
|
+
branch_part = Colors.MAGENTA(f"({branch})") + " " if branch else ""
|
|
89
|
+
return branch_part + Colors.BOLD_BLUE(cwd) + " $ "
|
|
90
|
+
|
|
91
|
+
def run(self) -> None:
|
|
92
|
+
"""Main REPL loop: readline setup -> input(prompt) -> dispatch."""
|
|
93
|
+
token = IN_SHELL.set(True)
|
|
94
|
+
self._setup_readline()
|
|
95
|
+
self._load_cwd()
|
|
96
|
+
print("contree interactive shell (type 'help' for commands, Ctrl-D to exit)")
|
|
97
|
+
try:
|
|
98
|
+
while True:
|
|
99
|
+
try:
|
|
100
|
+
self._print_status_line()
|
|
101
|
+
line = input(self._prompt)
|
|
102
|
+
except KeyboardInterrupt:
|
|
103
|
+
# Ctrl-C on empty prompt — print newline, continue
|
|
104
|
+
print()
|
|
105
|
+
continue
|
|
106
|
+
line = line.strip()
|
|
107
|
+
if not line:
|
|
108
|
+
continue
|
|
109
|
+
self._execute(line)
|
|
110
|
+
except EOFError:
|
|
111
|
+
# Ctrl-D — clean exit
|
|
112
|
+
print()
|
|
113
|
+
finally:
|
|
114
|
+
IN_SHELL.reset(token)
|
|
115
|
+
|
|
116
|
+
# ------------------------------------------------------------------
|
|
117
|
+
# Dispatch
|
|
118
|
+
# ------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
def _execute(self, line: str) -> None:
|
|
121
|
+
"""Parse line and dispatch to the appropriate command handler."""
|
|
122
|
+
# Tokenize
|
|
123
|
+
try:
|
|
124
|
+
tokens = shlex.split(line)
|
|
125
|
+
except ValueError as exc:
|
|
126
|
+
print(f"Parse error: {exc}", file=sys.stderr)
|
|
127
|
+
return
|
|
128
|
+
|
|
129
|
+
if not tokens:
|
|
130
|
+
return
|
|
131
|
+
|
|
132
|
+
cmd = tokens[0]
|
|
133
|
+
|
|
134
|
+
# Builtins (no prefix needed)
|
|
135
|
+
if cmd in ("exit", "quit"):
|
|
136
|
+
raise EOFError
|
|
137
|
+
if cmd == "help":
|
|
138
|
+
if len(tokens) > 1:
|
|
139
|
+
arg = tokens[1]
|
|
140
|
+
if arg in ("--format", "-f"):
|
|
141
|
+
self._print_format_help()
|
|
142
|
+
return
|
|
143
|
+
self._dispatch_contree([arg, "--help"])
|
|
144
|
+
else:
|
|
145
|
+
self._print_shell_help()
|
|
146
|
+
return
|
|
147
|
+
|
|
148
|
+
if cmd == "clear":
|
|
149
|
+
sys.stdout.write("\033[2J\033[H")
|
|
150
|
+
sys.stdout.flush()
|
|
151
|
+
return
|
|
152
|
+
|
|
153
|
+
# --format / -f — persistent format switching
|
|
154
|
+
if cmd in ("--format", "-f"):
|
|
155
|
+
self._handle_format_command(tokens[1:])
|
|
156
|
+
return
|
|
157
|
+
|
|
158
|
+
# cd / pwd — in-memory working directory
|
|
159
|
+
if cmd == "cd":
|
|
160
|
+
self._handle_cd(tokens[1:])
|
|
161
|
+
return
|
|
162
|
+
if cmd == "pwd":
|
|
163
|
+
print(self._cwd or "/")
|
|
164
|
+
return
|
|
165
|
+
if cmd == "history":
|
|
166
|
+
self._handle_history(tokens[1:])
|
|
167
|
+
return
|
|
168
|
+
|
|
169
|
+
# ls / cat — forwarded as contree commands when simple and no
|
|
170
|
+
# pending file uploads; otherwise fall back to run so pending
|
|
171
|
+
# files are injected into the container.
|
|
172
|
+
if cmd in _CONTREE_ALIASES:
|
|
173
|
+
args = tokens[1:]
|
|
174
|
+
if (
|
|
175
|
+
not self._has_pending_files()
|
|
176
|
+
and self._is_simple_alias(cmd, args)
|
|
177
|
+
):
|
|
178
|
+
resolved = [cmd] + [self._resolve_path(a) for a in args]
|
|
179
|
+
self._dispatch_contree(resolved)
|
|
180
|
+
else:
|
|
181
|
+
self._dispatch_run(tokens)
|
|
182
|
+
return
|
|
183
|
+
|
|
184
|
+
# vim / vi / nano — host editor via "file edit"
|
|
185
|
+
if cmd in _EDITOR_ALIASES:
|
|
186
|
+
self._dispatch_edit(cmd, tokens[1:])
|
|
187
|
+
return
|
|
188
|
+
|
|
189
|
+
# "contree ..." — explicit management command via argparse
|
|
190
|
+
if cmd == "contree":
|
|
191
|
+
if len(tokens) == 1:
|
|
192
|
+
self._print_shell_help()
|
|
193
|
+
return
|
|
194
|
+
# Truncate at --help/-h and dispatch help for the prefix
|
|
195
|
+
help_tokens: list[str] = []
|
|
196
|
+
for t in tokens[1:]:
|
|
197
|
+
if t in ("--help", "-h"):
|
|
198
|
+
break
|
|
199
|
+
help_tokens.append(t)
|
|
200
|
+
if len(help_tokens) < len(tokens) - 1:
|
|
201
|
+
# --help/-h was found
|
|
202
|
+
if not help_tokens:
|
|
203
|
+
self._print_shell_help()
|
|
204
|
+
else:
|
|
205
|
+
self._dispatch_contree([help_tokens[0], "--help"])
|
|
206
|
+
return
|
|
207
|
+
if tokens[1] == "cd":
|
|
208
|
+
# Intercept cd to keep shell._cwd in sync
|
|
209
|
+
cd_args = tokens[2:]
|
|
210
|
+
if not cd_args:
|
|
211
|
+
print(self._cwd or "/")
|
|
212
|
+
else:
|
|
213
|
+
self._handle_cd(cd_args)
|
|
214
|
+
else:
|
|
215
|
+
self._dispatch_contree(tokens[1:])
|
|
216
|
+
return
|
|
217
|
+
|
|
218
|
+
# Everything else → implicit run in container
|
|
219
|
+
self._dispatch_run(tokens)
|
|
220
|
+
|
|
221
|
+
def _dispatch_contree(self, tokens: list[str]) -> None:
|
|
222
|
+
"""Dispatch a contree management command via argparse."""
|
|
223
|
+
try:
|
|
224
|
+
ns = self._parser.parse_args(tokens)
|
|
225
|
+
except ShellParseError as exc:
|
|
226
|
+
# status=0 means --help was triggered (already printed)
|
|
227
|
+
if exc.status == 0:
|
|
228
|
+
return
|
|
229
|
+
if exc.usage:
|
|
230
|
+
print(exc.usage, file=sys.stderr, end="")
|
|
231
|
+
if exc.message:
|
|
232
|
+
print(f"error: {exc.message}", file=sys.stderr)
|
|
233
|
+
return
|
|
234
|
+
|
|
235
|
+
handler = ns.handler
|
|
236
|
+
loader: type[ArgumentsProtocol] = ns.load_args
|
|
237
|
+
|
|
238
|
+
# Per-command format override via -f/--format
|
|
239
|
+
fmt_token = None
|
|
240
|
+
fmt_name: str | None = getattr(ns, "output_format", None)
|
|
241
|
+
if fmt_name is not None:
|
|
242
|
+
fmt_token = FORMATTER.set(FORMATTERS[fmt_name]())
|
|
243
|
+
|
|
244
|
+
formatter = FORMATTER.get()
|
|
245
|
+
|
|
246
|
+
try:
|
|
247
|
+
handler(loader.from_args(ns))
|
|
248
|
+
except ApiError as exc:
|
|
249
|
+
print(f"API error: {exc}", file=sys.stderr)
|
|
250
|
+
except KeyboardInterrupt:
|
|
251
|
+
print()
|
|
252
|
+
except SystemExit:
|
|
253
|
+
# Some handlers may call sys.exit() — catch and continue
|
|
254
|
+
pass
|
|
255
|
+
except Exception as exc:
|
|
256
|
+
log.error("Command failed: %s", exc, exc_info=True)
|
|
257
|
+
finally:
|
|
258
|
+
formatter.flush()
|
|
259
|
+
if fmt_token is not None:
|
|
260
|
+
FORMATTER.reset(fmt_token)
|
|
261
|
+
|
|
262
|
+
def _dispatch_run(self, tokens: list[str]) -> None:
|
|
263
|
+
"""Dispatch tokens as an implicit ``run`` in the container."""
|
|
264
|
+
from contree_cli.cli.run import RunArgs, cmd_run
|
|
265
|
+
|
|
266
|
+
args = RunArgs(command_args=tokens, shell=True, cwd=self._cwd)
|
|
267
|
+
formatter = FORMATTER.get()
|
|
268
|
+
|
|
269
|
+
try:
|
|
270
|
+
cmd_run(args)
|
|
271
|
+
except ApiError as exc:
|
|
272
|
+
print(f"API error: {exc}", file=sys.stderr)
|
|
273
|
+
except KeyboardInterrupt:
|
|
274
|
+
print()
|
|
275
|
+
except SystemExit:
|
|
276
|
+
pass
|
|
277
|
+
except Exception as exc:
|
|
278
|
+
log.error("Command failed: %s", exc, exc_info=True)
|
|
279
|
+
finally:
|
|
280
|
+
formatter.flush()
|
|
281
|
+
|
|
282
|
+
def _dispatch_edit(self, editor: str, args: list[str]) -> None:
|
|
283
|
+
"""Open a container file in a host editor via ``file edit``."""
|
|
284
|
+
if not args:
|
|
285
|
+
print(f"Usage: {editor} <path>", file=sys.stderr)
|
|
286
|
+
return
|
|
287
|
+
resolved = self._resolve_path(args[0])
|
|
288
|
+
old_editor = os.environ.get("EDITOR")
|
|
289
|
+
os.environ["EDITOR"] = editor
|
|
290
|
+
try:
|
|
291
|
+
self._dispatch_contree(["file", "edit", resolved])
|
|
292
|
+
finally:
|
|
293
|
+
if old_editor is None:
|
|
294
|
+
os.environ.pop("EDITOR", None)
|
|
295
|
+
else:
|
|
296
|
+
os.environ["EDITOR"] = old_editor
|
|
297
|
+
|
|
298
|
+
# ------------------------------------------------------------------
|
|
299
|
+
# Path resolution helpers
|
|
300
|
+
# ------------------------------------------------------------------
|
|
301
|
+
|
|
302
|
+
_GLOB_CHARS = frozenset("*?[")
|
|
303
|
+
|
|
304
|
+
def _has_pending_files(self) -> bool:
|
|
305
|
+
"""Return True when the session has files awaiting upload."""
|
|
306
|
+
try:
|
|
307
|
+
return bool(SESSION_STORE.get().pending_files())
|
|
308
|
+
except (LookupError, Exception):
|
|
309
|
+
return False
|
|
310
|
+
|
|
311
|
+
def _resolve_path(self, path: str) -> str:
|
|
312
|
+
"""Resolve a container path via session store."""
|
|
313
|
+
if not path:
|
|
314
|
+
return path
|
|
315
|
+
return SESSION_STORE.get().resolve_path(path)
|
|
316
|
+
|
|
317
|
+
def _is_simple_alias(self, cmd: str, args: list[str]) -> bool:
|
|
318
|
+
"""Check whether alias args are simple enough for contree dispatch.
|
|
319
|
+
|
|
320
|
+
Returns ``False`` (fall back to ``run``) when the arguments
|
|
321
|
+
contain flags, glob characters, or an unexpected number of
|
|
322
|
+
positional arguments for the given command.
|
|
323
|
+
"""
|
|
324
|
+
for a in args:
|
|
325
|
+
if a.startswith("-"):
|
|
326
|
+
return False
|
|
327
|
+
if self._GLOB_CHARS & set(a):
|
|
328
|
+
return False
|
|
329
|
+
if cmd == "cat":
|
|
330
|
+
return len(args) == 1
|
|
331
|
+
if cmd == "ls":
|
|
332
|
+
return len(args) <= 1
|
|
333
|
+
return True
|
|
334
|
+
|
|
335
|
+
# ------------------------------------------------------------------
|
|
336
|
+
# cd — in-memory working directory
|
|
337
|
+
# ------------------------------------------------------------------
|
|
338
|
+
|
|
339
|
+
def _handle_cd(self, args: list[str]) -> None:
|
|
340
|
+
"""Change the working directory for subsequent ``run`` commands."""
|
|
341
|
+
if not args:
|
|
342
|
+
# bare ``cd`` → reset to container WORKDIR
|
|
343
|
+
self._prev_cwd = self._cwd
|
|
344
|
+
self._cwd = ""
|
|
345
|
+
self._persist_cwd()
|
|
346
|
+
return
|
|
347
|
+
|
|
348
|
+
target = args[0]
|
|
349
|
+
|
|
350
|
+
if target == "-":
|
|
351
|
+
self._cwd, self._prev_cwd = self._prev_cwd, self._cwd
|
|
352
|
+
if self._cwd:
|
|
353
|
+
print(self._cwd)
|
|
354
|
+
self._persist_cwd()
|
|
355
|
+
return
|
|
356
|
+
|
|
357
|
+
new_cwd = SESSION_STORE.get().resolve_path(target)
|
|
358
|
+
self._prev_cwd = self._cwd
|
|
359
|
+
self._cwd = new_cwd
|
|
360
|
+
self._persist_cwd()
|
|
361
|
+
|
|
362
|
+
def _persist_cwd(self) -> None:
|
|
363
|
+
"""Write the current working directory to session storage."""
|
|
364
|
+
try:
|
|
365
|
+
store = SESSION_STORE.get()
|
|
366
|
+
store.set_cwd(self._cwd)
|
|
367
|
+
except (LookupError, Exception):
|
|
368
|
+
pass
|
|
369
|
+
|
|
370
|
+
def _load_cwd(self) -> None:
|
|
371
|
+
"""Restore the working directory from session storage."""
|
|
372
|
+
try:
|
|
373
|
+
store = SESSION_STORE.get()
|
|
374
|
+
self._cwd = store.get_cwd()
|
|
375
|
+
except (LookupError, Exception):
|
|
376
|
+
pass
|
|
377
|
+
|
|
378
|
+
# ------------------------------------------------------------------
|
|
379
|
+
# history — show shell history from SQLite
|
|
380
|
+
# ------------------------------------------------------------------
|
|
381
|
+
|
|
382
|
+
def _handle_history(self, args: list[str]) -> None:
|
|
383
|
+
"""Print shell history from the session database."""
|
|
384
|
+
try:
|
|
385
|
+
store = SESSION_STORE.get()
|
|
386
|
+
lines = store.load_shell_history()
|
|
387
|
+
except (LookupError, Exception):
|
|
388
|
+
lines = []
|
|
389
|
+
if not lines:
|
|
390
|
+
print("(no history)")
|
|
391
|
+
return
|
|
392
|
+
# Optional: limit output with an argument (e.g. ``history 20``)
|
|
393
|
+
count = len(lines)
|
|
394
|
+
if args:
|
|
395
|
+
with contextlib.suppress(ValueError):
|
|
396
|
+
count = int(args[0])
|
|
397
|
+
for i, line in enumerate(lines[-count:], start=max(1, len(lines) - count + 1)):
|
|
398
|
+
print(f" {i:5d} {line}")
|
|
399
|
+
|
|
400
|
+
# ------------------------------------------------------------------
|
|
401
|
+
# --format / -f — persistent format switching
|
|
402
|
+
# ------------------------------------------------------------------
|
|
403
|
+
|
|
404
|
+
@staticmethod
|
|
405
|
+
def _format_name(formatter: OutputFormatter) -> str:
|
|
406
|
+
"""Return the FORMATTERS key for *formatter*'s type."""
|
|
407
|
+
for name, cls in FORMATTERS.items():
|
|
408
|
+
if type(formatter) is cls:
|
|
409
|
+
return name
|
|
410
|
+
return type(formatter).__name__
|
|
411
|
+
|
|
412
|
+
def _handle_format_command(self, args: list[str]) -> None:
|
|
413
|
+
"""Handle ``--format [NAME]`` as a shell builtin."""
|
|
414
|
+
if not args:
|
|
415
|
+
# Print current format name
|
|
416
|
+
print(self._format_name(FORMATTER.get()))
|
|
417
|
+
return
|
|
418
|
+
name = args[0]
|
|
419
|
+
if name not in FORMATTERS:
|
|
420
|
+
names = ", ".join(sorted(FORMATTERS))
|
|
421
|
+
print(
|
|
422
|
+
f"Unknown format {name!r}. Available: {names}",
|
|
423
|
+
file=sys.stderr,
|
|
424
|
+
)
|
|
425
|
+
return
|
|
426
|
+
FORMATTER.set(FORMATTERS[name]())
|
|
427
|
+
|
|
428
|
+
@staticmethod
|
|
429
|
+
def _print_format_help() -> None:
|
|
430
|
+
"""Print help text for the --format / -f builtin."""
|
|
431
|
+
names = ", ".join(sorted(FORMATTERS))
|
|
432
|
+
print(
|
|
433
|
+
"Usage:\n"
|
|
434
|
+
" --format Show current output format\n"
|
|
435
|
+
" --format NAME Switch output format for the session\n"
|
|
436
|
+
f"\nAvailable formats: {names}\n"
|
|
437
|
+
"\nPer-command override: contree -f NAME <command>",
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
def _print_shell_help(self) -> None:
|
|
441
|
+
"""Print full shell help including builtins and contree commands."""
|
|
442
|
+
fmt = ", ".join(sorted(FORMATTERS))
|
|
443
|
+
print(
|
|
444
|
+
"Shell builtins:\n"
|
|
445
|
+
" help [CMD] Show help (for CMD if given)\n"
|
|
446
|
+
" exit / quit Exit the shell (or Ctrl-D)\n"
|
|
447
|
+
" clear Clear the screen\n"
|
|
448
|
+
" cd [PATH] Change working directory (cd - to swap)\n"
|
|
449
|
+
" pwd Print working directory\n"
|
|
450
|
+
" history [N] Show command history (last N lines)\n"
|
|
451
|
+
" --format [NAME] Show or switch output format\n"
|
|
452
|
+
f" Available: {fmt}\n"
|
|
453
|
+
"\n"
|
|
454
|
+
"Editor aliases:\n"
|
|
455
|
+
" vim / vi / nano PATH Edit a file via contree file edit\n"
|
|
456
|
+
"\n"
|
|
457
|
+
"Contree commands (prefix with 'contree' or use directly):\n"
|
|
458
|
+
" ls [PATH] List files in container image\n"
|
|
459
|
+
" cat PATH Show file content from image\n"
|
|
460
|
+
" contree <CMD> Run any contree management command\n"
|
|
461
|
+
"\n"
|
|
462
|
+
"Anything else is executed as a command inside the container.\n"
|
|
463
|
+
"\n"
|
|
464
|
+
"Use 'help CMD' for detailed help on a specific command.",
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
# ------------------------------------------------------------------
|
|
468
|
+
# Readline
|
|
469
|
+
# ------------------------------------------------------------------
|
|
470
|
+
|
|
471
|
+
def _setup_readline(self) -> None:
|
|
472
|
+
"""Configure readline for tab completion."""
|
|
473
|
+
try:
|
|
474
|
+
import readline
|
|
475
|
+
except ImportError:
|
|
476
|
+
return
|
|
477
|
+
|
|
478
|
+
readline.set_completer(self._completer.complete)
|
|
479
|
+
readline.set_completer_delims(" \t\n")
|
|
480
|
+
|
|
481
|
+
# macOS uses libedit which has different bind syntax
|
|
482
|
+
doc = getattr(readline, "__doc__", "") or ""
|
|
483
|
+
if "libedit" in doc:
|
|
484
|
+
readline.parse_and_bind("bind ^I rl_complete")
|
|
485
|
+
else:
|
|
486
|
+
readline.parse_and_bind("tab: complete")
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable, Iterator, MutableMapping
|
|
4
|
+
|
|
5
|
+
Handler = Callable[..., list[str]]
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class PrefixRouter(MutableMapping[tuple[str, ...], Handler]):
|
|
9
|
+
"""Dict-of-dicts trie mapping token paths to handler callables.
|
|
10
|
+
|
|
11
|
+
Keys are tuples of tokens, e.g. ``("contree", "session", "use")``.
|
|
12
|
+
Values are handler callables receiving ``(remaining, text) -> list[str]``.
|
|
13
|
+
|
|
14
|
+
The :meth:`resolve` method walks the trie as far as possible,
|
|
15
|
+
returning the deepest matching node and how many tokens were consumed.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
__slots__ = ("_children", "_handler")
|
|
19
|
+
|
|
20
|
+
def __init__(self) -> None:
|
|
21
|
+
self._handler: Handler | None = None
|
|
22
|
+
self._children: dict[str, PrefixRouter] = {}
|
|
23
|
+
|
|
24
|
+
# -- Properties -----------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def value(self) -> Handler | None:
|
|
28
|
+
"""The handler stored at this node, or ``None``."""
|
|
29
|
+
return self._handler
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def children(self) -> dict[str, PrefixRouter]:
|
|
33
|
+
"""Direct children mapping token → sub-router."""
|
|
34
|
+
return self._children
|
|
35
|
+
|
|
36
|
+
# -- MutableMapping interface ---------------------------------------------
|
|
37
|
+
|
|
38
|
+
def __getitem__(self, key: tuple[str, ...]) -> Handler:
|
|
39
|
+
node = self
|
|
40
|
+
for token in key:
|
|
41
|
+
try:
|
|
42
|
+
node = node._children[token]
|
|
43
|
+
except KeyError:
|
|
44
|
+
raise KeyError(key) from None
|
|
45
|
+
if node._handler is None:
|
|
46
|
+
raise KeyError(key)
|
|
47
|
+
return node._handler
|
|
48
|
+
|
|
49
|
+
def __setitem__(self, key: tuple[str, ...], value: Handler) -> None:
|
|
50
|
+
node = self
|
|
51
|
+
for token in key:
|
|
52
|
+
if token not in node._children:
|
|
53
|
+
node._children[token] = PrefixRouter()
|
|
54
|
+
node = node._children[token]
|
|
55
|
+
node._handler = value
|
|
56
|
+
|
|
57
|
+
def __delitem__(self, key: tuple[str, ...]) -> None:
|
|
58
|
+
node = self
|
|
59
|
+
for token in key:
|
|
60
|
+
try:
|
|
61
|
+
node = node._children[token]
|
|
62
|
+
except KeyError:
|
|
63
|
+
raise KeyError(key) from None
|
|
64
|
+
if node._handler is None:
|
|
65
|
+
raise KeyError(key)
|
|
66
|
+
node._handler = None
|
|
67
|
+
|
|
68
|
+
def __contains__(self, key: object) -> bool:
|
|
69
|
+
if not isinstance(key, tuple):
|
|
70
|
+
return False
|
|
71
|
+
node = self
|
|
72
|
+
for token in key:
|
|
73
|
+
if not isinstance(token, str):
|
|
74
|
+
return False
|
|
75
|
+
child = node._children.get(token)
|
|
76
|
+
if child is None:
|
|
77
|
+
return False
|
|
78
|
+
node = child
|
|
79
|
+
return node._handler is not None
|
|
80
|
+
|
|
81
|
+
def __iter__(self) -> Iterator[tuple[str, ...]]:
|
|
82
|
+
yield from self._iter_keys(())
|
|
83
|
+
|
|
84
|
+
def _iter_keys(
|
|
85
|
+
self, prefix: tuple[str, ...],
|
|
86
|
+
) -> Iterator[tuple[str, ...]]:
|
|
87
|
+
if self._handler is not None:
|
|
88
|
+
yield prefix
|
|
89
|
+
for token, child in self._children.items():
|
|
90
|
+
yield from child._iter_keys((*prefix, token))
|
|
91
|
+
|
|
92
|
+
def __len__(self) -> int:
|
|
93
|
+
count = 1 if self._handler is not None else 0
|
|
94
|
+
for child in self._children.values():
|
|
95
|
+
count += len(child)
|
|
96
|
+
return count
|
|
97
|
+
|
|
98
|
+
def resolve(self, tokens: tuple[str, ...]) -> tuple[PrefixRouter, int]:
|
|
99
|
+
node = self
|
|
100
|
+
depth = 0
|
|
101
|
+
for token in tokens:
|
|
102
|
+
child = node._children.get(token)
|
|
103
|
+
if child is None:
|
|
104
|
+
break
|
|
105
|
+
node = child
|
|
106
|
+
depth += 1
|
|
107
|
+
return node, depth
|
|
108
|
+
|
|
109
|
+
def __repr__(self) -> str:
|
|
110
|
+
return (
|
|
111
|
+
f"PrefixRouter(handler={self._handler is not None}, "
|
|
112
|
+
f"children={list(self._children)})"
|
|
113
|
+
)
|
contree_cli/types.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from enum import Enum
|
|
8
|
+
|
|
9
|
+
from contree_cli import SetupResult
|
|
10
|
+
|
|
11
|
+
IS_A_TTY = sys.stderr.isatty()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ArgumentsFormatter(argparse.RawDescriptionHelpFormatter):
|
|
15
|
+
def _get_help_string(self, action: argparse.Action) -> str:
|
|
16
|
+
help_text = action.help or ""
|
|
17
|
+
if (
|
|
18
|
+
action.default not in (None, False, [], argparse.SUPPRESS)
|
|
19
|
+
and "%(default)" not in help_text
|
|
20
|
+
):
|
|
21
|
+
help_text += " (default: %(default)s)"
|
|
22
|
+
return help_text
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_command_docs(setup_fn: SetupFn) -> tuple[str | None, str | None]:
|
|
26
|
+
"""Extract description and epilog from the module that defines *setup_fn*.
|
|
27
|
+
|
|
28
|
+
The module's ``__doc__`` becomes the description; an optional
|
|
29
|
+
module-level ``EPILOG`` variable becomes the epilog.
|
|
30
|
+
"""
|
|
31
|
+
mod = sys.modules.get(setup_fn.__module__)
|
|
32
|
+
if mod is None:
|
|
33
|
+
return None, None
|
|
34
|
+
return mod.__doc__, getattr(mod, "EPILOG", None)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Colors(Enum):
|
|
38
|
+
"""
|
|
39
|
+
Each enum value is callable and can be used to wrap text in the corresponding color.
|
|
40
|
+
|
|
41
|
+
Example usage:
|
|
42
|
+
print(Colors.BOLD_RED("This is bold red text"))
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
DEFAULT = "\033[0m"
|
|
46
|
+
BOLD = "\033[1m"
|
|
47
|
+
BOLD_BLACK = "\033[1;30m"
|
|
48
|
+
BOLD_BLUE = "\033[1;34m"
|
|
49
|
+
BOLD_CYAN = "\033[1;36m"
|
|
50
|
+
BOLD_GRAY = "\033[1;90m"
|
|
51
|
+
BOLD_GREEN = "\033[1;32m"
|
|
52
|
+
BOLD_MAGENTA = "\033[1;35m"
|
|
53
|
+
BOLD_RED = "\033[1;31m"
|
|
54
|
+
BOLD_WHITE = "\033[1;37m"
|
|
55
|
+
BOLD_YELLOW = "\033[1;33m"
|
|
56
|
+
BLACK = "\033[30m"
|
|
57
|
+
BLUE = "\033[34m"
|
|
58
|
+
CYAN = "\033[36m"
|
|
59
|
+
GRAY = "\033[90m"
|
|
60
|
+
GREEN = "\033[32m"
|
|
61
|
+
MAGENTA = "\033[35m"
|
|
62
|
+
RED = "\033[31m"
|
|
63
|
+
WHITE = "\033[37m"
|
|
64
|
+
YELLOW = "\033[33m"
|
|
65
|
+
|
|
66
|
+
def __call__(self, text: str) -> str:
|
|
67
|
+
if not IS_A_TTY:
|
|
68
|
+
return text
|
|
69
|
+
return f"{self.value}{text}{Colors.DEFAULT.value}"
|
|
70
|
+
|
|
71
|
+
if sys.version_info >= (3, 11):
|
|
72
|
+
def parse_datetime(value: str) -> datetime:
|
|
73
|
+
"""Parse an ISO 8601 datetime string from the API."""
|
|
74
|
+
return datetime.fromisoformat(value).astimezone(tz=timezone.utc)
|
|
75
|
+
else:
|
|
76
|
+
def parse_datetime(value: str) -> datetime:
|
|
77
|
+
"""Parse an ISO 8601 datetime string from the API.
|
|
78
|
+
|
|
79
|
+
Python 3.10 ``fromisoformat`` does not accept the ``Z`` suffix.
|
|
80
|
+
"""
|
|
81
|
+
if value.endswith("Z"):
|
|
82
|
+
value = value[:-1] + "+00:00"
|
|
83
|
+
return datetime.fromisoformat(value).astimezone(tz=timezone.utc)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
SetupFn = Callable[[argparse.ArgumentParser], SetupResult]
|
|
87
|
+
COMMAND_REGISTRY: list[tuple[str, str, SetupFn, list[str]]] = []
|