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