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/config.py
ADDED
|
@@ -0,0 +1,555 @@
|
|
|
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
|
+
"""Configuration loading and precedence rules for kub-cli."""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
import os
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
import re
|
|
14
|
+
import shlex
|
|
15
|
+
from typing import Any, Mapping
|
|
16
|
+
|
|
17
|
+
from .errors import ConfigError
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
import tomllib
|
|
21
|
+
except ModuleNotFoundError: # pragma: no cover - exercised on Python < 3.11
|
|
22
|
+
import tomli as tomllib # type: ignore[import-not-found]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
SUPPORTED_RUNTIMES = {"auto", "apptainer", "docker"}
|
|
26
|
+
DEFAULT_RUNTIME = "auto"
|
|
27
|
+
DEFAULT_APPTAINER_RUNNER = "apptainer"
|
|
28
|
+
DEFAULT_DOCKER_RUNNER = "docker"
|
|
29
|
+
DEFAULT_DOCKER_IMAGE = "ghcr.io/feelpp/ktirio-urban-building:master"
|
|
30
|
+
DEFAULT_APPTAINER_IMAGE = "oras://ghcr.io/feelpp/ktirio-urban-building:master-sif"
|
|
31
|
+
DEFAULT_PROJECT_CONFIG_NAME = ".kub-cli.toml"
|
|
32
|
+
DEFAULT_USER_CONFIG_PATH = Path("~/.config/kub-cli/config.toml")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class KubConfig:
|
|
37
|
+
"""Effective kub-cli configuration used for command execution."""
|
|
38
|
+
|
|
39
|
+
runtime: str = DEFAULT_RUNTIME
|
|
40
|
+
imageOverride: str | None = None
|
|
41
|
+
image: str | None = None
|
|
42
|
+
imageDocker: str | None = None
|
|
43
|
+
imageApptainer: str | None = None
|
|
44
|
+
binds: tuple[str, ...] = ()
|
|
45
|
+
workdir: str | None = None
|
|
46
|
+
runner: str | None = None
|
|
47
|
+
apptainerRunner: str = DEFAULT_APPTAINER_RUNNER
|
|
48
|
+
dockerRunner: str = DEFAULT_DOCKER_RUNNER
|
|
49
|
+
verbose: bool = False
|
|
50
|
+
apptainerFlags: tuple[str, ...] = ()
|
|
51
|
+
dockerFlags: tuple[str, ...] = ()
|
|
52
|
+
env: Mapping[str, str] = field(default_factory=dict)
|
|
53
|
+
|
|
54
|
+
def toDict(self) -> dict[str, Any]:
|
|
55
|
+
return {
|
|
56
|
+
"runtime": self.runtime,
|
|
57
|
+
"imageOverride": self.imageOverride,
|
|
58
|
+
"image": self.image,
|
|
59
|
+
"imageDocker": self.imageDocker,
|
|
60
|
+
"imageApptainer": self.imageApptainer,
|
|
61
|
+
"binds": list(self.binds),
|
|
62
|
+
"workdir": self.workdir,
|
|
63
|
+
"runner": self.runner,
|
|
64
|
+
"apptainerRunner": self.apptainerRunner,
|
|
65
|
+
"dockerRunner": self.dockerRunner,
|
|
66
|
+
"verbose": self.verbose,
|
|
67
|
+
"apptainerFlags": list(self.apptainerFlags),
|
|
68
|
+
"dockerFlags": list(self.dockerFlags),
|
|
69
|
+
"env": dict(self.env),
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True)
|
|
74
|
+
class KubConfigOverrides:
|
|
75
|
+
"""CLI-provided configuration override values."""
|
|
76
|
+
|
|
77
|
+
runtime: str | None = None
|
|
78
|
+
image: str | None = None
|
|
79
|
+
imageDocker: str | None = None
|
|
80
|
+
imageApptainer: str | None = None
|
|
81
|
+
binds: tuple[str, ...] = ()
|
|
82
|
+
workdir: str | None = None
|
|
83
|
+
runner: str | None = None
|
|
84
|
+
apptainerRunner: str | None = None
|
|
85
|
+
dockerRunner: str | None = None
|
|
86
|
+
verbose: bool | None = None
|
|
87
|
+
apptainerFlags: tuple[str, ...] = ()
|
|
88
|
+
dockerFlags: tuple[str, ...] = ()
|
|
89
|
+
env: Mapping[str, str] = field(default_factory=dict)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass
|
|
93
|
+
class _PartialConfig:
|
|
94
|
+
runtime: str | None = None
|
|
95
|
+
imageOverride: str | None = None
|
|
96
|
+
image: str | None = None
|
|
97
|
+
imageDocker: str | None = None
|
|
98
|
+
imageApptainer: str | None = None
|
|
99
|
+
binds: list[str] = field(default_factory=list)
|
|
100
|
+
workdir: str | None = None
|
|
101
|
+
runner: str | None = None
|
|
102
|
+
apptainerRunner: str | None = None
|
|
103
|
+
dockerRunner: str | None = None
|
|
104
|
+
verbose: bool | None = None
|
|
105
|
+
apptainerFlags: list[str] = field(default_factory=list)
|
|
106
|
+
dockerFlags: list[str] = field(default_factory=list)
|
|
107
|
+
env: dict[str, str] = field(default_factory=dict)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def loadKubConfig(
|
|
111
|
+
*,
|
|
112
|
+
cwd: Path | None = None,
|
|
113
|
+
env: Mapping[str, str] | None = None,
|
|
114
|
+
overrides: KubConfigOverrides | None = None,
|
|
115
|
+
userConfigPath: Path | None = None,
|
|
116
|
+
projectConfigName: str = DEFAULT_PROJECT_CONFIG_NAME,
|
|
117
|
+
) -> KubConfig:
|
|
118
|
+
"""Load effective configuration using precedence:
|
|
119
|
+
|
|
120
|
+
CLI options > environment variables > project config > user config > defaults.
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
runtimeCwd = (cwd or Path.cwd()).resolve()
|
|
124
|
+
runtimeEnv = dict(os.environ if env is None else env)
|
|
125
|
+
userPath = (userConfigPath or DEFAULT_USER_CONFIG_PATH).expanduser()
|
|
126
|
+
projectPath = runtimeCwd / projectConfigName
|
|
127
|
+
|
|
128
|
+
config = KubConfig()
|
|
129
|
+
config = mergeConfig(config, loadFilePartial(userPath))
|
|
130
|
+
config = mergeConfig(config, loadFilePartial(projectPath))
|
|
131
|
+
config = mergeConfig(config, loadEnvPartial(runtimeEnv, runtimeCwd))
|
|
132
|
+
|
|
133
|
+
if overrides is not None:
|
|
134
|
+
config = mergeConfig(config, loadOverridePartial(overrides, runtimeCwd))
|
|
135
|
+
|
|
136
|
+
return config
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def loadFilePartial(path: Path) -> _PartialConfig:
|
|
140
|
+
if not path.exists():
|
|
141
|
+
return _PartialConfig()
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
with path.open("rb") as stream:
|
|
145
|
+
parsed = tomllib.load(stream)
|
|
146
|
+
except tomllib.TOMLDecodeError as error:
|
|
147
|
+
raise ConfigError(f"Invalid TOML in config file '{path}': {error}") from error
|
|
148
|
+
except OSError as error:
|
|
149
|
+
raise ConfigError(f"Unable to read config file '{path}': {error}") from error
|
|
150
|
+
|
|
151
|
+
table = extractConfigTable(parsed)
|
|
152
|
+
return parseMappingAsPartial(table, baseDir=path.parent)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def loadEnvPartial(env: Mapping[str, str], cwd: Path) -> _PartialConfig:
|
|
156
|
+
partial = _PartialConfig()
|
|
157
|
+
|
|
158
|
+
runtimeRaw = env.get("KUB_RUNTIME")
|
|
159
|
+
if runtimeRaw:
|
|
160
|
+
partial.runtime = parseRuntime(runtimeRaw, variableName="KUB_RUNTIME")
|
|
161
|
+
|
|
162
|
+
imageRaw = env.get("KUB_IMAGE")
|
|
163
|
+
if imageRaw:
|
|
164
|
+
partial.image = parseLegacyImageValue(imageRaw, baseDir=cwd)
|
|
165
|
+
|
|
166
|
+
imageDockerRaw = env.get("KUB_IMAGE_DOCKER")
|
|
167
|
+
if imageDockerRaw:
|
|
168
|
+
partial.imageDocker = parseDockerImageValue(imageDockerRaw)
|
|
169
|
+
|
|
170
|
+
imageApptainerRaw = env.get("KUB_IMAGE_APPTAINER")
|
|
171
|
+
if imageApptainerRaw:
|
|
172
|
+
partial.imageApptainer = parseApptainerImageValue(imageApptainerRaw, baseDir=cwd)
|
|
173
|
+
|
|
174
|
+
bindsRaw = env.get("KUB_BIND")
|
|
175
|
+
if bindsRaw:
|
|
176
|
+
partial.binds = parseDelimitedList(bindsRaw)
|
|
177
|
+
|
|
178
|
+
workdirRaw = env.get("KUB_WORKDIR")
|
|
179
|
+
if workdirRaw:
|
|
180
|
+
partial.workdir = workdirRaw
|
|
181
|
+
|
|
182
|
+
runnerRaw = env.get("KUB_APP_RUNNER")
|
|
183
|
+
if runnerRaw:
|
|
184
|
+
partial.runner = runnerRaw
|
|
185
|
+
|
|
186
|
+
apptainerRunnerRaw = env.get("KUB_APPTAINER_RUNNER")
|
|
187
|
+
if apptainerRunnerRaw:
|
|
188
|
+
partial.apptainerRunner = apptainerRunnerRaw
|
|
189
|
+
|
|
190
|
+
dockerRunnerRaw = env.get("KUB_DOCKER_RUNNER")
|
|
191
|
+
if dockerRunnerRaw:
|
|
192
|
+
partial.dockerRunner = dockerRunnerRaw
|
|
193
|
+
|
|
194
|
+
verboseRaw = env.get("KUB_VERBOSE")
|
|
195
|
+
if verboseRaw is not None:
|
|
196
|
+
partial.verbose = parseBool(verboseRaw, variableName="KUB_VERBOSE")
|
|
197
|
+
|
|
198
|
+
apptainerFlagsRaw = env.get("KUB_APPTAINER_FLAGS")
|
|
199
|
+
if apptainerFlagsRaw:
|
|
200
|
+
partial.apptainerFlags = shlex.split(apptainerFlagsRaw)
|
|
201
|
+
|
|
202
|
+
dockerFlagsRaw = env.get("KUB_DOCKER_FLAGS")
|
|
203
|
+
if dockerFlagsRaw:
|
|
204
|
+
partial.dockerFlags = shlex.split(dockerFlagsRaw)
|
|
205
|
+
|
|
206
|
+
return partial
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def loadOverridePartial(overrides: KubConfigOverrides, cwd: Path) -> _PartialConfig:
|
|
210
|
+
partial = _PartialConfig()
|
|
211
|
+
|
|
212
|
+
if overrides.runtime is not None:
|
|
213
|
+
partial.runtime = parseRuntime(overrides.runtime, variableName="--runtime")
|
|
214
|
+
|
|
215
|
+
if overrides.image is not None:
|
|
216
|
+
partial.imageOverride = parseLegacyImageValue(overrides.image, baseDir=cwd)
|
|
217
|
+
|
|
218
|
+
if overrides.imageDocker is not None:
|
|
219
|
+
partial.imageDocker = parseDockerImageValue(overrides.imageDocker)
|
|
220
|
+
|
|
221
|
+
if overrides.imageApptainer is not None:
|
|
222
|
+
partial.imageApptainer = parseApptainerImageValue(
|
|
223
|
+
overrides.imageApptainer,
|
|
224
|
+
baseDir=cwd,
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
if overrides.binds:
|
|
228
|
+
partial.binds = list(overrides.binds)
|
|
229
|
+
|
|
230
|
+
if overrides.workdir is not None:
|
|
231
|
+
partial.workdir = overrides.workdir
|
|
232
|
+
|
|
233
|
+
if overrides.runner is not None:
|
|
234
|
+
partial.runner = overrides.runner
|
|
235
|
+
|
|
236
|
+
if overrides.apptainerRunner is not None:
|
|
237
|
+
partial.apptainerRunner = overrides.apptainerRunner
|
|
238
|
+
|
|
239
|
+
if overrides.dockerRunner is not None:
|
|
240
|
+
partial.dockerRunner = overrides.dockerRunner
|
|
241
|
+
|
|
242
|
+
if overrides.verbose is not None:
|
|
243
|
+
partial.verbose = overrides.verbose
|
|
244
|
+
|
|
245
|
+
if overrides.apptainerFlags:
|
|
246
|
+
partial.apptainerFlags = list(overrides.apptainerFlags)
|
|
247
|
+
|
|
248
|
+
if overrides.dockerFlags:
|
|
249
|
+
partial.dockerFlags = list(overrides.dockerFlags)
|
|
250
|
+
|
|
251
|
+
if overrides.env:
|
|
252
|
+
partial.env = dict(overrides.env)
|
|
253
|
+
|
|
254
|
+
return partial
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def mergeConfig(base: KubConfig, partial: _PartialConfig) -> KubConfig:
|
|
258
|
+
runtime = partial.runtime if partial.runtime is not None else base.runtime
|
|
259
|
+
imageOverride = (
|
|
260
|
+
partial.imageOverride if partial.imageOverride is not None else base.imageOverride
|
|
261
|
+
)
|
|
262
|
+
image = partial.image if partial.image is not None else base.image
|
|
263
|
+
imageDocker = partial.imageDocker if partial.imageDocker is not None else base.imageDocker
|
|
264
|
+
imageApptainer = (
|
|
265
|
+
partial.imageApptainer
|
|
266
|
+
if partial.imageApptainer is not None
|
|
267
|
+
else base.imageApptainer
|
|
268
|
+
)
|
|
269
|
+
workdir = partial.workdir if partial.workdir is not None else base.workdir
|
|
270
|
+
runner = partial.runner if partial.runner is not None else base.runner
|
|
271
|
+
apptainerRunner = (
|
|
272
|
+
partial.apptainerRunner
|
|
273
|
+
if partial.apptainerRunner is not None
|
|
274
|
+
else base.apptainerRunner
|
|
275
|
+
)
|
|
276
|
+
dockerRunner = (
|
|
277
|
+
partial.dockerRunner if partial.dockerRunner is not None else base.dockerRunner
|
|
278
|
+
)
|
|
279
|
+
verbose = partial.verbose if partial.verbose is not None else base.verbose
|
|
280
|
+
|
|
281
|
+
binds = uniqueInOrder([*base.binds, *partial.binds])
|
|
282
|
+
apptainerFlags = uniqueInOrder([*base.apptainerFlags, *partial.apptainerFlags])
|
|
283
|
+
dockerFlags = uniqueInOrder([*base.dockerFlags, *partial.dockerFlags])
|
|
284
|
+
|
|
285
|
+
mergedEnv = dict(base.env)
|
|
286
|
+
mergedEnv.update(partial.env)
|
|
287
|
+
|
|
288
|
+
return KubConfig(
|
|
289
|
+
runtime=runtime,
|
|
290
|
+
imageOverride=imageOverride,
|
|
291
|
+
image=image,
|
|
292
|
+
imageDocker=imageDocker,
|
|
293
|
+
imageApptainer=imageApptainer,
|
|
294
|
+
binds=tuple(binds),
|
|
295
|
+
workdir=workdir,
|
|
296
|
+
runner=runner,
|
|
297
|
+
apptainerRunner=apptainerRunner,
|
|
298
|
+
dockerRunner=dockerRunner,
|
|
299
|
+
verbose=verbose,
|
|
300
|
+
apptainerFlags=tuple(apptainerFlags),
|
|
301
|
+
dockerFlags=tuple(dockerFlags),
|
|
302
|
+
env=mergedEnv,
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def parseMappingAsPartial(mapping: Mapping[str, Any], *, baseDir: Path) -> _PartialConfig:
|
|
307
|
+
partial = _PartialConfig()
|
|
308
|
+
|
|
309
|
+
runtimeRaw = mapping.get("runtime")
|
|
310
|
+
if runtimeRaw is not None:
|
|
311
|
+
partial.runtime = parseRuntime(runtimeRaw, variableName="runtime")
|
|
312
|
+
|
|
313
|
+
imageRaw = mapping.get("image")
|
|
314
|
+
if imageRaw is not None:
|
|
315
|
+
if isinstance(imageRaw, Mapping):
|
|
316
|
+
defaultImageRaw = imageRaw.get("default")
|
|
317
|
+
if defaultImageRaw is not None:
|
|
318
|
+
partial.image = parseLegacyImageValue(defaultImageRaw, baseDir=baseDir)
|
|
319
|
+
|
|
320
|
+
dockerImageRaw = imageRaw.get("docker")
|
|
321
|
+
if dockerImageRaw is not None:
|
|
322
|
+
partial.imageDocker = parseDockerImageValue(dockerImageRaw)
|
|
323
|
+
|
|
324
|
+
apptainerImageRaw = imageRaw.get("apptainer")
|
|
325
|
+
if apptainerImageRaw is not None:
|
|
326
|
+
partial.imageApptainer = parseApptainerImageValue(
|
|
327
|
+
apptainerImageRaw,
|
|
328
|
+
baseDir=baseDir,
|
|
329
|
+
)
|
|
330
|
+
else:
|
|
331
|
+
partial.image = parseLegacyImageValue(imageRaw, baseDir=baseDir)
|
|
332
|
+
|
|
333
|
+
imageDockerRaw = mapping.get("image_docker", mapping.get("imageDocker"))
|
|
334
|
+
if imageDockerRaw is not None:
|
|
335
|
+
partial.imageDocker = parseDockerImageValue(imageDockerRaw)
|
|
336
|
+
|
|
337
|
+
imageApptainerRaw = mapping.get(
|
|
338
|
+
"image_apptainer",
|
|
339
|
+
mapping.get("imageApptainer"),
|
|
340
|
+
)
|
|
341
|
+
if imageApptainerRaw is not None:
|
|
342
|
+
partial.imageApptainer = parseApptainerImageValue(imageApptainerRaw, baseDir=baseDir)
|
|
343
|
+
|
|
344
|
+
bindRaw = mapping.get("bind")
|
|
345
|
+
bindsRaw = mapping.get("binds")
|
|
346
|
+
if bindRaw is not None:
|
|
347
|
+
partial.binds.extend(parseBindValue(bindRaw))
|
|
348
|
+
if bindsRaw is not None:
|
|
349
|
+
partial.binds.extend(parseBindValue(bindsRaw))
|
|
350
|
+
|
|
351
|
+
workdirRaw = mapping.get("workdir", mapping.get("pwd"))
|
|
352
|
+
if workdirRaw is not None:
|
|
353
|
+
partial.workdir = str(workdirRaw)
|
|
354
|
+
|
|
355
|
+
runnerRaw = mapping.get("app_runner", mapping.get("runner"))
|
|
356
|
+
if runnerRaw is not None:
|
|
357
|
+
partial.runner = str(runnerRaw)
|
|
358
|
+
|
|
359
|
+
apptainerRunnerRaw = mapping.get("apptainer_runner", mapping.get("apptainerRunner"))
|
|
360
|
+
if apptainerRunnerRaw is not None:
|
|
361
|
+
partial.apptainerRunner = str(apptainerRunnerRaw)
|
|
362
|
+
|
|
363
|
+
dockerRunnerRaw = mapping.get("docker_runner", mapping.get("dockerRunner"))
|
|
364
|
+
if dockerRunnerRaw is not None:
|
|
365
|
+
partial.dockerRunner = str(dockerRunnerRaw)
|
|
366
|
+
|
|
367
|
+
verboseRaw = mapping.get("verbose")
|
|
368
|
+
if verboseRaw is not None:
|
|
369
|
+
if isinstance(verboseRaw, bool):
|
|
370
|
+
partial.verbose = verboseRaw
|
|
371
|
+
elif isinstance(verboseRaw, str):
|
|
372
|
+
partial.verbose = parseBool(verboseRaw, variableName="verbose")
|
|
373
|
+
else:
|
|
374
|
+
raise ConfigError("Config value 'verbose' must be a boolean or string")
|
|
375
|
+
|
|
376
|
+
apptainerFlagsRaw = mapping.get("apptainer_flags", mapping.get("apptainerFlags"))
|
|
377
|
+
if apptainerFlagsRaw is not None:
|
|
378
|
+
partial.apptainerFlags = parseFlagValue(
|
|
379
|
+
apptainerFlagsRaw,
|
|
380
|
+
variableName="apptainer_flags",
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
dockerFlagsRaw = mapping.get("docker_flags", mapping.get("dockerFlags"))
|
|
384
|
+
if dockerFlagsRaw is not None:
|
|
385
|
+
partial.dockerFlags = parseFlagValue(
|
|
386
|
+
dockerFlagsRaw,
|
|
387
|
+
variableName="docker_flags",
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
envRaw = mapping.get("env")
|
|
391
|
+
if envRaw is not None:
|
|
392
|
+
if not isinstance(envRaw, Mapping):
|
|
393
|
+
raise ConfigError("Config value 'env' must be a TOML table")
|
|
394
|
+
partial.env = {str(key): str(value) for key, value in envRaw.items()}
|
|
395
|
+
|
|
396
|
+
return partial
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def extractConfigTable(parsed: Any) -> Mapping[str, Any]:
|
|
400
|
+
if not isinstance(parsed, Mapping):
|
|
401
|
+
raise ConfigError("Top-level TOML data must be a table")
|
|
402
|
+
|
|
403
|
+
if "kub_cli" in parsed and isinstance(parsed["kub_cli"], Mapping):
|
|
404
|
+
return parsed["kub_cli"]
|
|
405
|
+
|
|
406
|
+
if "kub-cli" in parsed and isinstance(parsed["kub-cli"], Mapping):
|
|
407
|
+
return parsed["kub-cli"]
|
|
408
|
+
|
|
409
|
+
return parsed
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def parseRuntime(rawValue: Any, *, variableName: str) -> str:
|
|
413
|
+
if not isinstance(rawValue, str):
|
|
414
|
+
raise ConfigError(
|
|
415
|
+
f"Invalid runtime value for '{variableName}': expected string, "
|
|
416
|
+
f"received {type(rawValue)!r}"
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
normalized = rawValue.strip().lower()
|
|
420
|
+
if normalized in SUPPORTED_RUNTIMES:
|
|
421
|
+
return normalized
|
|
422
|
+
|
|
423
|
+
supported = ", ".join(sorted(SUPPORTED_RUNTIMES))
|
|
424
|
+
raise ConfigError(
|
|
425
|
+
f"Invalid runtime value for '{variableName}': '{rawValue}'. "
|
|
426
|
+
f"Use one of: {supported}"
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def parseLegacyImageValue(value: Any, *, baseDir: Path) -> str:
|
|
431
|
+
text = parseStringValue(value, variableName="image")
|
|
432
|
+
if looksLikeContainerReference(text):
|
|
433
|
+
return text
|
|
434
|
+
return normalizePathString(text, baseDir=baseDir)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def parseDockerImageValue(value: Any) -> str:
|
|
438
|
+
return parseStringValue(value, variableName="image.docker")
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def parseApptainerImageValue(value: Any, *, baseDir: Path) -> str:
|
|
442
|
+
text = parseStringValue(value, variableName="image.apptainer")
|
|
443
|
+
if hasUriScheme(text):
|
|
444
|
+
return text
|
|
445
|
+
if looksLikeContainerReference(text):
|
|
446
|
+
return f"oras://{text}"
|
|
447
|
+
return normalizePathString(text, baseDir=baseDir)
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def parseStringValue(value: Any, *, variableName: str) -> str:
|
|
451
|
+
if not isinstance(value, (str, Path)):
|
|
452
|
+
raise ConfigError(
|
|
453
|
+
f"Config value '{variableName}' must be a string, "
|
|
454
|
+
f"received {type(value)!r}"
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
normalized = str(value).strip()
|
|
458
|
+
if not normalized:
|
|
459
|
+
raise ConfigError(f"Config value '{variableName}' cannot be empty")
|
|
460
|
+
|
|
461
|
+
return normalized
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def normalizePathString(rawValue: str, *, baseDir: Path) -> str:
|
|
465
|
+
pathValue = Path(rawValue).expanduser()
|
|
466
|
+
if pathValue.is_absolute():
|
|
467
|
+
return str(pathValue)
|
|
468
|
+
return str((baseDir / pathValue).resolve())
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def looksLikeContainerReference(value: str) -> bool:
|
|
472
|
+
if hasUriScheme(value):
|
|
473
|
+
return True
|
|
474
|
+
|
|
475
|
+
if "@" in value:
|
|
476
|
+
return True
|
|
477
|
+
|
|
478
|
+
if value.startswith(("./", "../", "/", "~")):
|
|
479
|
+
return False
|
|
480
|
+
|
|
481
|
+
firstSegment = value.split("/", maxsplit=1)[0]
|
|
482
|
+
if "." in firstSegment or ":" in firstSegment:
|
|
483
|
+
return True
|
|
484
|
+
|
|
485
|
+
tagPattern = re.compile(r"^[a-z0-9][a-z0-9._/-]*:[A-Za-z0-9._-]+$")
|
|
486
|
+
return bool(tagPattern.match(value))
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def hasUriScheme(value: str) -> bool:
|
|
490
|
+
return "://" in value
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def parseBindValue(value: Any) -> list[str]:
|
|
494
|
+
if isinstance(value, str):
|
|
495
|
+
return parseDelimitedList(value)
|
|
496
|
+
|
|
497
|
+
if isinstance(value, list):
|
|
498
|
+
binds: list[str] = []
|
|
499
|
+
for item in value:
|
|
500
|
+
if not isinstance(item, str):
|
|
501
|
+
raise ConfigError("Bind entries must be strings")
|
|
502
|
+
binds.extend(parseDelimitedList(item))
|
|
503
|
+
return binds
|
|
504
|
+
|
|
505
|
+
raise ConfigError("Bind value must be a string or list of strings")
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def parseFlagValue(value: Any, *, variableName: str) -> list[str]:
|
|
509
|
+
if isinstance(value, str):
|
|
510
|
+
return shlex.split(value)
|
|
511
|
+
|
|
512
|
+
if isinstance(value, list):
|
|
513
|
+
flags: list[str] = []
|
|
514
|
+
for item in value:
|
|
515
|
+
if not isinstance(item, str):
|
|
516
|
+
raise ConfigError(f"{variableName} entries must be strings")
|
|
517
|
+
flags.append(item)
|
|
518
|
+
return flags
|
|
519
|
+
|
|
520
|
+
raise ConfigError(f"{variableName} must be a string or list of strings")
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def parseDelimitedList(rawValue: str) -> list[str]:
|
|
524
|
+
normalized = rawValue.replace(";", "\n").replace(",", "\n")
|
|
525
|
+
return [token.strip() for token in normalized.splitlines() if token.strip()]
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def parseBool(rawValue: str, *, variableName: str) -> bool:
|
|
529
|
+
lowered = rawValue.strip().lower()
|
|
530
|
+
truthy = {"1", "true", "yes", "on"}
|
|
531
|
+
falsy = {"0", "false", "no", "off"}
|
|
532
|
+
|
|
533
|
+
if lowered in truthy:
|
|
534
|
+
return True
|
|
535
|
+
|
|
536
|
+
if lowered in falsy:
|
|
537
|
+
return False
|
|
538
|
+
|
|
539
|
+
raise ConfigError(
|
|
540
|
+
f"Invalid boolean value for '{variableName}': '{rawValue}'. "
|
|
541
|
+
"Use one of: true/false, 1/0, yes/no, on/off"
|
|
542
|
+
)
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def uniqueInOrder(values: list[str]) -> list[str]:
|
|
546
|
+
seen: set[str] = set()
|
|
547
|
+
uniqueValues: list[str] = []
|
|
548
|
+
|
|
549
|
+
for value in values:
|
|
550
|
+
if value in seen:
|
|
551
|
+
continue
|
|
552
|
+
seen.add(value)
|
|
553
|
+
uniqueValues.append(value)
|
|
554
|
+
|
|
555
|
+
return uniqueValues
|
kub_cli/errors.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
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
|
+
"""kub-cli exception hierarchy."""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class KubCliError(RuntimeError):
|
|
12
|
+
"""Base class for user-facing CLI errors."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, message: str, *, exit_code: int = 2) -> None:
|
|
15
|
+
super().__init__(message)
|
|
16
|
+
self.exit_code = exit_code
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ConfigError(KubCliError):
|
|
20
|
+
"""Raised when configuration cannot be parsed or validated."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class RunnerNotFoundError(KubCliError):
|
|
24
|
+
"""Raised when the Apptainer executable cannot be located."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ImageNotFoundError(KubCliError):
|
|
28
|
+
"""Raised when the image path is missing or invalid."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class RuntimeSelectionError(KubCliError):
|
|
32
|
+
"""Raised when the configured runtime cannot be resolved."""
|