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
contree_cli/__init__.py
ADDED
|
@@ -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]]
|
contree_cli/__main__.py
ADDED
|
@@ -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()
|
contree_cli/arguments.py
ADDED
|
@@ -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
|
contree_cli/cli/auth.py
ADDED
|
@@ -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
|