kub-cli 0.2.0__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.
- kub_cli/__init__.py +17 -0
- kub_cli/__main__.py +12 -0
- kub_cli/cli.py +292 -0
- kub_cli/commands.py +164 -0
- kub_cli/config.py +555 -0
- kub_cli/errors.py +32 -0
- kub_cli/img_cli.py +306 -0
- kub_cli/img_integration.py +265 -0
- kub_cli/img_tools.py +434 -0
- kub_cli/logging_utils.py +31 -0
- kub_cli/runtime.py +463 -0
- kub_cli/versioning.py +175 -0
- kub_cli-0.2.0.dist-info/METADATA +344 -0
- kub_cli-0.2.0.dist-info/RECORD +17 -0
- kub_cli-0.2.0.dist-info/WHEEL +4 -0
- kub_cli-0.2.0.dist-info/entry_points.txt +6 -0
- kub_cli-0.2.0.dist-info/licenses/LICENSE +206 -0
kub_cli/img_tools.py
ADDED
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 University of Strasbourg
|
|
2
|
+
# SPDX-FileContributor: Christophe Prud'homme
|
|
3
|
+
# SPDX-FileContributor: Cemosis
|
|
4
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
|
|
6
|
+
"""Image management helpers for the kub-img command."""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, replace
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
import subprocess
|
|
16
|
+
from typing import Any, Mapping, Sequence
|
|
17
|
+
|
|
18
|
+
from .config import KubConfig, KubConfigOverrides, SUPPORTED_RUNTIMES, loadKubConfig
|
|
19
|
+
from .errors import ImageNotFoundError, KubCliError
|
|
20
|
+
from .img_integration import (
|
|
21
|
+
buildKubImgInfoRequest,
|
|
22
|
+
buildKubImgPullRequest,
|
|
23
|
+
resolveApptainerLocalImageReference,
|
|
24
|
+
)
|
|
25
|
+
from .logging_utils import LOGGER, formatCommand
|
|
26
|
+
from .runtime import getRunnerValue, resolveRunnerExecutable
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class KubImgManager:
|
|
31
|
+
"""Execute runtime-specific image operations using resolved kub-cli config."""
|
|
32
|
+
|
|
33
|
+
config: KubConfig
|
|
34
|
+
|
|
35
|
+
def resolveRuntime(self, runtime: str | None) -> str:
|
|
36
|
+
if runtime is None:
|
|
37
|
+
return self.config.runtime
|
|
38
|
+
|
|
39
|
+
normalized = runtime.strip().lower()
|
|
40
|
+
if normalized not in SUPPORTED_RUNTIMES:
|
|
41
|
+
supported = ", ".join(sorted(SUPPORTED_RUNTIMES))
|
|
42
|
+
raise KubCliError(
|
|
43
|
+
f"Invalid runtime value '{runtime}'. Use one of: {supported}."
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
return normalized
|
|
47
|
+
|
|
48
|
+
def configWithRuntime(self, runtime: str | None) -> KubConfig:
|
|
49
|
+
resolvedRuntime = self.resolveRuntime(runtime)
|
|
50
|
+
return replace(self.config, runtime=resolvedRuntime)
|
|
51
|
+
|
|
52
|
+
def pullImage(
|
|
53
|
+
self,
|
|
54
|
+
*,
|
|
55
|
+
runtime: str | None,
|
|
56
|
+
source: str | None,
|
|
57
|
+
force: bool,
|
|
58
|
+
disableCache: bool,
|
|
59
|
+
apptainerFlags: Sequence[str],
|
|
60
|
+
dockerFlags: Sequence[str],
|
|
61
|
+
dryRun: bool,
|
|
62
|
+
) -> int:
|
|
63
|
+
runtimeConfig = self.configWithRuntime(runtime)
|
|
64
|
+
request = buildKubImgPullRequest(runtimeConfig)
|
|
65
|
+
|
|
66
|
+
if source is not None:
|
|
67
|
+
normalizedSource = source.strip()
|
|
68
|
+
if not normalizedSource:
|
|
69
|
+
raise KubCliError("Image source cannot be empty.")
|
|
70
|
+
request = replace(request, source=normalizedSource)
|
|
71
|
+
|
|
72
|
+
if request.runtime == "apptainer":
|
|
73
|
+
return self.pullApptainerImage(
|
|
74
|
+
runtimeConfig=runtimeConfig,
|
|
75
|
+
source=request.source,
|
|
76
|
+
destinationImage=request.image,
|
|
77
|
+
force=force,
|
|
78
|
+
disableCache=disableCache,
|
|
79
|
+
apptainerFlags=apptainerFlags,
|
|
80
|
+
dryRun=dryRun,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
return self.pullDockerImage(
|
|
84
|
+
runtimeConfig=runtimeConfig,
|
|
85
|
+
source=request.source,
|
|
86
|
+
destinationImage=request.image,
|
|
87
|
+
dockerFlags=dockerFlags,
|
|
88
|
+
dryRun=dryRun,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
def pullApptainerImage(
|
|
92
|
+
self,
|
|
93
|
+
*,
|
|
94
|
+
runtimeConfig: KubConfig,
|
|
95
|
+
source: str,
|
|
96
|
+
destinationImage: str,
|
|
97
|
+
force: bool,
|
|
98
|
+
disableCache: bool,
|
|
99
|
+
apptainerFlags: Sequence[str],
|
|
100
|
+
dryRun: bool,
|
|
101
|
+
) -> int:
|
|
102
|
+
if source.startswith("docker://"):
|
|
103
|
+
raise KubCliError(
|
|
104
|
+
"Apptainer pull source must use oras:// and not docker://."
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
runner = self.resolveRunner("apptainer", runtimeConfig)
|
|
108
|
+
|
|
109
|
+
command: list[str] = [runner, "pull"]
|
|
110
|
+
|
|
111
|
+
if force:
|
|
112
|
+
command.append("--force")
|
|
113
|
+
|
|
114
|
+
if disableCache:
|
|
115
|
+
command.append("--disable-cache")
|
|
116
|
+
|
|
117
|
+
if runtimeConfig.apptainerFlags:
|
|
118
|
+
command.extend(runtimeConfig.apptainerFlags)
|
|
119
|
+
|
|
120
|
+
if apptainerFlags:
|
|
121
|
+
command.extend(apptainerFlags)
|
|
122
|
+
|
|
123
|
+
command.extend([destinationImage, source])
|
|
124
|
+
|
|
125
|
+
return self.runCommand(command, captureOutput=False, dryRun=dryRun)
|
|
126
|
+
|
|
127
|
+
def pullDockerImage(
|
|
128
|
+
self,
|
|
129
|
+
*,
|
|
130
|
+
runtimeConfig: KubConfig,
|
|
131
|
+
source: str,
|
|
132
|
+
destinationImage: str,
|
|
133
|
+
dockerFlags: Sequence[str],
|
|
134
|
+
dryRun: bool,
|
|
135
|
+
) -> int:
|
|
136
|
+
runner = self.resolveRunner("docker", runtimeConfig)
|
|
137
|
+
|
|
138
|
+
pullCommand: list[str] = [runner, "pull"]
|
|
139
|
+
if runtimeConfig.dockerFlags:
|
|
140
|
+
pullCommand.extend(runtimeConfig.dockerFlags)
|
|
141
|
+
if dockerFlags:
|
|
142
|
+
pullCommand.extend(dockerFlags)
|
|
143
|
+
pullCommand.append(source)
|
|
144
|
+
|
|
145
|
+
exitCode = self.runCommand(pullCommand, captureOutput=False, dryRun=dryRun)
|
|
146
|
+
if exitCode != 0:
|
|
147
|
+
return exitCode
|
|
148
|
+
|
|
149
|
+
if destinationImage == source:
|
|
150
|
+
return 0
|
|
151
|
+
|
|
152
|
+
tagCommand = [runner, "tag", source, destinationImage]
|
|
153
|
+
return self.runCommand(tagCommand, captureOutput=False, dryRun=dryRun)
|
|
154
|
+
|
|
155
|
+
def collectInfo(self, *, runtime: str | None) -> dict[str, Any]:
|
|
156
|
+
runtimeConfig = self.configWithRuntime(runtime)
|
|
157
|
+
request = buildKubImgInfoRequest(runtimeConfig)
|
|
158
|
+
|
|
159
|
+
if request.runtime == "apptainer":
|
|
160
|
+
return self.collectApptainerInfo(request.image, runtimeConfig)
|
|
161
|
+
|
|
162
|
+
return self.collectDockerInfo(request.image, runtimeConfig)
|
|
163
|
+
|
|
164
|
+
def collectApptainerInfo(self, imagePathRaw: str, runtimeConfig: KubConfig) -> dict[str, Any]:
|
|
165
|
+
imagePath = Path(imagePathRaw).expanduser()
|
|
166
|
+
if not imagePath.exists():
|
|
167
|
+
raise ImageNotFoundError(f"Container image not found: '{imagePath}'.")
|
|
168
|
+
|
|
169
|
+
if imagePath.is_dir():
|
|
170
|
+
raise ImageNotFoundError(
|
|
171
|
+
f"Container image must be a file, got directory: '{imagePath}'."
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
apps = self.inspectApptainerApps(imagePath, runtimeConfig)
|
|
175
|
+
labelsRaw = self.inspectApptainerLabels(imagePath, runtimeConfig)
|
|
176
|
+
|
|
177
|
+
imageStat = imagePath.stat()
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
"runtime": "apptainer",
|
|
181
|
+
"image": str(imagePath),
|
|
182
|
+
"sizeBytes": imageStat.st_size,
|
|
183
|
+
"modifiedUtc": datetime.fromtimestamp(
|
|
184
|
+
imageStat.st_mtime,
|
|
185
|
+
tz=timezone.utc,
|
|
186
|
+
).isoformat(),
|
|
187
|
+
"apps": apps,
|
|
188
|
+
"labels": parseLabelOutput(labelsRaw),
|
|
189
|
+
"labelsRaw": labelsRaw,
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
def collectDockerInfo(self, imageReference: str, runtimeConfig: KubConfig) -> dict[str, Any]:
|
|
193
|
+
runner = self.resolveRunner("docker", runtimeConfig)
|
|
194
|
+
command = [runner, "image", "inspect", imageReference]
|
|
195
|
+
completed = self.runCommand(
|
|
196
|
+
command,
|
|
197
|
+
captureOutput=True,
|
|
198
|
+
dryRun=False,
|
|
199
|
+
runtimeConfig=runtimeConfig,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
if not isinstance(completed, subprocess.CompletedProcess):
|
|
203
|
+
raise KubCliError("Internal error while collecting Docker image information.")
|
|
204
|
+
|
|
205
|
+
if completed.returncode != 0:
|
|
206
|
+
stderrText = completed.stderr.strip() if completed.stderr else ""
|
|
207
|
+
raise KubCliError(
|
|
208
|
+
"Unable to inspect Docker image. "
|
|
209
|
+
f"Command failed with code {completed.returncode}. {stderrText}".strip()
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
payload: Any
|
|
213
|
+
try:
|
|
214
|
+
payload = json.loads(completed.stdout)
|
|
215
|
+
except json.JSONDecodeError as error:
|
|
216
|
+
raise KubCliError("Docker inspect did not return valid JSON output.") from error
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
"runtime": "docker",
|
|
220
|
+
"image": imageReference,
|
|
221
|
+
"inspect": payload,
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
def inspectApptainerApps(self, imagePath: Path, runtimeConfig: KubConfig) -> list[str]:
|
|
225
|
+
runner = self.resolveRunner("apptainer", runtimeConfig)
|
|
226
|
+
command = [runner, "inspect", "--list-apps", str(imagePath)]
|
|
227
|
+
completed = self.runCommand(
|
|
228
|
+
command,
|
|
229
|
+
captureOutput=True,
|
|
230
|
+
dryRun=False,
|
|
231
|
+
runtimeConfig=runtimeConfig,
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
if not isinstance(completed, subprocess.CompletedProcess):
|
|
235
|
+
raise KubCliError("Internal error while listing Apptainer apps.")
|
|
236
|
+
|
|
237
|
+
if completed.returncode != 0:
|
|
238
|
+
stderrText = completed.stderr.strip() if completed.stderr else ""
|
|
239
|
+
raise KubCliError(
|
|
240
|
+
"Unable to list Apptainer apps from image. "
|
|
241
|
+
f"Command failed with code {completed.returncode}. {stderrText}".strip()
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
return [line.strip() for line in completed.stdout.splitlines() if line.strip()]
|
|
245
|
+
|
|
246
|
+
def inspectApptainerLabels(self, imagePath: Path, runtimeConfig: KubConfig) -> str:
|
|
247
|
+
runner = self.resolveRunner("apptainer", runtimeConfig)
|
|
248
|
+
command = [runner, "inspect", "--labels", str(imagePath)]
|
|
249
|
+
completed = self.runCommand(
|
|
250
|
+
command,
|
|
251
|
+
captureOutput=True,
|
|
252
|
+
dryRun=False,
|
|
253
|
+
runtimeConfig=runtimeConfig,
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
if not isinstance(completed, subprocess.CompletedProcess):
|
|
257
|
+
raise KubCliError("Internal error while inspecting Apptainer labels.")
|
|
258
|
+
|
|
259
|
+
if completed.returncode != 0:
|
|
260
|
+
stderrText = completed.stderr.strip() if completed.stderr else ""
|
|
261
|
+
raise KubCliError(
|
|
262
|
+
"Unable to inspect Apptainer image labels. "
|
|
263
|
+
f"Command failed with code {completed.returncode}. {stderrText}".strip()
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
return completed.stdout.strip()
|
|
267
|
+
|
|
268
|
+
def printInfo(self, *, runtime: str | None, jsonOutput: bool) -> int:
|
|
269
|
+
info = self.collectInfo(runtime=runtime)
|
|
270
|
+
|
|
271
|
+
if jsonOutput:
|
|
272
|
+
print(json.dumps(info, indent=2, sort_keys=True))
|
|
273
|
+
return 0
|
|
274
|
+
|
|
275
|
+
print(f"Runtime: {info['runtime']}")
|
|
276
|
+
print(f"Image: {info['image']}")
|
|
277
|
+
|
|
278
|
+
if info["runtime"] == "apptainer":
|
|
279
|
+
print(f"Size: {info['sizeBytes']} bytes")
|
|
280
|
+
print(f"Modified (UTC): {info['modifiedUtc']}")
|
|
281
|
+
|
|
282
|
+
apps: list[str] = info["apps"]
|
|
283
|
+
if apps:
|
|
284
|
+
print("Apps:")
|
|
285
|
+
for app in apps:
|
|
286
|
+
print(f" - {app}")
|
|
287
|
+
else:
|
|
288
|
+
print("Apps: (none listed)")
|
|
289
|
+
|
|
290
|
+
labels: Mapping[str, str] = info["labels"]
|
|
291
|
+
if labels:
|
|
292
|
+
print("Labels:")
|
|
293
|
+
for key in sorted(labels.keys()):
|
|
294
|
+
print(f" {key}: {labels[key]}")
|
|
295
|
+
else:
|
|
296
|
+
labelsRaw: str = info["labelsRaw"]
|
|
297
|
+
if labelsRaw:
|
|
298
|
+
print("Labels (raw):")
|
|
299
|
+
print(labelsRaw)
|
|
300
|
+
else:
|
|
301
|
+
print("Labels: (none)")
|
|
302
|
+
else:
|
|
303
|
+
inspectData = info["inspect"]
|
|
304
|
+
if isinstance(inspectData, list) and inspectData:
|
|
305
|
+
first = inspectData[0]
|
|
306
|
+
if isinstance(first, Mapping):
|
|
307
|
+
repoTags = first.get("RepoTags")
|
|
308
|
+
if repoTags is not None:
|
|
309
|
+
print(f"RepoTags: {repoTags}")
|
|
310
|
+
imageId = first.get("Id")
|
|
311
|
+
if imageId is not None:
|
|
312
|
+
print(f"Id: {imageId}")
|
|
313
|
+
|
|
314
|
+
return 0
|
|
315
|
+
|
|
316
|
+
def printApps(self, *, runtime: str | None) -> int:
|
|
317
|
+
runtimeConfig = self.configWithRuntime(runtime)
|
|
318
|
+
|
|
319
|
+
if resolveRuntimeString(runtimeConfig.runtime) != "apptainer":
|
|
320
|
+
raise KubCliError(
|
|
321
|
+
"kub-img apps is only available with Apptainer runtime. "
|
|
322
|
+
"Use --runtime apptainer."
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
imagePathRaw = resolveApptainerLocalImageReference(runtimeConfig)
|
|
326
|
+
imagePath = Path(imagePathRaw).expanduser()
|
|
327
|
+
if not imagePath.exists():
|
|
328
|
+
raise ImageNotFoundError(f"Container image not found: '{imagePath}'.")
|
|
329
|
+
|
|
330
|
+
apps = self.inspectApptainerApps(imagePath, runtimeConfig)
|
|
331
|
+
|
|
332
|
+
for app in apps:
|
|
333
|
+
print(app)
|
|
334
|
+
|
|
335
|
+
return 0
|
|
336
|
+
|
|
337
|
+
def printImagePath(self, *, runtime: str | None) -> int:
|
|
338
|
+
runtimeConfig = self.configWithRuntime(runtime)
|
|
339
|
+
resolvedRuntime = resolveRuntimeString(runtimeConfig.runtime)
|
|
340
|
+
|
|
341
|
+
if resolvedRuntime == "docker":
|
|
342
|
+
request = buildKubImgInfoRequest(runtimeConfig)
|
|
343
|
+
print(request.image)
|
|
344
|
+
return 0
|
|
345
|
+
|
|
346
|
+
imagePath = resolveApptainerLocalImageReference(runtimeConfig)
|
|
347
|
+
print(imagePath)
|
|
348
|
+
return 0
|
|
349
|
+
|
|
350
|
+
def resolveRunner(self, runtime: str, runtimeConfig: KubConfig) -> str:
|
|
351
|
+
runnerValue = getRunnerValue(runtimeConfig, runtime) # type: ignore[arg-type]
|
|
352
|
+
return resolveRunnerExecutable(runnerValue, runtimeName=runtime)
|
|
353
|
+
|
|
354
|
+
def runCommand(
|
|
355
|
+
self,
|
|
356
|
+
command: Sequence[str],
|
|
357
|
+
*,
|
|
358
|
+
captureOutput: bool,
|
|
359
|
+
dryRun: bool,
|
|
360
|
+
runtimeConfig: KubConfig | None = None,
|
|
361
|
+
) -> int | subprocess.CompletedProcess[str]:
|
|
362
|
+
if self.config.verbose or (runtimeConfig is not None and runtimeConfig.verbose):
|
|
363
|
+
LOGGER.debug("Resolved command: %s", formatCommand(command))
|
|
364
|
+
|
|
365
|
+
if dryRun:
|
|
366
|
+
print(formatCommand(command))
|
|
367
|
+
return 0
|
|
368
|
+
|
|
369
|
+
if captureOutput:
|
|
370
|
+
runKwargs: dict[str, Any] = {
|
|
371
|
+
"capture_output": True,
|
|
372
|
+
"text": True,
|
|
373
|
+
}
|
|
374
|
+
else:
|
|
375
|
+
runKwargs = {}
|
|
376
|
+
|
|
377
|
+
try:
|
|
378
|
+
return subprocess.run(
|
|
379
|
+
list(command),
|
|
380
|
+
check=False,
|
|
381
|
+
env=dict(os.environ),
|
|
382
|
+
**runKwargs,
|
|
383
|
+
)
|
|
384
|
+
except KeyboardInterrupt as error:
|
|
385
|
+
raise KubCliError("Execution interrupted by user.", exit_code=130) from error
|
|
386
|
+
except OSError as error:
|
|
387
|
+
raise KubCliError(f"Unable to execute runtime command: {error}") from error
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def resolveImgConfig(
|
|
391
|
+
*,
|
|
392
|
+
runtime: str | None,
|
|
393
|
+
image: str | None,
|
|
394
|
+
runner: str | None,
|
|
395
|
+
verbose: bool | None,
|
|
396
|
+
cwd: Path | None = None,
|
|
397
|
+
env: Mapping[str, str] | None = None,
|
|
398
|
+
userConfigPath: Path | None = None,
|
|
399
|
+
) -> KubConfig:
|
|
400
|
+
overrides = KubConfigOverrides(
|
|
401
|
+
runtime=runtime,
|
|
402
|
+
image=image,
|
|
403
|
+
runner=runner,
|
|
404
|
+
verbose=verbose,
|
|
405
|
+
)
|
|
406
|
+
return loadKubConfig(
|
|
407
|
+
cwd=cwd,
|
|
408
|
+
env=env,
|
|
409
|
+
overrides=overrides,
|
|
410
|
+
userConfigPath=userConfigPath,
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def parseLabelOutput(rawText: str) -> dict[str, str]:
|
|
415
|
+
labels: dict[str, str] = {}
|
|
416
|
+
|
|
417
|
+
for line in rawText.splitlines():
|
|
418
|
+
normalized = line.strip()
|
|
419
|
+
if not normalized or ":" not in normalized:
|
|
420
|
+
continue
|
|
421
|
+
|
|
422
|
+
key, value = normalized.split(":", maxsplit=1)
|
|
423
|
+
labels[key.strip()] = value.strip()
|
|
424
|
+
|
|
425
|
+
return labels
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def resolveRuntimeString(runtime: str) -> str:
|
|
429
|
+
normalized = runtime.strip().lower()
|
|
430
|
+
if normalized not in SUPPORTED_RUNTIMES:
|
|
431
|
+
raise KubCliError(f"Invalid runtime value: '{runtime}'.")
|
|
432
|
+
if normalized == "auto":
|
|
433
|
+
return "apptainer"
|
|
434
|
+
return normalized
|
kub_cli/logging_utils.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 University of Strasbourg
|
|
2
|
+
# SPDX-FileContributor: Christophe Prud'homme
|
|
3
|
+
# SPDX-FileContributor: Cemosis
|
|
4
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
|
|
6
|
+
"""Logging helpers for kub-cli."""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import shlex
|
|
12
|
+
from typing import Sequence
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
LOGGER = logging.getLogger("kub_cli")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def configureLogging(verbose: bool) -> None:
|
|
19
|
+
"""Configure process-wide logging according to verbosity."""
|
|
20
|
+
level = logging.DEBUG if verbose else logging.WARNING
|
|
21
|
+
logging.basicConfig(
|
|
22
|
+
level=level,
|
|
23
|
+
format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
|
|
24
|
+
datefmt="%Y-%m-%d %H:%M:%S",
|
|
25
|
+
force=True,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def formatCommand(arguments: Sequence[str]) -> str:
|
|
30
|
+
"""Format a subprocess argument list as a shell-style string."""
|
|
31
|
+
return shlex.join([str(item) for item in arguments])
|