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.
@@ -0,0 +1,59 @@
1
+ """Start an interactive shell session.
2
+
3
+ Launches a REPL where bare commands (e.g. `apt install curl`) are
4
+ executed in the session container via `run --shell`, and prefixed
5
+ commands (e.g. `contree ls /etc`) are dispatched as management
6
+ commands.
7
+
8
+ Built-in commands: cd, pwd, history, help, exit/quit.
9
+ Tab completion for commands, flags, image paths, tags, and branches.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ from dataclasses import dataclass
15
+
16
+ from contree_cli import CLIENT, SESSION_STORE, ArgumentsProtocol, SetupResult
17
+ from contree_cli.shell.completer import ShellCompleter
18
+ from contree_cli.shell.history import load_history, save_history
19
+ from contree_cli.shell.parser import build_shell_parser
20
+ from contree_cli.shell.repl import ContreeShell
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class ShellArgs(ArgumentsProtocol):
25
+ @classmethod
26
+ def from_args(cls, ns: argparse.Namespace) -> ShellArgs:
27
+ return cls()
28
+
29
+
30
+ def setup_parser(p: argparse.ArgumentParser) -> SetupResult:
31
+ """Register 'contree shell' subcommand."""
32
+ return cmd_shell, ShellArgs
33
+
34
+
35
+ def cmd_shell(_args: ShellArgs) -> int | None:
36
+ """Build parser + completer, start REPL."""
37
+ parser, commands = build_shell_parser()
38
+
39
+ try:
40
+ client = CLIENT.get()
41
+ except LookupError:
42
+ client = None
43
+ try:
44
+ store = SESSION_STORE.get()
45
+ except LookupError:
46
+ store = None
47
+
48
+ completer = ShellCompleter(commands, client=client, store=store, root_parser=parser)
49
+ shell = ContreeShell(parser, completer)
50
+
51
+ if store is not None:
52
+ load_history(store)
53
+ try:
54
+ shell.run()
55
+ finally:
56
+ if store is not None:
57
+ save_history(store)
58
+
59
+ return None
@@ -0,0 +1,465 @@
1
+ """Tab completion for the interactive shell."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import logging
7
+ import shlex
8
+ from typing import TYPE_CHECKING
9
+
10
+ from contree_cli.output import FORMATTERS
11
+ from contree_cli.shell.parser import CommandInfo, ShellArgumentParser, get_command_names
12
+ from contree_cli.shell.trie import Handler, PrefixRouter
13
+
14
+ if TYPE_CHECKING:
15
+ from contree_cli.client import ContreeClient
16
+ from contree_cli.session import ImageCache, SessionStore
17
+
18
+ log = logging.getLogger(__name__)
19
+
20
+
21
+ class ShellCompleter:
22
+ """Readline completer with PrefixRouter-based dispatch."""
23
+
24
+ def __init__(
25
+ self,
26
+ commands: dict[str, CommandInfo],
27
+ client: ContreeClient | None = None,
28
+ store: SessionStore | None = None,
29
+ root_parser: ShellArgumentParser | None = None,
30
+ ) -> None:
31
+ self._commands = commands
32
+ self._command_names = get_command_names()
33
+ self._client = client
34
+ self._store = store
35
+ self._root_parser = root_parser
36
+ self._matches: list[str] = []
37
+
38
+ self.router: PrefixRouter = PrefixRouter()
39
+ self._build_router()
40
+
41
+ def _build_router(self) -> None:
42
+ r = self.router
43
+
44
+ # Shell builtins with no argument completion
45
+ for name in ("exit", "quit", "pwd", "history", "clear"):
46
+ r[(name,)] = self._complete_noop
47
+
48
+ r[("help",)] = self._complete_help_names
49
+ r[("cd",)] = self._complete_dir_only
50
+
51
+ # Editors → container path
52
+ for name in ("vim", "vi", "nano"):
53
+ r[(name,)] = self._complete_container_path
54
+
55
+ # Bare aliases → container path (same as contree ls/cat)
56
+ r[("ls",)] = self._complete_container_path
57
+ r[("cat",)] = self._complete_container_path
58
+
59
+ # Contree subcommand argument completers
60
+ arg_map: dict[str, Handler] = {
61
+ "ls": self._complete_container_path,
62
+ "cat": self._complete_container_path,
63
+ "cp": self._complete_container_path,
64
+ "cd": self._complete_dir_only,
65
+ "use": self._complete_image,
66
+ "tag": self._complete_image,
67
+ "show": self._complete_operation,
68
+ "kill": self._complete_operation,
69
+ }
70
+
71
+ # Register all contree commands (names + aliases)
72
+ for name in self._command_names:
73
+ handler = arg_map.get(name, self._complete_noop)
74
+ r[("contree", name)] = handler
75
+
76
+ # Register subparser children so subcommand name completion works.
77
+ # E.g. "contree file <TAB>" should show "edit", "cp", etc.
78
+ for name, info in self._commands.items():
79
+ for sub_name in self._get_subcommand_names(info.parser):
80
+ key = ("contree", name, sub_name)
81
+ if key not in r:
82
+ r[key] = self._complete_noop
83
+
84
+ # --format / -f — complete format names
85
+ r[("--format",)] = self._complete_format_name
86
+ r[("-f",)] = self._complete_format_name
87
+
88
+ # Nested subcommands with specific completers
89
+ r[("contree", "session", "use")] = self._complete_session_name
90
+ r[("contree", "session", "checkout")] = self._complete_branch
91
+ r[("contree", "session", "co")] = self._complete_branch
92
+ r[("contree", "session", "branch")] = self._complete_branch
93
+ r[("contree", "session", "br")] = self._complete_branch
94
+ r[("contree", "file", "edit")] = self._complete_container_path
95
+ r[("contree", "file", "e")] = self._complete_container_path
96
+
97
+ def complete(self, text: str, state: int) -> str | None:
98
+ """Readline completer function (called repeatedly with state=0,1,...)."""
99
+ if state == 0:
100
+ try:
101
+ import readline
102
+ line = readline.get_line_buffer()
103
+ begidx = readline.get_begidx()
104
+ except (ImportError, AttributeError):
105
+ line = text
106
+ begidx = 0
107
+ self._matches = self.compute_completions(text, line, begidx)
108
+ if state < len(self._matches):
109
+ return self._matches[state]
110
+ return None
111
+
112
+ def compute_completions(
113
+ self, text: str, line: str, begidx: int,
114
+ ) -> list[str]:
115
+ """Determine context and return matching completions."""
116
+ before_cursor = line[:begidx]
117
+ try:
118
+ tokens = shlex.split(before_cursor)
119
+ except ValueError:
120
+ return []
121
+
122
+ # No tokens yet → root child names
123
+ if not tokens:
124
+ return [
125
+ n + " " for n in self.router.children
126
+ if n.startswith(text)
127
+ ]
128
+
129
+ node, depth = self.router.resolve(tuple(tokens))
130
+ remaining = tuple(tokens[depth:])
131
+
132
+ # Flag-value completion: -f/--format <TAB> → format names
133
+ if tokens and tokens[-1] in ("-f", "--format"):
134
+ return self._complete_format_name((), text)
135
+
136
+ # Flags: look up the parser from commands dict
137
+ if text.startswith("-"):
138
+ parser = self._find_parser(tokens)
139
+ if parser is not None:
140
+ return self._complete_flags(parser, text)
141
+
142
+ # Children → subcommand name completion
143
+ if node.children:
144
+ matches = [
145
+ n + " " for n in node.children
146
+ if n.startswith(text)
147
+ ]
148
+ if matches:
149
+ return matches
150
+
151
+ # Handler with remaining tokens
152
+ if node.value is not None:
153
+ return node.value(remaining, text)
154
+
155
+ # Fallback: implicit run mode — path-like text gets path completion,
156
+ # anything else gets root command names as suggestions.
157
+ if self._looks_like_path(text):
158
+ return self._complete_container_path((), text)
159
+
160
+ return [
161
+ n + " " for n in self.router.children
162
+ if n.startswith(text)
163
+ ]
164
+
165
+ # ------------------------------------------------------------------
166
+ # Parser lookup for flag completion
167
+ # ------------------------------------------------------------------
168
+
169
+ def _find_parser(
170
+ self, tokens: list[str],
171
+ ) -> argparse.ArgumentParser | None:
172
+ """Find the argparse parser for the current command context."""
173
+ # Strip "contree" prefix if present
174
+ cmd_tokens = tokens[1:] if tokens and tokens[0] == "contree" else tokens
175
+ if not cmd_tokens:
176
+ return None
177
+ cmd_name = cmd_tokens[0]
178
+ if cmd_name not in self._commands:
179
+ return None
180
+ return self._commands[cmd_name].parser
181
+
182
+ # ------------------------------------------------------------------
183
+ # Completion handlers (remaining, text) -> list[str]
184
+ # ------------------------------------------------------------------
185
+
186
+ def _complete_noop(
187
+ self, remaining: tuple[str, ...], text: str,
188
+ ) -> list[str]:
189
+ return []
190
+
191
+ def _complete_help_names(
192
+ self, remaining: tuple[str, ...], text: str,
193
+ ) -> list[str]:
194
+ """``help <name>`` — all known command / alias / shell names."""
195
+ all_names = sorted({*self._command_names, *self.router.children})
196
+ return [n + " " for n in all_names if n.startswith(text)]
197
+
198
+ def _complete_format_name(
199
+ self, remaining: tuple[str, ...], text: str,
200
+ ) -> list[str]:
201
+ """Complete output format names (``--format json``, ``-f table``)."""
202
+ return [n + " " for n in sorted(FORMATTERS) if n.startswith(text)]
203
+
204
+ def _complete_dir_only(
205
+ self, remaining: tuple[str, ...], text: str,
206
+ ) -> list[str]:
207
+ """Complete container directory paths (no files)."""
208
+ return self._complete_container_path_inner(text, dirs_only=True)
209
+
210
+ def _complete_container_path(
211
+ self, remaining: tuple[str, ...], text: str,
212
+ ) -> list[str]:
213
+ """Complete a container file/directory path."""
214
+ return self._complete_container_path_inner(text)
215
+
216
+ @staticmethod
217
+ def _looks_like_path(text: str) -> bool:
218
+ """Return True when *text* looks like a filesystem path."""
219
+ return (
220
+ "/" in text
221
+ or text.startswith(".")
222
+ or text.startswith("~")
223
+ )
224
+
225
+ # ------------------------------------------------------------------
226
+ # Low-level helpers
227
+ # ------------------------------------------------------------------
228
+
229
+ @staticmethod
230
+ def _get_subcommand_names(
231
+ parser: argparse.ArgumentParser,
232
+ ) -> list[str]:
233
+ """Extract subcommand names from a parser (if it has subparsers)."""
234
+ for action in parser._actions:
235
+ if isinstance(action, argparse._SubParsersAction):
236
+ return list(action.choices.keys())
237
+ return []
238
+
239
+ def _complete_flags(
240
+ self, parser: argparse.ArgumentParser, text: str,
241
+ ) -> list[str]:
242
+ """Complete flag names from parser actions."""
243
+ flags: list[str] = []
244
+ for action in parser._actions:
245
+ for opt in action.option_strings:
246
+ if opt.startswith(text):
247
+ flags.append(opt + " ")
248
+ return flags
249
+
250
+ def _complete_container_path_inner(
251
+ self, text: str, *, dirs_only: bool = False,
252
+ ) -> list[str]:
253
+ """Complete a container file path via the inspect API."""
254
+ if self._client is None or self._store is None:
255
+ return []
256
+
257
+ try:
258
+ session = self._store.session
259
+ if session is None:
260
+ return []
261
+ image_uuid = session.current_image
262
+ except (SystemExit, Exception):
263
+ return []
264
+
265
+ # Split into directory + prefix for the API query.
266
+ # resolve_path handles cwd joining and .. normalisation.
267
+ if "/" in text:
268
+ last_slash = text.rindex("/")
269
+ user_dir = text[: last_slash + 1] or "/"
270
+ prefix = text[last_slash + 1 :]
271
+ resolved = self._store.resolve_path(user_dir)
272
+ api_dir = resolved if resolved == "/" else resolved + "/"
273
+ else:
274
+ user_dir = ""
275
+ prefix = text
276
+ resolved = self._store.resolve_path("")
277
+ api_dir = resolved if resolved == "/" else resolved + "/"
278
+
279
+ entries = self._list_dir(image_uuid, api_dir)
280
+ if entries is None:
281
+ return []
282
+
283
+ results: list[str] = []
284
+ for entry in entries:
285
+ path = entry.get("path", "")
286
+ if not isinstance(path, str) or not path:
287
+ continue
288
+ is_dir = bool(entry.get("is_dir"))
289
+ if dirs_only and not is_dir:
290
+ continue
291
+ # API returns full paths like "/etc/hosts" — extract basename
292
+ name = path.rsplit("/", 1)[-1]
293
+ if not name.startswith(prefix):
294
+ continue
295
+ # Return the path as the user typed it (relative or absolute)
296
+ full = user_dir + name
297
+ if is_dir:
298
+ full += "/"
299
+ else:
300
+ full += " "
301
+ results.append(full)
302
+ return results
303
+
304
+ def _complete_image(
305
+ self, remaining: tuple[str, ...], text: str,
306
+ ) -> list[str]:
307
+ """Complete image references (``tag:NAME`` or UUID).
308
+
309
+ When the user types a ``tag:`` prefix, matches are filtered
310
+ against the full ``tag:NAME`` candidate. Otherwise bare text
311
+ is matched against tag names directly and the completion
312
+ inserts the ``tag:`` prefix for the user.
313
+ """
314
+ images = self._list_images()
315
+ if images is None:
316
+ return []
317
+
318
+ results: list[str] = []
319
+ for img in images:
320
+ tag = img.get("tag")
321
+ if isinstance(tag, str) and tag:
322
+ prefixed = f"tag:{tag}"
323
+ if text.startswith("tag:"):
324
+ if prefixed.startswith(text):
325
+ results.append(prefixed + " ")
326
+ elif tag.startswith(text):
327
+ results.append(prefixed + " ")
328
+ uuid_str = img.get("uuid")
329
+ if isinstance(uuid_str, str) and uuid_str.startswith(text):
330
+ results.append(uuid_str + " ")
331
+ return results
332
+
333
+ def _complete_operation(
334
+ self, remaining: tuple[str, ...], text: str,
335
+ ) -> list[str]:
336
+ """Complete operation UUIDs for ``show`` and ``kill``."""
337
+ ops = self._list_operations()
338
+ if ops is None:
339
+ return []
340
+ results: list[str] = []
341
+ for op in ops:
342
+ uuid_str = op.get("uuid")
343
+ if isinstance(uuid_str, str) and uuid_str.startswith(text):
344
+ results.append(uuid_str + " ")
345
+ return results
346
+
347
+ def _list_operations(self) -> list[dict[str, object]] | None:
348
+ """Fetch recent operations from the API (no caching)."""
349
+ if self._client is None:
350
+ return None
351
+ try:
352
+ resp = self.client.get(
353
+ "/v1/operations", params={"limit": "100"},
354
+ )
355
+ data = json.loads(resp.read())
356
+ return data.get("operations", []) # type: ignore[no-any-return]
357
+ except Exception:
358
+ log.debug("Operation completion failed")
359
+ return None
360
+
361
+ def _complete_session_name(
362
+ self, remaining: tuple[str, ...], text: str,
363
+ ) -> list[str]:
364
+ """Complete session names for ``session use``."""
365
+ if self._store is None:
366
+ return []
367
+ try:
368
+ sessions = self._store.list_sessions()
369
+ except Exception:
370
+ return []
371
+ results: list[str] = []
372
+ for s in sessions:
373
+ key = s.session_key
374
+ # Match full key
375
+ if key.startswith(text):
376
+ results.append(key + " ")
377
+ # Match suffix (last component after _)
378
+ suffix = key.rsplit("_", 1)[-1] if "_" in key else ""
379
+ if suffix and suffix != key and suffix.startswith(text):
380
+ results.append(suffix + " ")
381
+ return results
382
+
383
+ def _complete_branch(
384
+ self, remaining: tuple[str, ...], text: str,
385
+ ) -> list[str]:
386
+ """Complete branch names for ``session checkout/branch``."""
387
+ if self._store is None:
388
+ return []
389
+ try:
390
+ branches = self._store.list_branches()
391
+ except Exception:
392
+ return []
393
+ return [
394
+ name + " " for name, _active in branches
395
+ if name.startswith(text)
396
+ ]
397
+
398
+ @property
399
+ def client(self) -> ContreeClient:
400
+ if self._client is None:
401
+ raise RuntimeError("ContreeClient is not set")
402
+ return self._client
403
+
404
+ @property
405
+ def cache(self) -> ImageCache:
406
+ if self._store is None:
407
+ raise RuntimeError("SessionStore is not set")
408
+ return self._store.cache
409
+
410
+ def cached(
411
+ self, key: tuple[str, str],
412
+ ) -> list[dict[str, object]] | None:
413
+ """Return a cached value or ``None``."""
414
+ result = self.cache.get(key)
415
+ return result # type: ignore[return-value]
416
+
417
+ def _list_images(self) -> list[dict[str, object]] | None:
418
+ """Fetch image list from the API, with persistent caching."""
419
+ if self._client is None or self._store is None:
420
+ return None
421
+
422
+ cache_key = ("", "images")
423
+ cached = self.cached(cache_key)
424
+ if cached is not None:
425
+ return cached
426
+
427
+ try:
428
+ resp = self.client.get(
429
+ "/v1/images", params={"limit": "100"},
430
+ )
431
+ data = json.loads(resp.read())
432
+ images: list[dict[str, object]] = data.get("images", [])
433
+ self.cache[cache_key] = images
434
+ return images
435
+ except Exception:
436
+ log.debug("Image completion failed")
437
+ return None
438
+
439
+ def _list_dir(
440
+ self, image_uuid: str, dir_path: str,
441
+ ) -> list[dict[str, object]] | None:
442
+ """List a container directory, with persistent caching."""
443
+ cache_key = (image_uuid, f"files:{dir_path}")
444
+
445
+ cached = self.cached(cache_key)
446
+ if cached is not None:
447
+ return cached
448
+
449
+ try:
450
+ from contree_cli.client import resolve_image
451
+ uuid = resolve_image(self.client, image_uuid)
452
+ resp = self.client.get(
453
+ f"/v1/inspect/{uuid}/list",
454
+ params={"path": dir_path},
455
+ )
456
+ data = json.loads(resp.read())
457
+ file_list: list[dict[str, object]] = data.get("files", [])
458
+ self.cache[cache_key] = file_list
459
+ return file_list
460
+ except Exception:
461
+ log.debug(
462
+ "Path completion failed for %s:%s",
463
+ image_uuid, dir_path,
464
+ )
465
+ return None
@@ -0,0 +1,53 @@
1
+ """Readline history persistence backed by the session SQLite DB."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ from typing import TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING:
8
+ from contree_cli.session import SessionStore
9
+
10
+ log = logging.getLogger(__name__)
11
+
12
+
13
+ def load_history(store: SessionStore) -> None:
14
+ """Populate readline history from the session database."""
15
+ try:
16
+ import readline
17
+ except ImportError:
18
+ return
19
+
20
+ lines = store.load_shell_history()
21
+ for line in lines:
22
+ readline.add_history(line)
23
+ log.debug("Loaded %d history lines from session DB", len(lines))
24
+
25
+
26
+ def save_history(store: SessionStore) -> None:
27
+ """Persist current readline history into the session database.
28
+
29
+ Compares what readline holds against what the DB already has and
30
+ appends only the new tail entries. Then trims to the maximum.
31
+ """
32
+ try:
33
+ import readline
34
+ except ImportError:
35
+ return
36
+
37
+ total = readline.get_current_history_length()
38
+ existing = store.load_shell_history()
39
+ existing_count = len(existing)
40
+
41
+ # Readline history is 1-indexed.
42
+ new_count = total - existing_count
43
+ if new_count <= 0:
44
+ return
45
+
46
+ start = existing_count + 1
47
+ for i in range(start, total + 1):
48
+ item = readline.get_history_item(i)
49
+ if item is not None:
50
+ store.add_shell_history(item)
51
+
52
+ store.trim_shell_history()
53
+ log.debug("Saved %d new history lines to session DB", new_count)
@@ -0,0 +1,107 @@
1
+ """Shell-safe argument parser that raises instead of calling sys.exit()."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ from dataclasses import dataclass
6
+ from typing import NoReturn
7
+
8
+ from contree_cli import Handler
9
+ from contree_cli.output import FORMATTERS
10
+ from contree_cli.types import COMMAND_REGISTRY, ArgumentsFormatter, get_command_docs
11
+
12
+
13
+ class ShellParseError(Exception):
14
+ """Raised instead of sys.exit() when argparse encounters bad input."""
15
+
16
+ def __init__(self, message: str, usage: str, status: int = 2) -> None:
17
+ super().__init__(message)
18
+ self.message = message
19
+ self.usage = usage
20
+ self.status = status
21
+
22
+
23
+ class ShellArgumentParser(argparse.ArgumentParser):
24
+ """ArgumentParser that raises ShellParseError instead of calling sys.exit()."""
25
+
26
+ def exit(self, status: int = 0, message: str | None = None) -> NoReturn:
27
+ raise ShellParseError(message or "", usage="", status=status)
28
+
29
+ def error(self, message: str) -> NoReturn:
30
+ raise ShellParseError(message, usage=self.format_usage())
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class CommandInfo:
35
+ """Metadata about a registered shell command."""
36
+
37
+ name: str
38
+ handler: Handler
39
+ parser: ShellArgumentParser
40
+ aliases: list[str]
41
+
42
+
43
+ def _ensure_registered() -> None:
44
+ """Import arguments module to trigger register() calls if needed."""
45
+ if not COMMAND_REGISTRY:
46
+ import contree_cli.arguments # noqa: F401
47
+
48
+
49
+ def build_shell_parser() -> tuple[ShellArgumentParser, dict[str, CommandInfo]]:
50
+ """Build a parser tree from the same setup_parser() functions used by the CLI.
51
+
52
+ Returns the root parser and a dict mapping command names (and aliases)
53
+ to their CommandInfo for use by the completer.
54
+ """
55
+ _ensure_registered()
56
+ root = ShellArgumentParser(prog="", add_help=False)
57
+ root.add_argument(
58
+ "-f", "--format",
59
+ dest="output_format",
60
+ default=None,
61
+ choices=sorted(FORMATTERS),
62
+ )
63
+ subparsers = root.add_subparsers(
64
+ dest="command",
65
+ parser_class=ShellArgumentParser,
66
+ )
67
+
68
+ commands: dict[str, CommandInfo] = {}
69
+
70
+ for name, help_text, setup_fn, aliases in COMMAND_REGISTRY:
71
+ if name == "shell":
72
+ continue
73
+ description, epilog = get_command_docs(setup_fn)
74
+ sub_parser = subparsers.add_parser(
75
+ name,
76
+ help=help_text,
77
+ aliases=aliases,
78
+ description=description,
79
+ epilog=epilog,
80
+ formatter_class=ArgumentsFormatter,
81
+ )
82
+ handler, loader = setup_fn(sub_parser)
83
+ sub_parser.set_defaults(handler=handler, load_args=loader)
84
+
85
+ info = CommandInfo(
86
+ name=name,
87
+ handler=handler,
88
+ parser=sub_parser,
89
+ aliases=aliases,
90
+ )
91
+ commands[name] = info
92
+ for alias in aliases:
93
+ commands[alias] = info
94
+
95
+ return root, commands
96
+
97
+
98
+ def get_command_names() -> list[str]:
99
+ """Return all command names and aliases (for completion)."""
100
+ _ensure_registered()
101
+ names: list[str] = []
102
+ for name, _, _, aliases in COMMAND_REGISTRY:
103
+ if name == "shell":
104
+ continue
105
+ names.append(name)
106
+ names.extend(aliases)
107
+ return names