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/client.py ADDED
@@ -0,0 +1,222 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import http.client
5
+ import json
6
+ import logging
7
+ import platform
8
+ import sys
9
+ import time
10
+ from collections.abc import Iterator
11
+ from importlib.metadata import PackageNotFoundError, version
12
+ from urllib.parse import urlencode, urlsplit
13
+
14
+ log = logging.getLogger(__name__)
15
+
16
+ RETRY_DELAYS = (1, 2, 4, 5, 10, 10, 10)
17
+
18
+
19
+ def _cli_version() -> str:
20
+ try:
21
+ return version("contree-cli")
22
+ except PackageNotFoundError:
23
+ return "editable"
24
+
25
+
26
+ CLI_USER_AGENT = (
27
+ f"contree-cli/{_cli_version()} "
28
+ f"Python/{'.'.join(map(str, sys.version_info))} "
29
+ f"{platform.platform()}"
30
+ )
31
+
32
+
33
+
34
+ class ApiError(Exception):
35
+ """Raised when the Contree API returns a non-2xx status."""
36
+
37
+ def __init__(self, status: int, reason: str, body: str) -> None:
38
+ self.status = status
39
+ self.reason = reason
40
+ self.body = body
41
+
42
+ def __str__(self) -> str:
43
+ return f"API {self.status} {self.reason}: {self.body}"
44
+
45
+
46
+ class ContreeClient:
47
+ """Minimal HTTP client for the Contree REST API (stdlib only)."""
48
+
49
+ def __init__(self, url: str, token: str | None) -> None:
50
+ parts = urlsplit(url)
51
+ self._scheme = parts.scheme or "https"
52
+ self._host = parts.hostname or "localhost"
53
+ self._port = parts.port
54
+ self._prefix = (parts.path or "").rstrip("/")
55
+ self._token = token
56
+
57
+ def _connect(self) -> http.client.HTTPConnection:
58
+ if self._scheme == "https":
59
+ return http.client.HTTPSConnection(self._host, self._port)
60
+ return http.client.HTTPConnection(self._host, self._port)
61
+
62
+ def request(
63
+ self,
64
+ method: str,
65
+ path: str,
66
+ *,
67
+ body: bytes | None = None,
68
+ headers: dict[str, str] | None = None,
69
+ ) -> http.client.HTTPResponse:
70
+ if self._token is None:
71
+ raise ApiError(
72
+ 0, "Unauthorized",
73
+ "No token configured. Run `contree auth` first.",
74
+ )
75
+
76
+ merged: dict[str, str] = {
77
+ "Authorization": f"Bearer {self._token}",
78
+ "User-Agent": CLI_USER_AGENT,
79
+ }
80
+ if body is not None:
81
+ merged.setdefault("Content-Type", "application/json")
82
+ if headers:
83
+ merged.update(headers)
84
+
85
+ full_path = self._prefix + path
86
+ last_error: ApiError | None = None
87
+ attempts = len(RETRY_DELAYS) + 1
88
+
89
+ for attempt in range(attempts):
90
+ if last_error is not None:
91
+ delay = RETRY_DELAYS[attempt - 1]
92
+ log.warning(
93
+ "Server error %d, retrying in %ds…",
94
+ last_error.status, delay,
95
+ )
96
+ time.sleep(delay)
97
+
98
+ log.debug(
99
+ "%s %s body=%s",
100
+ method, full_path,
101
+ f"{len(body)}B" if body is not None else "none",
102
+ )
103
+ conn = self._connect()
104
+ conn.request(method, full_path, body, merged)
105
+ resp = conn.getresponse()
106
+
107
+ if 200 <= resp.status < 300:
108
+ log.debug(
109
+ "%s %s -> %d %s",
110
+ method, full_path, resp.status, resp.reason,
111
+ )
112
+ return resp
113
+
114
+ resp_body = resp.read().decode("utf-8", errors="replace")
115
+ log.debug(
116
+ "%s %s -> %d %s (%dB)",
117
+ method, full_path,
118
+ resp.status, resp.reason, len(resp_body),
119
+ )
120
+ error = ApiError(resp.status, resp.reason, resp_body)
121
+
122
+ if 500 <= resp.status < 600:
123
+ last_error = error
124
+ continue
125
+
126
+ raise error
127
+
128
+ assert last_error is not None
129
+ raise last_error
130
+
131
+ # -- convenience methods --------------------------------------------------
132
+
133
+ def get(
134
+ self, path: str, params: dict[str, str] | None = None,
135
+ ) -> http.client.HTTPResponse:
136
+ if params:
137
+ path = f"{path}?{urlencode(params)}"
138
+ return self.request("GET", path)
139
+
140
+ def post_json(
141
+ self, path: str, payload: dict[str, object],
142
+ ) -> http.client.HTTPResponse:
143
+ return self.request(
144
+ "POST", path,
145
+ body=json.dumps(payload).encode(),
146
+ headers={"Content-Type": "application/json"},
147
+ )
148
+
149
+ def patch_json(
150
+ self, path: str, payload: dict[str, object],
151
+ ) -> http.client.HTTPResponse:
152
+ return self.request(
153
+ "PATCH", path,
154
+ body=json.dumps(payload).encode(),
155
+ headers={"Content-Type": "application/json"},
156
+ )
157
+
158
+ def delete(self, path: str) -> http.client.HTTPResponse:
159
+ return self.request("DELETE", path)
160
+
161
+
162
+ _UUID_RE_PATTERN = (
163
+ r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
164
+ )
165
+
166
+
167
+ def _is_uuid(ref: str) -> bool:
168
+ """Return True if *ref* looks like a UUID."""
169
+ import re
170
+ return re.match(_UUID_RE_PATTERN, ref, re.ASCII) is not None
171
+
172
+
173
+ def _resolve_tag(client: ContreeClient, tag: str) -> str:
174
+ resp = client.get("/v1/images", params={"tag": tag})
175
+ data = json.loads(resp.read())
176
+ images = data.get("images", [])
177
+ if not images:
178
+ raise ApiError(404, "Not Found", f"No image with tag '{tag}'")
179
+ return str(images[0]["uuid"])
180
+
181
+
182
+ def resolve_image(client: ContreeClient, ref: str) -> str:
183
+ """Resolve an image reference to a UUID.
184
+
185
+ Accepts a raw UUID, ``tag:NAME``, or a bare tag name. For bare
186
+ references that are not valid UUIDs the function tries to resolve
187
+ them as tag names.
188
+ """
189
+ if ref.startswith("tag:"):
190
+ return _resolve_tag(client, ref[4:])
191
+ if _is_uuid(ref):
192
+ return ref
193
+ return _resolve_tag(client, ref)
194
+
195
+
196
+ CHUNK_SIZE = 256 * 1024 # 256 KiB
197
+
198
+
199
+ def stream_response(
200
+ resp: http.client.HTTPResponse,
201
+ ) -> Iterator[bytes]:
202
+ """Yield chunks from *resp*."""
203
+ while True:
204
+ chunk = resp.read(CHUNK_SIZE)
205
+ if not chunk:
206
+ break
207
+ yield chunk
208
+
209
+
210
+ def decode_stream(stream: dict[str, object] | None) -> str:
211
+ """Decode an API StreamRepr object to a string."""
212
+ if not stream:
213
+ return ""
214
+ value = stream.get("value", "")
215
+ if not isinstance(value, str) or not value:
216
+ return ""
217
+ encoding = stream.get("encoding", "ascii")
218
+ if encoding == "base64":
219
+ return base64.b64decode(value).decode(
220
+ "utf-8", errors="replace",
221
+ )
222
+ return value
contree_cli/config.py ADDED
@@ -0,0 +1,116 @@
1
+ import configparser
2
+ import os
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+
6
+ CONFIG_DIR = Path.home() / ".config" / "contree-cli"
7
+ CONFIG_FILE = CONFIG_DIR / "config.ini"
8
+
9
+ DEFAULT_URL = "https://contree.dev"
10
+ PROFILE_PREFIX = "profile:"
11
+
12
+
13
+ @dataclass(frozen=True, repr=False)
14
+ class ConfigProfile:
15
+ name: str
16
+ url: str
17
+ token: str | None
18
+
19
+ def __repr__(self) -> str:
20
+ masked = "***" if self.token else None
21
+ return (
22
+ f"ConfigProfile(name={self.name!r},"
23
+ f" url={self.url!r}, token={masked!r})"
24
+ )
25
+
26
+
27
+ def _section(name: str) -> str:
28
+ """Return the INI section name for a profile."""
29
+ return f"{PROFILE_PREFIX}{name}"
30
+
31
+
32
+ def load_config(path: Path | None = None) -> ConfigProfile:
33
+ """Load config file and return the resolved active profile.
34
+
35
+ Resolution precedence for each field: env var > config file > default.
36
+ """
37
+ cfg_path = path or CONFIG_FILE
38
+ cp = configparser.ConfigParser()
39
+ cp.read(cfg_path)
40
+
41
+ name = os.environ.get("CONTREE_PROFILE") or cp.defaults().get("profile", "default")
42
+ section = _section(name)
43
+
44
+ env_token = os.environ.get("CONTREE_TOKEN")
45
+ env_url = os.environ.get("CONTREE_URL")
46
+
47
+ if cp.has_section(section):
48
+ cfg_token = cp.get(section, "token", fallback=None)
49
+ cfg_url = cp.get(section, "url", fallback=DEFAULT_URL)
50
+ else:
51
+ cfg_token = None
52
+ cfg_url = DEFAULT_URL
53
+
54
+ return ConfigProfile(
55
+ name=name,
56
+ token=env_token or cfg_token,
57
+ url=env_url or cfg_url,
58
+ )
59
+
60
+
61
+ def _read_ini(path: Path | None = None) -> configparser.ConfigParser:
62
+ """Read the raw INI file into a ConfigParser."""
63
+ cp = configparser.ConfigParser()
64
+ cp.read(path or CONFIG_FILE)
65
+ return cp
66
+
67
+
68
+ def profile_exists(
69
+ profile: str, *, path: Path | None = None,
70
+ ) -> bool:
71
+ """Check whether a named profile already exists."""
72
+ cp = _read_ini(path)
73
+ return cp.has_section(_section(profile))
74
+
75
+
76
+ def save_profile(
77
+ profile: str, token: str, url: str,
78
+ *, path: Path | None = None,
79
+ ) -> None:
80
+ """Write/update a profile in the config file, preserving all other sections."""
81
+ cfg_dir = (path or CONFIG_FILE).parent if path else CONFIG_DIR
82
+ cfg_path = path or CONFIG_FILE
83
+ cp = _read_ini(cfg_path)
84
+ section = _section(profile)
85
+ if not cp.has_section(section):
86
+ cp.add_section(section)
87
+ cp.set(section, "token", token)
88
+ cp.set(section, "url", url)
89
+ cfg_dir.mkdir(parents=True, exist_ok=True)
90
+ with open(cfg_path, "w") as f:
91
+ cp.write(f)
92
+
93
+
94
+ def list_profiles(
95
+ *, path: Path | None = None,
96
+ ) -> list[tuple[str, bool]]:
97
+ """Return (name, is_active) pairs for all profiles."""
98
+ cp = _read_ini(path)
99
+ active = cp.defaults().get("profile", "default")
100
+ result: list[tuple[str, bool]] = []
101
+ for section in cp.sections():
102
+ if section.startswith(PROFILE_PREFIX):
103
+ name = section[len(PROFILE_PREFIX):]
104
+ result.append((name, name == active))
105
+ return result
106
+
107
+
108
+ def switch_profile(profile: str, *, path: Path | None = None) -> None:
109
+ """Set the active profile in [DEFAULT]."""
110
+ cfg_path = path or CONFIG_FILE
111
+ cp = _read_ini(cfg_path)
112
+ if not cp.has_section(_section(profile)):
113
+ raise ValueError(f"profile {profile!r} does not exist")
114
+ cp["DEFAULT"]["profile"] = profile
115
+ with open(cfg_path, "w") as f:
116
+ cp.write(f)
contree_cli/log.py ADDED
@@ -0,0 +1,40 @@
1
+ import logging
2
+ import sys
3
+ from types import MappingProxyType
4
+
5
+ from contree_cli.types import Colors
6
+
7
+
8
+ class ColorFormatter(logging.Formatter):
9
+ LEVEL_COLORS = MappingProxyType({
10
+ logging.DEBUG: Colors.CYAN,
11
+ logging.INFO: Colors.GREEN,
12
+ logging.WARNING: Colors.YELLOW,
13
+ logging.ERROR: Colors.RED,
14
+ logging.CRITICAL: Colors.BOLD_RED,
15
+ })
16
+
17
+ TEXT_COLORS = MappingProxyType({
18
+ logging.DEBUG: Colors.GRAY,
19
+ logging.INFO: Colors.DEFAULT,
20
+ logging.WARNING: Colors.DEFAULT,
21
+ logging.ERROR: Colors.DEFAULT,
22
+ logging.CRITICAL: Colors.RED,
23
+ })
24
+
25
+ def __init__(self, *, tty: bool) -> None:
26
+ super().__init__()
27
+ self._tty = tty
28
+
29
+ def format(self, record: logging.LogRecord) -> str:
30
+ level_color = self.LEVEL_COLORS.get(record.levelno, Colors.DEFAULT)
31
+ message_color = self.TEXT_COLORS.get(record.levelno, Colors.DEFAULT)
32
+ lvl = level_color(f"[{record.levelname}]")
33
+ msg = message_color(record.getMessage())
34
+ return f"{lvl} {msg}"
35
+
36
+
37
+ def setup_logging(level: int = logging.INFO) -> None:
38
+ handler = logging.StreamHandler(sys.stderr)
39
+ handler.setFormatter(ColorFormatter(tty=sys.stderr.isatty()))
40
+ logging.basicConfig(level=level, handlers=[handler], force=True)
@@ -0,0 +1,112 @@
1
+ import dataclasses
2
+ import grp
3
+ import hashlib
4
+ import os
5
+ import pwd
6
+
7
+
8
+ @dataclasses.dataclass(frozen=True, slots=True)
9
+ class MappedFile:
10
+ host_path: str
11
+ instance_path: str
12
+ uid: int
13
+ gid: int
14
+ mode: int
15
+
16
+ def sha256(self) -> str:
17
+ """Return hex SHA256 digest of the host file (streamed)."""
18
+ h = hashlib.sha256()
19
+ with open(self.host_path, "rb") as fh:
20
+ while chunk := fh.read(256 * 1024):
21
+ h.update(chunk)
22
+ return h.hexdigest()
23
+
24
+ @classmethod
25
+ def parse(cls, spec: str) -> "MappedFile":
26
+ parts = spec.split(":")
27
+ if not parts or not parts[0]:
28
+ raise ValueError(f"invalid file spec {spec!r}: host_path is required")
29
+
30
+ host_path = parts[0]
31
+ instance_path: str | None = None
32
+ uid: int | None = None
33
+ gid: int | None = None
34
+ mode: int | None = None
35
+
36
+ for part in parts[1:]:
37
+ if part.startswith("/"):
38
+ if instance_path is not None:
39
+ raise ValueError(
40
+ f"invalid file spec {spec!r}: duplicate instance path"
41
+ )
42
+ instance_path = part
43
+ elif part.startswith("u"):
44
+ uid = _resolve_uid(part[1:])
45
+ elif part.startswith("g"):
46
+ gid = _resolve_gid(part[1:])
47
+ elif part.startswith("m"):
48
+ mode = _parse_mode(part[1:])
49
+ else:
50
+ raise ValueError(
51
+ f"invalid file spec {spec!r}: "
52
+ f"unknown field {part!r} "
53
+ f"(expected /path, u<uid>, g<gid>, or m<mode>)"
54
+ )
55
+
56
+ needs_stat = instance_path is None or uid is None or gid is None or mode is None
57
+ if needs_stat:
58
+ try:
59
+ st = os.stat(host_path)
60
+ except OSError as exc:
61
+ raise ValueError(
62
+ f"cannot stat host file {host_path!r}: {exc}"
63
+ ) from exc
64
+ if instance_path is None:
65
+ instance_path = host_path
66
+ if uid is None:
67
+ uid = st.st_uid
68
+ if gid is None:
69
+ gid = st.st_gid
70
+ if mode is None:
71
+ mode = st.st_mode & 0o7777
72
+
73
+ assert instance_path is not None
74
+ assert uid is not None
75
+ assert gid is not None
76
+ assert mode is not None
77
+ return cls(
78
+ host_path=host_path,
79
+ instance_path=instance_path,
80
+ uid=uid,
81
+ gid=gid,
82
+ mode=mode,
83
+ )
84
+
85
+
86
+ def _resolve_uid(value: str) -> int:
87
+ try:
88
+ return int(value)
89
+ except ValueError:
90
+ pass
91
+ try:
92
+ return pwd.getpwnam(value).pw_uid
93
+ except KeyError:
94
+ return 0
95
+
96
+
97
+ def _resolve_gid(value: str) -> int:
98
+ try:
99
+ return int(value)
100
+ except ValueError:
101
+ pass
102
+ try:
103
+ return grp.getgrnam(value).gr_gid
104
+ except KeyError:
105
+ return 0
106
+
107
+
108
+ def _parse_mode(value: str) -> int:
109
+ try:
110
+ return int(value, 8)
111
+ except ValueError:
112
+ return 0