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,27 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ from collections.abc import Callable
5
+ from contextvars import ContextVar
6
+ from typing import TYPE_CHECKING, Protocol
7
+
8
+ if TYPE_CHECKING:
9
+ from contree_cli.client import ContreeClient
10
+ from contree_cli.config import ConfigProfile
11
+ from contree_cli.output import OutputFormatter
12
+ from contree_cli.session import SessionStore
13
+
14
+ PROFILE: ContextVar[ConfigProfile] = ContextVar("PROFILE")
15
+ CLIENT: ContextVar[ContreeClient] = ContextVar("CLIENT")
16
+ FORMATTER: ContextVar[OutputFormatter] = ContextVar("FORMATTER")
17
+ SESSION_STORE: ContextVar[SessionStore] = ContextVar("SESSION_STORE")
18
+ IN_SHELL: ContextVar[bool] = ContextVar("IN_SHELL", default=False)
19
+
20
+
21
+ class ArgumentsProtocol(Protocol):
22
+ @classmethod
23
+ def from_args(cls, ns: argparse.Namespace) -> ArgumentsProtocol: ...
24
+
25
+
26
+ Handler = Callable[..., int | None]
27
+ SetupResult = tuple[Handler, type[ArgumentsProtocol]]
@@ -0,0 +1,62 @@
1
+ import contextvars
2
+ import logging
3
+ from collections.abc import Callable
4
+
5
+ import contree_cli.config as config_mod
6
+ from contree_cli import CLIENT, FORMATTER, PROFILE, SESSION_STORE, ArgumentsProtocol
7
+ from contree_cli.arguments import parser
8
+ from contree_cli.client import ApiError, ContreeClient
9
+ from contree_cli.config import load_config
10
+ from contree_cli.log import setup_logging
11
+ from contree_cli.output import FORMATTERS
12
+ from contree_cli.session import SessionStore, get_db_path, get_session_key
13
+
14
+ log = logging.getLogger(__name__)
15
+
16
+
17
+ def main() -> None:
18
+ args = parser.parse_args()
19
+ setup_logging(level=getattr(logging, args.log_level.upper(), logging.INFO))
20
+
21
+ config_mod.CONFIG_FILE = args.config_path
22
+ config_mod.CONFIG_DIR = args.config_path.parent
23
+
24
+ profile = load_config()
25
+
26
+ token = args.token or profile.token
27
+ url = args.url or profile.url
28
+ client = ContreeClient(url, token)
29
+
30
+ formatter = FORMATTERS[args.output_format]()
31
+
32
+ session_key = get_session_key(profile.name)
33
+ db_path = get_db_path()
34
+ store = SessionStore(db_path, session_key)
35
+
36
+ PROFILE.set(profile)
37
+ CLIENT.set(client)
38
+ FORMATTER.set(formatter)
39
+ SESSION_STORE.set(store)
40
+ ctx = contextvars.copy_context()
41
+
42
+ loader: type[ArgumentsProtocol] = args.load_args
43
+ handler: Callable[[ArgumentsProtocol], int | None] = args.handler
44
+
45
+ try:
46
+ exit_code = ctx.run(handler, loader.from_args(args))
47
+ except ApiError as exc:
48
+ log.error("%s", exc)
49
+ exit(1)
50
+ except KeyboardInterrupt:
51
+ log.error("User interrupted")
52
+ exit(1)
53
+ finally:
54
+ store.close()
55
+ formatter.flush()
56
+
57
+ exit(exit_code or 0)
58
+
59
+
60
+
61
+ if __name__ == "__main__":
62
+ main()
@@ -0,0 +1,141 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ from pathlib import Path
5
+
6
+ from contree_cli.cli import (
7
+ auth,
8
+ cat,
9
+ cd,
10
+ cp,
11
+ file,
12
+ images,
13
+ kill,
14
+ ls,
15
+ ps,
16
+ run,
17
+ session,
18
+ show,
19
+ tag,
20
+ use,
21
+ )
22
+ from contree_cli.config import CONFIG_FILE
23
+ from contree_cli.output import FORMATTERS
24
+ from contree_cli.shell import setup_parser as shell_setup_parser
25
+ from contree_cli.types import (
26
+ COMMAND_REGISTRY,
27
+ ArgumentsFormatter,
28
+ SetupFn,
29
+ get_command_docs,
30
+ )
31
+
32
+ EPILOG = """\
33
+ examples:
34
+ contree use tag:ubuntu:latest set session image
35
+ eval $(contree use tag:ubuntu:latest) set + export env var
36
+ contree run -- uname -a run command in session image
37
+ contree run --shell -- 'echo hi' shell mode
38
+ contree run --file ./app.py:/app.py --disposable -- python /app.py
39
+ contree images --prefix=ubuntu
40
+ contree ps -q
41
+ contree show OPERATION_UUID
42
+ contree tag IMAGE_UUID latest
43
+ contree ls /etc list files in session image
44
+ contree cat /etc/os-release show file from session image
45
+ contree auth save token (secure prompt)
46
+ contree auth switch staging
47
+
48
+ environment variables:
49
+ CONTREE_TOKEN API bearer token (overrides config file)
50
+ CONTREE_URL API base URL (overrides config file)
51
+ CONTREE_PROFILE Active config profile (overrides config file)
52
+ CONTREE_SESSION Explicit session name (for multi-terminal workflows)
53
+ CONTREE_SESSION_DB Path to session SQLite database
54
+ """
55
+
56
+ DESCRIPTION = """\
57
+ Contree CLI - command-line client for the Contree container platform.
58
+
59
+ Run containers, manage images, inspect filesystems, and track operations
60
+ through the Contree REST API (https://contree.dev).
61
+
62
+ Authentication:
63
+ All API calls require a bearer token. Provide it via --token or set the
64
+ CONTREE_TOKEN environment variable. Tokens are issued per-project and
65
+ grant scoped permissions.
66
+
67
+ Use `contree auth --help` to configure persistent credentials.
68
+ """
69
+
70
+ parser = argparse.ArgumentParser(
71
+ description=DESCRIPTION,
72
+ epilog=EPILOG,
73
+ formatter_class=ArgumentsFormatter,
74
+ )
75
+ parser.add_argument(
76
+ "--token", default=None, help="API token (overrides config and env)",
77
+ )
78
+ parser.add_argument(
79
+ "--url", default=None, help="API base URL (overrides config and env)",
80
+ )
81
+
82
+ parser.add_argument(
83
+ "-c", "--config",
84
+ type=Path,
85
+ default=CONFIG_FILE,
86
+ dest="config_path",
87
+ help="Config file path",
88
+ )
89
+ parser.add_argument(
90
+ "-L", "--log-level",
91
+ default="info",
92
+ choices=("debug", "info", "warning", "error", "critical"),
93
+ help="Logging level",
94
+ )
95
+ parser.add_argument(
96
+ "-f", "--format",
97
+ default="default",
98
+ choices=sorted(FORMATTERS),
99
+ dest="output_format",
100
+ help="Output format",
101
+ )
102
+ subparsers = parser.add_subparsers(dest="command", required=True)
103
+
104
+
105
+ def register(
106
+ name: str, help: str,
107
+ setup_fn: SetupFn,
108
+ aliases: list[str] | None = None,
109
+ ) -> None:
110
+ aliases_list = aliases or []
111
+ COMMAND_REGISTRY.append((name, help, setup_fn, aliases_list))
112
+ description, epilog = get_command_docs(setup_fn)
113
+ p = subparsers.add_parser(
114
+ name, help=help,
115
+ aliases=aliases_list,
116
+ description=description,
117
+ epilog=epilog,
118
+ formatter_class=ArgumentsFormatter,
119
+ )
120
+ handler, loader = setup_fn(p)
121
+ p.set_defaults(handler=handler, load_args=loader)
122
+
123
+
124
+ register("use", "Set or show current session image", use.setup_parser, aliases=["ci"])
125
+ register("run", "Spawn a container instance", run.setup_parser)
126
+ register("images", "List images", images.setup_parser, aliases=["img"])
127
+ register("tag", "Tag an image", tag.setup_parser)
128
+ register("ps", "List operations/instances", ps.setup_parser)
129
+ register("kill", "Cancel an operation", kill.setup_parser)
130
+ register("show", "Show operation result", show.setup_parser)
131
+ register("ls", "List files in image", ls.setup_parser)
132
+ register("cat", "Show file content from image", cat.setup_parser)
133
+ register("cp", "Copy file from image to local path", cp.setup_parser)
134
+ register("file", "Manage files in session image", file.setup_parser)
135
+ register(
136
+ "session", "Manage session branches and history",
137
+ session.setup_parser, aliases=["s"],
138
+ )
139
+ register("auth", "Configure authentication", auth.setup_parser)
140
+ register("cd", "Change working directory", cd.setup_parser)
141
+ register("shell", "Interactive shell mode", shell_setup_parser)
File without changes
@@ -0,0 +1,124 @@
1
+ """Configure authentication credentials.
2
+
3
+ Validates a token against the API (GET /v1/whoami) and saves it to the
4
+ config file under the specified profile. The token is prompted securely
5
+ via getpass if --token is not provided.
6
+
7
+ Subcommands:
8
+ profiles List saved profiles (* marks active)
9
+ switch NAME Switch the active profile
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import getpass
15
+ import logging
16
+ from dataclasses import dataclass
17
+
18
+ from contree_cli import FORMATTER, ArgumentsProtocol, SetupResult
19
+ from contree_cli.client import ApiError, ContreeClient
20
+ from contree_cli.config import (
21
+ DEFAULT_URL,
22
+ list_profiles,
23
+ profile_exists,
24
+ save_profile,
25
+ switch_profile,
26
+ )
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class AuthArgs(ArgumentsProtocol):
33
+ token: str
34
+ url: str = DEFAULT_URL
35
+ profile: str = "default"
36
+ force: bool = False
37
+
38
+ @classmethod
39
+ def from_args(cls, ns: argparse.Namespace) -> AuthArgs:
40
+ return cls(
41
+ token=ns.auth_token or getpass.getpass("Token: "),
42
+ url=ns.auth_url or DEFAULT_URL,
43
+ profile=ns.profile or "default",
44
+ force=ns.force,
45
+ )
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class ProfilesArgs(ArgumentsProtocol):
50
+ @classmethod
51
+ def from_args(cls, ns: argparse.Namespace) -> ProfilesArgs:
52
+ return cls()
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class SwitchArgs(ArgumentsProtocol):
57
+ profile_name: str
58
+
59
+ @classmethod
60
+ def from_args(cls, ns: argparse.Namespace) -> SwitchArgs:
61
+ return cls(profile_name=ns.profile_name)
62
+
63
+
64
+ def setup_parser(p: argparse.ArgumentParser) -> SetupResult:
65
+ p.add_argument("--token", dest="auth_token", help="API token (prompted if omitted)")
66
+ p.add_argument("--url", dest="auth_url", help="API base URL", default=DEFAULT_URL)
67
+ p.add_argument("--profile", help="Profile name", default="default")
68
+ p.add_argument(
69
+ "--force", action="store_true",
70
+ help="Overwrite existing profile without confirmation",
71
+ )
72
+
73
+ auth_sub = p.add_subparsers(dest="auth_action")
74
+
75
+ profiles_parser = auth_sub.add_parser(
76
+ "profiles", help="List saved profiles",
77
+ )
78
+ profiles_parser.set_defaults(
79
+ handler=cmd_profiles, load_args=ProfilesArgs,
80
+ )
81
+
82
+ switch_parser = auth_sub.add_parser(
83
+ "switch", help="Switch active profile",
84
+ )
85
+ switch_parser.set_defaults(handler=cmd_switch, load_args=SwitchArgs)
86
+ switch_parser.add_argument("profile_name", help="Profile to activate")
87
+ return cmd_auth, AuthArgs
88
+
89
+
90
+ def cmd_auth(args: AuthArgs) -> int | None:
91
+ client = ContreeClient(args.url, args.token)
92
+ try:
93
+ client.get("/v1/whoami")
94
+ except ApiError as exc:
95
+ logger.error("Token verification failed: %s", exc)
96
+ return 1
97
+
98
+ if not args.force and profile_exists(args.profile):
99
+ answer = input(
100
+ f"Profile {args.profile!r} already exists."
101
+ " Overwrite? [y/N] ",
102
+ )
103
+ if answer.lower() != "y":
104
+ print("Aborted.")
105
+ return 1
106
+ save_profile(args.profile, args.token, args.url)
107
+ logger.info("Saved profile %r", args.profile)
108
+ return None
109
+
110
+
111
+ def cmd_profiles(_: ProfilesArgs) -> None:
112
+ profiles = list_profiles()
113
+ logger.info("Configured profiles (* stands for active)")
114
+ if not profiles:
115
+ print("No profiles configured.")
116
+ return
117
+ formatter = FORMATTER.get()
118
+ for name, active in profiles:
119
+ formatter(name=name, active=active)
120
+
121
+
122
+ def cmd_switch(args: SwitchArgs) -> None:
123
+ switch_profile(args.profile_name)
124
+ logger.info("Switched to profile %r", args.profile_name)
contree_cli/cli/cat.py ADDED
@@ -0,0 +1,74 @@
1
+ """Show file content from the session image.
2
+
3
+ Downloads and displays a file from the current session image via the
4
+ /inspect/ API without spawning an instance. Binary files are refused
5
+ when stdout is a terminal — use shell redirection or `contree cp` to
6
+ save them locally.
7
+
8
+ Results are cached per (image, path) so repeated reads are instant.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import base64
14
+ import logging
15
+ import sys
16
+ from dataclasses import dataclass
17
+ from typing import cast
18
+
19
+ from contree_cli import CLIENT, FORMATTER, SESSION_STORE, ArgumentsProtocol, SetupResult
20
+ from contree_cli.client import resolve_image
21
+ from contree_cli.output import DefaultFormatter
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class CatArgs(ArgumentsProtocol):
28
+ path: str
29
+
30
+ @classmethod
31
+ def from_args(cls, ns: argparse.Namespace) -> CatArgs:
32
+ return cls(path=ns.path)
33
+
34
+
35
+ def setup_parser(p: argparse.ArgumentParser) -> SetupResult:
36
+ p.add_argument("path", help="Path inside image")
37
+ return cmd_cat, CatArgs
38
+
39
+
40
+ def cmd_cat(args: CatArgs) -> int | None:
41
+ client = CLIENT.get()
42
+ formatter = FORMATTER.get()
43
+ if not isinstance(formatter, DefaultFormatter):
44
+ logger.warning("cat always outputs raw content; --format is ignored")
45
+
46
+ store = SESSION_STORE.get()
47
+ image = store.current_image
48
+ path = store.resolve_path(args.path)
49
+ uuid = resolve_image(client, image)
50
+
51
+ cache_key = (uuid, f"download:{path}")
52
+ cached = store.cache.get(cache_key)
53
+ if cached is not None:
54
+ data = base64.b64decode(cast(str, cached))
55
+ else:
56
+ resp = client.get(f"/v1/inspect/{uuid}/download", params={"path": path})
57
+ data = resp.read()
58
+ store.cache[cache_key] = base64.b64encode(data).decode("ascii")
59
+
60
+ if not sys.stdout.isatty():
61
+ sys.stdout.buffer.write(data)
62
+ return None
63
+
64
+ try:
65
+ data.decode("utf-8")
66
+ except UnicodeDecodeError:
67
+ logger.error(
68
+ "Binary content - refusing to write to terminal. "
69
+ "Use shell redirection or `contree cp`.",
70
+ )
71
+ return 1
72
+
73
+ sys.stdout.buffer.write(data)
74
+ return None
contree_cli/cli/cd.py ADDED
@@ -0,0 +1,49 @@
1
+ """Change the working directory in the current session.
2
+
3
+ Sets the session's cwd used by subsequent commands (run, ls, cat).
4
+ Relative paths are resolved against the current cwd. Without an
5
+ argument, prints the current working directory.
6
+
7
+ Note: the path is not validated against the image filesystem — errors
8
+ surface only when the next command uses the invalid cwd.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import posixpath
14
+ from dataclasses import dataclass
15
+
16
+ from contree_cli import SESSION_STORE, ArgumentsProtocol, SetupResult
17
+
18
+
19
+ def setup_parser(p: argparse.ArgumentParser) -> SetupResult:
20
+ p.add_argument("path", nargs="?", default=None, help="Target directory")
21
+ return cmd_cd, CdArgs
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class CdArgs(ArgumentsProtocol):
26
+ path: str | None
27
+
28
+ @classmethod
29
+ def from_args(cls, ns: argparse.Namespace) -> CdArgs:
30
+ return cls(path=ns.path)
31
+
32
+
33
+ def cmd_cd(args: CdArgs) -> None:
34
+ store = SESSION_STORE.get()
35
+
36
+ if args.path is None:
37
+ cwd = store.get_cwd()
38
+ print(cwd or "/")
39
+ return
40
+
41
+ target = args.path
42
+ current = store.get_cwd() or "/"
43
+
44
+ if target.startswith("/"):
45
+ new_cwd = posixpath.normpath(target)
46
+ else:
47
+ new_cwd = posixpath.normpath(posixpath.join(current, target))
48
+
49
+ store.set_cwd(new_cwd)
contree_cli/cli/cp.py ADDED
@@ -0,0 +1,107 @@
1
+ """Copy a file from the session image to a local path.
2
+
3
+ Downloads the file at PATH inside the current session image and writes
4
+ it to DEST on the local filesystem. Progress is logged for large files.
5
+ Unlike `cat`, this command handles binary content and does not require
6
+ a terminal.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import logging
12
+ import time
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+
16
+ from contree_cli import CLIENT, FORMATTER, SESSION_STORE, ArgumentsProtocol, SetupResult
17
+ from contree_cli.client import resolve_image, stream_response
18
+ from contree_cli.output import DefaultFormatter
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ LOG_INTERVAL = 5.0 # seconds between progress logs
23
+
24
+
25
+ def fmt_size(n: int | float) -> str:
26
+ for unit in ("B", "KiB", "MiB", "GiB"):
27
+ if abs(n) < 1024:
28
+ return f"{n:.1f} {unit}"
29
+ n /= 1024
30
+ return f"{n:.1f} TiB"
31
+
32
+
33
+ def fmt_duration(seconds: float) -> str:
34
+ if seconds < 60:
35
+ return f"{seconds:.0f}s"
36
+ minutes, secs = divmod(int(seconds), 60)
37
+ if minutes < 60:
38
+ return f"{minutes}m{secs:02d}s"
39
+ hours, minutes = divmod(minutes, 60)
40
+ return f"{hours}h{minutes:02d}m{secs:02d}s"
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class CpArgs(ArgumentsProtocol):
45
+ path: str
46
+ dest: str
47
+
48
+ @classmethod
49
+ def from_args(cls, ns: argparse.Namespace) -> CpArgs:
50
+ return cls(path=ns.path, dest=ns.dest)
51
+
52
+
53
+ def setup_parser(p: argparse.ArgumentParser) -> SetupResult:
54
+ p.add_argument("path", help="Path inside image")
55
+ p.add_argument("dest", help="Local destination path")
56
+ return cmd_cp, CpArgs
57
+
58
+
59
+ def cmd_cp(args: CpArgs) -> int | None:
60
+ client = CLIENT.get()
61
+ formatter = FORMATTER.get()
62
+ if not isinstance(formatter, DefaultFormatter):
63
+ logger.warning("cp always outputs raw content; --format is ignored")
64
+
65
+ store = SESSION_STORE.get()
66
+ image = store.current_image
67
+ path = store.resolve_path(args.path)
68
+ uuid = resolve_image(client, image)
69
+ resp = client.get(f"/v1/inspect/{uuid}/download", params={"path": path})
70
+
71
+ total: int | None = None
72
+ cl = resp.getheader("Content-Length")
73
+ if cl is not None:
74
+ total = int(cl)
75
+
76
+ downloaded = 0
77
+ start = time.monotonic()
78
+ last_log = start
79
+
80
+ with Path(args.dest).open("wb") as f:
81
+ for chunk in stream_response(resp):
82
+ f.write(chunk)
83
+ downloaded += len(chunk)
84
+
85
+ now = time.monotonic()
86
+ if now - last_log >= LOG_INTERVAL:
87
+ last_log = now
88
+ elapsed = now - start
89
+ speed = downloaded / elapsed if elapsed > 0 else 0
90
+ parts = [f"{fmt_size(downloaded)} downloaded"]
91
+ if total:
92
+ pct = downloaded / total * 100
93
+ remaining = (
94
+ (total - downloaded) / speed if speed > 0 else 0
95
+ )
96
+ parts.append(f"{pct:.0f}%")
97
+ parts.append(f"ETA {fmt_duration(remaining)}")
98
+ parts.append(f"{fmt_size(speed)}/s")
99
+ logger.info("%s", " | ".join(parts))
100
+
101
+ elapsed = time.monotonic() - start
102
+ speed = downloaded / elapsed if elapsed > 0 else 0
103
+ logger.info(
104
+ "Written %s to %s (%s/s)",
105
+ fmt_size(downloaded), args.dest, fmt_size(speed),
106
+ )
107
+ return None