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,179 @@
1
+ """Manage files in the session image.
2
+
3
+ Subcommands:
4
+ edit (e) Download a file from the session image, open it in $EDITOR
5
+ (or vi), and upload the modified version as a pending file
6
+ attachment. The change takes effect on the next `run`.
7
+
8
+ cp Copy a local file into the session image as a pending file
9
+ attachment. The file is uploaded immediately but injected
10
+ into the container on the next `run`.
11
+
12
+ Pending files are branch-aware — switching branches changes which
13
+ files are visible.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import hashlib
19
+ import json
20
+ import logging
21
+ import os
22
+ import shlex
23
+ import subprocess
24
+ import tempfile
25
+ from dataclasses import dataclass
26
+ from pathlib import Path
27
+
28
+ from contree_cli import CLIENT, SESSION_STORE, ArgumentsProtocol, SetupResult
29
+ from contree_cli.client import ApiError, ContreeClient, resolve_image, stream_response
30
+ from contree_cli.session import SessionStore
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class FileEditArgs(ArgumentsProtocol):
37
+ path: str
38
+ editor: str | None = None
39
+
40
+ @classmethod
41
+ def from_args(cls, ns: argparse.Namespace) -> FileEditArgs:
42
+ return cls(path=ns.path, editor=getattr(ns, "editor", None))
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class FileCpArgs(ArgumentsProtocol):
47
+ src: str
48
+ dest: str
49
+
50
+ @classmethod
51
+ def from_args(cls, ns: argparse.Namespace) -> FileCpArgs:
52
+ return cls(src=ns.src, dest=ns.dest)
53
+
54
+
55
+ def setup_parser(p: argparse.ArgumentParser) -> SetupResult:
56
+ sub = p.add_subparsers(dest="file_action", required=True)
57
+ edit_p = sub.add_parser(
58
+ "edit", aliases=["e"],
59
+ help="Edit a file in the session image",
60
+ )
61
+ edit_p.add_argument(
62
+ "-e", "--editor", help="Editor command (default: $EDITOR or vi)",
63
+ )
64
+ edit_p.add_argument("path", help="Path inside image")
65
+ edit_p.set_defaults(handler=cmd_file_edit, load_args=FileEditArgs)
66
+
67
+ cp_p = sub.add_parser(
68
+ "cp", help="Copy a local file into the session image",
69
+ )
70
+ cp_p.add_argument("src", help="Local file path")
71
+ cp_p.add_argument("dest", help="Destination path inside image")
72
+ cp_p.set_defaults(handler=cmd_file_cp, load_args=FileCpArgs)
73
+
74
+ return cmd_file_edit, FileEditArgs
75
+
76
+
77
+ def _file_sha256(path: Path) -> str:
78
+ h = hashlib.sha256()
79
+ with path.open("rb") as f:
80
+ while chunk := f.read(256 * 1024):
81
+ h.update(chunk)
82
+ return h.hexdigest()
83
+
84
+
85
+ def _upload_and_record(
86
+ client: ContreeClient,
87
+ store: SessionStore,
88
+ local_path: Path,
89
+ instance_path: str,
90
+ title: str,
91
+ ) -> str:
92
+ """Upload a local file (with dedup) and record as pending."""
93
+ sha = _file_sha256(local_path)
94
+ try:
95
+ resp = client.get("/v1/files", params={"sha256": sha})
96
+ file_uuid = json.loads(resp.read())["uuid"]
97
+ logger.info("File already exists on server (%s)", file_uuid)
98
+ except ApiError as exc:
99
+ if exc.status != 404:
100
+ raise
101
+ data = local_path.read_bytes()
102
+ resp = client.request(
103
+ "POST", "/v1/files",
104
+ body=data,
105
+ headers={"Content-Type": "application/octet-stream"},
106
+ )
107
+ file_uuid = json.loads(resp.read())["uuid"]
108
+ logger.info("Uploaded %s (%s)", instance_path, file_uuid)
109
+
110
+ history_id = store.set_image(
111
+ store.current_image, kind="file", title=title,
112
+ )
113
+ store.add_pending_file(history_id, instance_path, str(file_uuid))
114
+ logger.info(
115
+ "Pending: %s -> %s (will be included in next run)",
116
+ instance_path, file_uuid,
117
+ )
118
+ return str(file_uuid)
119
+
120
+
121
+ def cmd_file_edit(args: FileEditArgs) -> int | None:
122
+ client = CLIENT.get()
123
+ store = SESSION_STORE.get()
124
+ image_uuid = resolve_image(client, store.current_image)
125
+
126
+ # 1. Download to temp file (or create empty)
127
+ tmp_dir = Path(tempfile.mkdtemp(prefix="contree-"))
128
+ filename = Path(args.path).name or "file"
129
+ tmp_file = tmp_dir / filename
130
+ try:
131
+ resp = client.get(
132
+ f"/v1/inspect/{image_uuid}/download",
133
+ params={"path": args.path},
134
+ )
135
+ with tmp_file.open("wb") as f:
136
+ for chunk in stream_response(resp):
137
+ f.write(chunk)
138
+ logger.info("Downloaded %s to %s", args.path, tmp_file)
139
+ except ApiError as exc:
140
+ if exc.status != 404:
141
+ raise
142
+ tmp_file.write_bytes(b"")
143
+ logger.info("File %s not found, creating empty file", args.path)
144
+
145
+ # 2. Record original hash, open editor
146
+ original_hash = _file_sha256(tmp_file)
147
+ editor = args.editor or os.environ.get("EDITOR", "vi")
148
+ logger.info("Opening %s in %s", tmp_file, editor)
149
+ rc = subprocess.call(f"{editor} {shlex.quote(str(tmp_file))}", shell=True)
150
+ if rc != 0:
151
+ logger.error("Editor exited with code %d", rc)
152
+ return 1
153
+
154
+ # 3. Check for changes
155
+ new_hash = _file_sha256(tmp_file)
156
+ if new_hash == original_hash:
157
+ logger.info("No changes detected, skipping upload")
158
+ return None
159
+
160
+ # 4+5. Upload (with dedup) and record pending file
161
+ _upload_and_record(
162
+ client, store, tmp_file, args.path,
163
+ title=f"Change file {args.path}",
164
+ )
165
+ return None
166
+
167
+
168
+ def cmd_file_cp(args: FileCpArgs) -> int | None:
169
+ client = CLIENT.get()
170
+ store = SESSION_STORE.get()
171
+ local_path = Path(args.src)
172
+ if not local_path.is_file():
173
+ logger.error("File not found: %s", args.src)
174
+ return 1
175
+ _upload_and_record(
176
+ client, store, local_path, args.dest,
177
+ title=f"Change file {args.dest}",
178
+ )
179
+ return None
@@ -0,0 +1,88 @@
1
+ """List container images.
2
+
3
+ Queries the Contree API for available images and displays them with
4
+ their UUID, tag, and creation time. Supports filtering by tag prefix,
5
+ UUID, date range, and tagged-only mode.
6
+
7
+ Results are paginated automatically. Use --format to control output
8
+ (default table, json, csv, etc.).
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import json
14
+ import logging
15
+ from dataclasses import dataclass
16
+ from contree_cli import CLIENT, FORMATTER, ArgumentsProtocol, SetupResult
17
+ from contree_cli.types import parse_datetime
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ PAGE_SIZE = 100
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class ImagesArgs(ArgumentsProtocol):
26
+ prefix: str | None = None
27
+ uuid: str | None = None
28
+ tagged: bool = False
29
+ since: str | None = None
30
+ until: str | None = None
31
+
32
+ @classmethod
33
+ def from_args(cls, ns: argparse.Namespace) -> ImagesArgs:
34
+ return cls(
35
+ prefix=ns.prefix,
36
+ uuid=ns.uuid,
37
+ tagged=ns.tagged,
38
+ since=ns.since,
39
+ until=ns.until,
40
+ )
41
+
42
+
43
+ def setup_parser(p: argparse.ArgumentParser) -> SetupResult:
44
+ p.add_argument("--prefix", help="Filter by tag prefix")
45
+ p.add_argument("--uuid", help="Filter by image UUID")
46
+ p.add_argument(
47
+ "--tagged", action="store_true",
48
+ help="Only show tagged images",
49
+ )
50
+ p.add_argument("--since", help="Show images after date/time or interval")
51
+ p.add_argument("--until", help="Show images before date/time or interval")
52
+ return cmd_images, ImagesArgs
53
+
54
+
55
+ def cmd_images(args: ImagesArgs) -> None:
56
+ client = CLIENT.get()
57
+ formatter = FORMATTER.get()
58
+
59
+ base_params: dict[str, str] = {"limit": str(PAGE_SIZE)}
60
+ if args.prefix is not None:
61
+ base_params["tag"] = args.prefix
62
+ if args.uuid is not None:
63
+ base_params["uuid"] = args.uuid
64
+ if args.tagged:
65
+ base_params["tagged"] = "1"
66
+ if args.since is not None:
67
+ base_params["since"] = args.since
68
+ if args.until is not None:
69
+ base_params["until"] = args.until
70
+
71
+ offset = 0
72
+ while True:
73
+ params = {**base_params, "offset": str(offset)}
74
+ resp = client.get("/v1/images", params=params)
75
+ data = json.loads(resp.read())
76
+ images = data["images"]
77
+ if not images:
78
+ break
79
+ for image in images:
80
+ created_at = parse_datetime(image["created_at"])
81
+ formatter(
82
+ uuid=image["uuid"],
83
+ created_at=created_at,
84
+ tag=image.get("tag") or "",
85
+ )
86
+ if len(images) < PAGE_SIZE:
87
+ break
88
+ offset += len(images)
@@ -0,0 +1,86 @@
1
+ """Cancel a running operation.
2
+
3
+ Sends a DELETE request to stop the specified operation. Use --all to
4
+ cancel every active operation (PENDING, ASSIGNED, EXECUTING) in one go.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import json
10
+ import logging
11
+ from dataclasses import dataclass
12
+
13
+ from contree_cli import CLIENT, ArgumentsProtocol, SetupResult
14
+ from contree_cli.client import ApiError
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ PAGE_SIZE = 100
19
+ ACTIVE_STATUSES = ("PENDING", "ASSIGNED", "EXECUTING")
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class KillArgs(ArgumentsProtocol):
24
+ uuid: str | None = None
25
+ all: bool = False
26
+
27
+ @classmethod
28
+ def from_args(cls, ns: argparse.Namespace) -> KillArgs:
29
+ return cls(uuid=ns.uuid, all=ns.all)
30
+
31
+
32
+ def setup_parser(p: argparse.ArgumentParser) -> SetupResult:
33
+ target = p.add_mutually_exclusive_group(required=True)
34
+ target.add_argument("uuid", nargs="?", help="Operation UUID")
35
+ target.add_argument(
36
+ "--all", action="store_true",
37
+ help="Cancel all active operations",
38
+ )
39
+ return cmd_kill, KillArgs
40
+
41
+
42
+ def _list_active(client: object) -> list[str]:
43
+ """Collect UUIDs of all active operations."""
44
+ from contree_cli.client import ContreeClient
45
+ assert isinstance(client, ContreeClient)
46
+ uuids: list[str] = []
47
+ for status in ACTIVE_STATUSES:
48
+ offset = 0
49
+ while True:
50
+ params = {
51
+ "status": status,
52
+ "limit": str(PAGE_SIZE),
53
+ "offset": str(offset),
54
+ }
55
+ resp = client.get("/v1/operations", params=params)
56
+ operations = json.loads(resp.read())
57
+ if not operations:
58
+ break
59
+ uuids.extend(op["uuid"] for op in operations)
60
+ if len(operations) < PAGE_SIZE:
61
+ break
62
+ offset += len(operations)
63
+ return uuids
64
+
65
+
66
+ def cmd_kill(args: KillArgs) -> int | None:
67
+ client = CLIENT.get()
68
+
69
+ if args.all:
70
+ uuids = _list_active(client)
71
+ if not uuids:
72
+ logger.info("No active operations to cancel")
73
+ return None
74
+ failed = 0
75
+ for uuid in uuids:
76
+ try:
77
+ client.delete(f"/v1/operations/{uuid}")
78
+ logger.info("Cancelled operation %s", uuid)
79
+ except ApiError as exc:
80
+ logger.error("Failed to cancel %s: %s", uuid, exc)
81
+ failed += 1
82
+ return 1 if failed else None
83
+
84
+ client.delete(f"/v1/operations/{args.uuid}")
85
+ logger.info("Cancelled operation %s", args.uuid)
86
+ return None
contree_cli/cli/ls.py ADDED
@@ -0,0 +1,83 @@
1
+ """List files in the session image.
2
+
3
+ Uses the /inspect/ API to list directory contents without spawning an
4
+ instance. Defaults to the session working directory (set via `cd`).
5
+
6
+ In default format, the API returns a pre-formatted text listing. In
7
+ structured formats (json, csv, etc.) the response is cached per
8
+ (image, path) for instant repeat queries.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import json
14
+ import sys
15
+ from dataclasses import dataclass
16
+ from datetime import datetime, timezone
17
+ from typing import Any, 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
+
24
+ def setup_parser(p: argparse.ArgumentParser) -> SetupResult:
25
+ p.add_argument(
26
+ "path", nargs="?", default=None,
27
+ help="Path inside image (defaults to session cwd)",
28
+ )
29
+ return cmd_ls, LsArgs
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class LsArgs(ArgumentsProtocol):
34
+ path: str | None
35
+
36
+ @classmethod
37
+ def from_args(cls, ns: argparse.Namespace) -> LsArgs:
38
+ return cls(path=ns.path)
39
+
40
+
41
+ def cmd_ls(args: LsArgs) -> None:
42
+ client = CLIENT.get()
43
+ formatter = FORMATTER.get()
44
+ store = SESSION_STORE.get()
45
+ image = store.current_image
46
+ uuid = resolve_image(client, image)
47
+ if args.path is not None:
48
+ path = store.resolve_path(args.path)
49
+ else:
50
+ path = store.get_cwd() or "/"
51
+
52
+ if isinstance(formatter, DefaultFormatter):
53
+ resp = client.get(
54
+ f"/v1/inspect/{uuid}/list",
55
+ params={"path": path, "text": "1"},
56
+ )
57
+ sys.stdout.write(resp.read().decode())
58
+ return
59
+
60
+ cache_key = (uuid, f"list:{path}")
61
+ cached = store.cache.get(cache_key)
62
+ if cached is not None:
63
+ data = cast(dict[str, Any], cached)
64
+ else:
65
+ resp = client.get(f"/v1/inspect/{uuid}/list", params={"path": path})
66
+ data = json.loads(resp.read())
67
+ store.cache[cache_key] = data
68
+ for f in data["files"]:
69
+ if f.get("is_dir"):
70
+ ftype = "d"
71
+ elif f.get("is_symlink"):
72
+ ftype = "l"
73
+ else:
74
+ ftype = "-"
75
+ formatter(
76
+ path=f["path"],
77
+ size=f["size"],
78
+ mode=format(f["mode"], "o"),
79
+ owner=f.get("owner", f.get("uid", "")),
80
+ group=f.get("group", f.get("gid", "")),
81
+ mtime=datetime.fromtimestamp(f["mtime"], tz=timezone.utc),
82
+ type=ftype,
83
+ )
contree_cli/cli/ps.py ADDED
@@ -0,0 +1,120 @@
1
+ """List operations (running and completed instances, image imports).
2
+
3
+ By default shows only active operations (PENDING, ASSIGNED, EXECUTING).
4
+ Use -a/--all to include completed ones, or -S/--status to filter by a
5
+ specific status. Use -K/--kind to filter by operation type.
6
+
7
+ Use -q/--quiet to print only UUIDs, useful for scripting.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import json
13
+ import logging
14
+ from dataclasses import dataclass
15
+ from datetime import timedelta
16
+
17
+ from contree_cli import CLIENT, FORMATTER, ArgumentsProtocol, SetupResult
18
+ from contree_cli.types import parse_datetime
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ PAGE_SIZE = 100
23
+ ACTIVE_STATUSES = frozenset({"PENDING", "ASSIGNED", "EXECUTING"})
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class PsArgs(ArgumentsProtocol):
28
+ quiet: bool = False
29
+ all: bool = False
30
+ status: str | None = None
31
+ kind: str | None = None
32
+ since: str | None = None
33
+ until: str | None = None
34
+
35
+ @classmethod
36
+ def from_args(cls, ns: argparse.Namespace) -> PsArgs:
37
+ return cls(
38
+ quiet=ns.quiet,
39
+ all=getattr(ns, "all", False),
40
+ status=ns.status,
41
+ kind=ns.kind,
42
+ since=ns.since,
43
+ until=ns.until,
44
+ )
45
+
46
+
47
+ def setup_parser(p: argparse.ArgumentParser) -> SetupResult:
48
+ p.add_argument(
49
+ "-q", "--quiet", action="store_true",
50
+ help="Only show UUIDs, useful for scripting",
51
+ )
52
+ p.add_argument(
53
+ "-a", "--all", action="store_true",
54
+ help="Show all operations (default: active only)",
55
+ )
56
+ p.add_argument(
57
+ "-S", "--status",
58
+ choices=(
59
+ "PENDING", "ASSIGNED", "EXECUTING",
60
+ "SUCCESS", "FAILED", "CANCELLED",
61
+ ),
62
+ help="Filter by status",
63
+ )
64
+ p.add_argument(
65
+ "-K", "--kind",
66
+ choices=("image_import", "instance"),
67
+ help="Filter by operation kind",
68
+ )
69
+ p.add_argument("--since", help="Show operations after date/time or interval")
70
+ p.add_argument("--until", help="Show operations before date/time or interval")
71
+ return cmd_ps, PsArgs
72
+
73
+
74
+ def cmd_ps(args: PsArgs) -> None:
75
+ client = CLIENT.get()
76
+ formatter = FORMATTER.get()
77
+
78
+ # When neither --all nor --status is given, show only active operations.
79
+ filter_active = not args.all and args.status is None
80
+
81
+ base_params: dict[str, str] = {"limit": str(PAGE_SIZE)}
82
+ if args.status is not None:
83
+ base_params["status"] = args.status
84
+ if args.kind is not None:
85
+ base_params["kind"] = args.kind
86
+ if args.since is not None:
87
+ base_params["since"] = args.since
88
+ if args.until is not None:
89
+ base_params["until"] = args.until
90
+
91
+ offset = 0
92
+ while True:
93
+ params = {**base_params, "offset": str(offset)}
94
+ resp = client.get("/v1/operations", params=params)
95
+ operations = json.loads(resp.read())
96
+ if not operations:
97
+ break
98
+ for op in operations:
99
+ if filter_active and op["status"] not in ACTIVE_STATUSES:
100
+ continue
101
+ if args.quiet:
102
+ print(op["uuid"])
103
+ else:
104
+ created_at = parse_datetime(op["created_at"])
105
+ duration = (
106
+ timedelta(seconds=op["duration"])
107
+ if op.get("duration") is not None
108
+ else None
109
+ )
110
+ formatter(
111
+ uuid=op["uuid"],
112
+ status=op["status"],
113
+ kind=op["kind"],
114
+ created_at=created_at,
115
+ duration=duration,
116
+ error=op.get("error") or "",
117
+ )
118
+ if len(operations) < PAGE_SIZE:
119
+ break
120
+ offset += len(operations)