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,282 @@
1
+ """Manage session branches and history.
2
+
3
+ Without a subcommand, shows the current session info (key, branch,
4
+ image, last operation).
5
+
6
+ Subcommands:
7
+ list (ls) List all sessions
8
+ use KEY Import another session's current image
9
+ branch (br) List or create branches (--from to fork)
10
+ checkout (co) Switch active branch
11
+ rollback (rb) Roll back N steps in history
12
+ show Display the session history DAG
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import sys
18
+ from dataclasses import dataclass
19
+
20
+ from contree_cli import FORMATTER, SESSION_STORE, ArgumentsProtocol, SetupResult
21
+ from contree_cli.output import DefaultFormatter
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class SessionInfoArgs(ArgumentsProtocol):
26
+ @classmethod
27
+ def from_args(cls, ns: argparse.Namespace) -> SessionInfoArgs:
28
+ return cls()
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class ListArgs(ArgumentsProtocol):
33
+ @classmethod
34
+ def from_args(cls, ns: argparse.Namespace) -> ListArgs:
35
+ return cls()
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class UseSessionArgs(ArgumentsProtocol):
40
+ name: str
41
+
42
+ @classmethod
43
+ def from_args(cls, ns: argparse.Namespace) -> UseSessionArgs:
44
+ return cls(name=ns.session_name)
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class BranchArgs(ArgumentsProtocol):
49
+ name: str | None
50
+ from_branch: str | None
51
+
52
+ @classmethod
53
+ def from_args(cls, ns: argparse.Namespace) -> BranchArgs:
54
+ return cls(
55
+ name=getattr(ns, "branch_name", None),
56
+ from_branch=getattr(ns, "from_branch", None),
57
+ )
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class RollbackArgs(ArgumentsProtocol):
62
+ n: int
63
+
64
+ @classmethod
65
+ def from_args(cls, ns: argparse.Namespace) -> RollbackArgs:
66
+ return cls(n=ns.n)
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class CheckoutArgs(ArgumentsProtocol):
71
+ name: str
72
+
73
+ @classmethod
74
+ def from_args(cls, ns: argparse.Namespace) -> CheckoutArgs:
75
+ return cls(name=ns.checkout_branch)
76
+
77
+
78
+ @dataclass(frozen=True)
79
+ class ShowArgs(ArgumentsProtocol):
80
+ @classmethod
81
+ def from_args(cls, ns: argparse.Namespace) -> ShowArgs:
82
+ return cls()
83
+
84
+
85
+ def setup_parser(p: argparse.ArgumentParser) -> SetupResult:
86
+ sub = p.add_subparsers(dest="session_action")
87
+
88
+ # session list
89
+ list_p = sub.add_parser(
90
+ "list", aliases=["ls"],
91
+ help="List all sessions",
92
+ )
93
+ list_p.set_defaults(handler=cmd_list, load_args=ListArgs)
94
+
95
+ # session use SESSION
96
+ use_p = sub.add_parser("use", help="Import another session's image")
97
+ use_p.add_argument("session_name", help="Session key or suffix to match")
98
+ use_p.set_defaults(handler=cmd_use_session, load_args=UseSessionArgs)
99
+
100
+ # session branch [NAME] [--from BRANCH]
101
+ branch_p = sub.add_parser(
102
+ "branch", aliases=["br"],
103
+ help="List or create branches",
104
+ )
105
+ branch_p.add_argument(
106
+ "branch_name", nargs="?", default=None,
107
+ help="New branch name",
108
+ )
109
+ branch_p.add_argument(
110
+ "--from", dest="from_branch", default=None,
111
+ help="Source branch (default: active branch)",
112
+ )
113
+ branch_p.set_defaults(handler=cmd_branch, load_args=BranchArgs)
114
+
115
+ # session checkout BRANCH
116
+ co_p = sub.add_parser(
117
+ "checkout", aliases=["co"],
118
+ help="Switch active branch",
119
+ )
120
+ co_p.add_argument("checkout_branch", help="Branch to switch to")
121
+ co_p.set_defaults(
122
+ handler=cmd_checkout, load_args=CheckoutArgs,
123
+ )
124
+
125
+ # session rollback [N]
126
+ rollback_p = sub.add_parser(
127
+ "rollback", aliases=["rb"],
128
+ help="Roll back N steps",
129
+ )
130
+ rollback_p.add_argument(
131
+ "n", nargs="?", type=int, default=1,
132
+ help="Steps to rollback",
133
+ )
134
+ rollback_p.set_defaults(handler=cmd_rollback, load_args=RollbackArgs)
135
+
136
+ # session show
137
+ show_p = sub.add_parser("show", help="Show session history")
138
+ show_p.set_defaults(handler=cmd_show, load_args=ShowArgs)
139
+
140
+ return cmd_session_info, SessionInfoArgs
141
+
142
+
143
+ def cmd_session_info(_: SessionInfoArgs) -> int | None:
144
+ store = SESSION_STORE.get()
145
+ session = store.session
146
+ if session is None:
147
+ print(
148
+ "No active session. Run `contree use IMAGE` to start one.",
149
+ file=sys.stderr,
150
+ )
151
+ return 1
152
+ formatter = FORMATTER.get()
153
+ formatter(
154
+ session_key=session.session_key,
155
+ active_branch=session.active_branch,
156
+ current_image=session.current_image,
157
+ last_kind=session.last_kind,
158
+ last_title=session.last_title,
159
+ updated_at=session.updated_at,
160
+ )
161
+ return None
162
+
163
+
164
+ def cmd_list(_: ListArgs) -> int | None:
165
+ store = SESSION_STORE.get()
166
+ sessions = store.list_sessions()
167
+ if not sessions:
168
+ print("No sessions found.", file=sys.stderr)
169
+ return None
170
+ formatter = FORMATTER.get()
171
+ current_key = store.session_key
172
+ for s in sessions:
173
+ active = s.session_key == current_key
174
+ formatter(
175
+ active=active,
176
+ session_key=s.session_key,
177
+ active_branch=s.active_branch,
178
+ current_image=s.current_image,
179
+ updated_at=s.updated_at,
180
+ )
181
+ return None
182
+
183
+
184
+ def cmd_use_session(args: UseSessionArgs) -> int | None:
185
+ store = SESSION_STORE.get()
186
+ try:
187
+ target = store.find_session(args.name)
188
+ except ValueError as exc:
189
+ print(str(exc), file=sys.stderr)
190
+ return 1
191
+ store.set_image(
192
+ target.current_image,
193
+ kind="session use",
194
+ title=target.session_key,
195
+ )
196
+ formatter = FORMATTER.get()
197
+ formatter(
198
+ image=target.current_image,
199
+ from_session=target.session_key,
200
+ )
201
+ return None
202
+
203
+
204
+ def cmd_branch(args: BranchArgs) -> int | None:
205
+ store = SESSION_STORE.get()
206
+ if args.name is None:
207
+ # List branches
208
+ branches = store.list_branches()
209
+ if not branches:
210
+ print("No branches (no active session).", file=sys.stderr)
211
+ return None
212
+ formatter = FORMATTER.get()
213
+ for name, is_active in branches:
214
+ if isinstance(formatter, DefaultFormatter):
215
+ marker = "* " if is_active else " "
216
+ formatter(marker=marker, branch=name)
217
+ else:
218
+ formatter(branch=name, active=is_active)
219
+ return None
220
+
221
+ # Create branch
222
+ try:
223
+ store.create_branch(args.name, args.from_branch)
224
+ except ValueError as exc:
225
+ print(str(exc), file=sys.stderr)
226
+ return 1
227
+ print(f"Created branch {args.name!r}")
228
+ return None
229
+
230
+
231
+ def cmd_checkout(args: CheckoutArgs) -> int | None:
232
+ store = SESSION_STORE.get()
233
+ try:
234
+ entry = store.switch_branch(args.name)
235
+ except ValueError as exc:
236
+ print(str(exc), file=sys.stderr)
237
+ return 1
238
+ formatter = FORMATTER.get()
239
+ formatter(
240
+ branch=args.name,
241
+ image=entry.image_uuid,
242
+ )
243
+ return None
244
+
245
+
246
+ def cmd_rollback(args: RollbackArgs) -> int | None:
247
+ store = SESSION_STORE.get()
248
+ try:
249
+ entry = store.rollback(args.n)
250
+ except ValueError as exc:
251
+ print(str(exc), file=sys.stderr)
252
+ return 1
253
+ formatter = FORMATTER.get()
254
+ formatter(
255
+ image=entry.image_uuid,
256
+ kind=entry.kind,
257
+ title=entry.title,
258
+ created_at=entry.created_at,
259
+ )
260
+ return None
261
+
262
+
263
+ def cmd_show(_: ShowArgs) -> int | None:
264
+ store = SESSION_STORE.get()
265
+ entries, branch_map = store.history_dag()
266
+ if not entries:
267
+ print("No history.", file=sys.stderr)
268
+ return None
269
+
270
+ formatter = FORMATTER.get()
271
+ for entry in entries:
272
+ branches = branch_map.get(entry.id, [])
273
+ formatter(
274
+ id=entry.id,
275
+ image=entry.image_uuid,
276
+ parent_id=entry.parent_id,
277
+ kind=entry.kind,
278
+ title=entry.title,
279
+ created_at=entry.created_at,
280
+ branches=", ".join(branches) if branches else "",
281
+ )
282
+ return None
@@ -0,0 +1,97 @@
1
+ """Show the result of an operation.
2
+
3
+ Fetches the operation by UUID and displays its status, duration, exit
4
+ code, result image, and captured stdout/stderr. Terminal operations
5
+ (SUCCESS, FAILED, CANCELLED) are cached locally to avoid redundant API
6
+ calls.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import json
12
+ import logging
13
+ import sys
14
+ from dataclasses import dataclass
15
+ from datetime import timedelta
16
+ from typing import Any, cast
17
+
18
+ from contree_cli import CLIENT, FORMATTER, SESSION_STORE, ArgumentsProtocol, SetupResult
19
+ from contree_cli.client import decode_stream
20
+ from contree_cli.output import DefaultFormatter, JSONFormatter, JSONPrettyFormatter
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class ShowArgs(ArgumentsProtocol):
27
+ uuid: str
28
+
29
+ @classmethod
30
+ def from_args(cls, ns: argparse.Namespace) -> ShowArgs:
31
+ return cls(uuid=ns.uuid)
32
+
33
+
34
+ def setup_parser(p: argparse.ArgumentParser) -> SetupResult:
35
+ p.add_argument("uuid", help="Operation UUID")
36
+ return cmd_show, ShowArgs
37
+
38
+
39
+ _TERMINAL = frozenset({"SUCCESS", "FAILED", "CANCELLED"})
40
+
41
+
42
+ def cmd_show(args: ShowArgs) -> None:
43
+ client = CLIENT.get()
44
+ formatter = FORMATTER.get()
45
+ store = SESSION_STORE.get()
46
+
47
+ cache_key = (args.uuid, "operation")
48
+ cached = store.cache.get(cache_key)
49
+ if cached is not None:
50
+ op = cast(dict[str, Any], cached)
51
+ else:
52
+ resp = client.get(f"/v1/operations/{args.uuid}")
53
+ op = json.loads(resp.read())
54
+ if op.get("status") in _TERMINAL:
55
+ store.cache[cache_key] = op
56
+
57
+ duration = (
58
+ timedelta(seconds=op["duration"])
59
+ if op.get("duration") is not None
60
+ else None
61
+ )
62
+ result = op.get("result") or {}
63
+ metadata = op.get("metadata") or {}
64
+ instance_result = metadata.get("result") or {}
65
+
66
+ exit_code = None
67
+ state = instance_result.get("state") or {}
68
+ if state:
69
+ exit_code = state.get("exit_code")
70
+
71
+ formatter(
72
+ uuid=op["uuid"],
73
+ kind=op["kind"],
74
+ status=op["status"],
75
+ duration=duration,
76
+ exit_code=exit_code,
77
+ error=op.get("error") or "",
78
+ image=result.get("image") or "",
79
+ tag=result.get("tag") or "",
80
+ )
81
+ formatter.flush()
82
+
83
+ _STREAM_FMTS = (DefaultFormatter, JSONFormatter, JSONPrettyFormatter)
84
+ if not isinstance(formatter, _STREAM_FMTS):
85
+ return
86
+
87
+ stdout = decode_stream(instance_result.get("stdout"))
88
+ stderr = decode_stream(instance_result.get("stderr"))
89
+
90
+ if stdout:
91
+ sys.stdout.write(stdout)
92
+ if not stdout.endswith("\n"):
93
+ sys.stdout.write("\n")
94
+ if stderr:
95
+ sys.stderr.write(stderr)
96
+ if not stderr.endswith("\n"):
97
+ sys.stderr.write("\n")
contree_cli/cli/tag.py ADDED
@@ -0,0 +1,50 @@
1
+ """Assign or remove a tag from an image.
2
+
3
+ Tags provide human-readable names for image UUIDs, making them easier
4
+ to reference in commands like `contree use tag:NAME`.
5
+
6
+ Use -d/--delete to remove a tag instead of assigning one.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import logging
12
+ from dataclasses import dataclass
13
+
14
+ from contree_cli import CLIENT, ArgumentsProtocol, SetupResult
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class TagArgs(ArgumentsProtocol):
21
+ uuid: str
22
+ tag: str | None = None
23
+ delete: bool = False
24
+
25
+ @classmethod
26
+ def from_args(cls, ns: argparse.Namespace) -> TagArgs:
27
+ return cls(uuid=ns.uuid, tag=ns.tag, delete=ns.delete)
28
+
29
+
30
+ def setup_parser(p: argparse.ArgumentParser) -> SetupResult:
31
+ p.add_argument("uuid", help="Image UUID")
32
+ p.add_argument("tag", help="Tag to assign")
33
+ p.add_argument(
34
+ "-d", "--delete", action="store_true",
35
+ help="Remove tag from image",
36
+ )
37
+ return cmd_tag, TagArgs
38
+
39
+
40
+ def cmd_tag(args: TagArgs) -> int | None:
41
+ client = CLIENT.get()
42
+
43
+ if args.delete:
44
+ client.delete(f"/v1/images/{args.uuid}/tag?tag={args.tag}")
45
+ logger.info("Removed tag from image %s", args.uuid)
46
+ return None
47
+
48
+ client.patch_json(f"/v1/images/{args.uuid}/tag", {"tag": args.tag})
49
+ logger.info("Tagged image %s as %s", args.uuid, args.tag)
50
+ return None
contree_cli/cli/use.py ADDED
@@ -0,0 +1,119 @@
1
+ """Set or show the current session image.
2
+
3
+ With an IMAGE argument, resolves it (UUID or tag:NAME) and sets it as
4
+ the active session image. Prints a shell export line so that the
5
+ session key can be captured with eval:
6
+
7
+ eval $(contree use tag:ubuntu:latest)
8
+
9
+ Without arguments, displays the current session info (image, branch,
10
+ last operation).
11
+
12
+ Use -N/--new to start a fresh session instead of resuming the current
13
+ one. The new session key is printed as an export line.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import logging
19
+ import os
20
+ import sys
21
+ import uuid
22
+ from dataclasses import dataclass
23
+ from pathlib import PurePosixPath
24
+
25
+ from contree_cli import (
26
+ CLIENT,
27
+ FORMATTER,
28
+ IN_SHELL,
29
+ SESSION_STORE,
30
+ ArgumentsProtocol,
31
+ SetupResult,
32
+ )
33
+ from contree_cli.client import resolve_image
34
+ from contree_cli.session import SessionStore, get_db_path
35
+
36
+ log = logging.getLogger(__name__)
37
+
38
+
39
+ def _shell_name() -> str:
40
+ return PurePosixPath(os.getenv("SHELL", "/bin/sh")).name
41
+
42
+
43
+ def _print_shell_export(key: str, value: str) -> None:
44
+ if _shell_name() == "fish":
45
+ sys.stdout.write(f"set -gx {key} {value}\n")
46
+ else:
47
+ sys.stdout.write(f"export {key}={value}\n")
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class UseArgs(ArgumentsProtocol):
52
+ image: str | None
53
+ new: bool
54
+
55
+ @classmethod
56
+ def from_args(cls, ns: argparse.Namespace) -> UseArgs:
57
+ return cls(image=ns.image, new=ns.new)
58
+
59
+
60
+ def setup_parser(p: argparse.ArgumentParser) -> SetupResult:
61
+ p.add_argument("image", nargs="?", default=None, help="Image UUID or tag")
62
+ p.add_argument(
63
+ "--new", "-N", action="store_true", default=False,
64
+ help="Start a new session instead of resuming the current one",
65
+ )
66
+ return cmd_use, UseArgs
67
+
68
+
69
+ def cmd_use(args: UseArgs) -> int | None:
70
+ store = SESSION_STORE.get()
71
+
72
+ if args.new:
73
+ if args.image is None:
74
+ print(
75
+ "--new requires an IMAGE argument.",
76
+ file=sys.stderr,
77
+ )
78
+ return 1
79
+ new_key = str(uuid.uuid4())
80
+ store = SessionStore(get_db_path(), new_key)
81
+ SESSION_STORE.set(store)
82
+
83
+ if args.image is not None:
84
+ client = CLIENT.get()
85
+ image_uuid = resolve_image(client, args.image)
86
+ store.set_image(image_uuid, kind="use", title=args.image)
87
+ if not IN_SHELL.get(False):
88
+ _print_shell_export("CONTREE_SESSION", store.session_key)
89
+ if args.new and sys.stdout.isatty():
90
+ if _shell_name() == "fish":
91
+ eval_hint = "eval (contree use -N IMAGE)"
92
+ else:
93
+ eval_hint = "eval $(contree use -N IMAGE)"
94
+ log.warning(
95
+ "Session is not active until exported. "
96
+ "Either paste the line above, or use: %s",
97
+ eval_hint,
98
+ )
99
+ return None
100
+
101
+ session = store.session
102
+ if session is None:
103
+ print(
104
+ "No active session. Run `contree use IMAGE` to start one.",
105
+ file=sys.stderr,
106
+ )
107
+ return 1
108
+
109
+ formatter = FORMATTER.get()
110
+
111
+ formatter(
112
+ session_key=session.session_key,
113
+ active_branch=session.active_branch,
114
+ current_image=session.current_image,
115
+ last_kind=session.last_kind,
116
+ last_title=session.last_title,
117
+ updated_at=session.updated_at,
118
+ )
119
+ return None