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/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
|