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/runtime.py
ADDED
|
@@ -0,0 +1,463 @@
|
|
|
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
|
+
"""Container runtime resolution, command construction, and process execution."""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
import os
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
import shutil
|
|
14
|
+
import subprocess
|
|
15
|
+
from typing import Literal, Sequence
|
|
16
|
+
|
|
17
|
+
from .config import (
|
|
18
|
+
DEFAULT_APPTAINER_IMAGE,
|
|
19
|
+
DEFAULT_DOCKER_IMAGE,
|
|
20
|
+
KubConfig,
|
|
21
|
+
SUPPORTED_RUNTIMES,
|
|
22
|
+
looksLikeContainerReference,
|
|
23
|
+
)
|
|
24
|
+
from .errors import ImageNotFoundError, KubCliError, RunnerNotFoundError, RuntimeSelectionError
|
|
25
|
+
from .logging_utils import LOGGER, formatCommand
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
ResolvedRuntime = Literal["apptainer", "docker"]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class RuntimeResolution:
|
|
33
|
+
"""Concrete runtime and image selected for command execution."""
|
|
34
|
+
|
|
35
|
+
runtime: ResolvedRuntime
|
|
36
|
+
runnerPath: str
|
|
37
|
+
imageReference: str
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def deriveApptainerOrasReference(dockerImageReference: str) -> str:
|
|
41
|
+
"""Derive Apptainer ORAS source from a Docker image reference.
|
|
42
|
+
|
|
43
|
+
Example:
|
|
44
|
+
`ghcr.io/org/app:master` -> `oras://ghcr.io/org/app:master-sif`
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
normalized = dockerImageReference.strip()
|
|
48
|
+
if not normalized:
|
|
49
|
+
raise KubCliError("Docker image reference cannot be empty.")
|
|
50
|
+
|
|
51
|
+
if "@" in normalized:
|
|
52
|
+
raise KubCliError(
|
|
53
|
+
"Cannot derive Apptainer ORAS reference from digest-based Docker image. "
|
|
54
|
+
"Provide a tag-based Docker image reference."
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
if normalized.startswith("oras://"):
|
|
58
|
+
return normalized
|
|
59
|
+
|
|
60
|
+
if "://" in normalized:
|
|
61
|
+
raise KubCliError(
|
|
62
|
+
"Docker image reference for ORAS derivation must not include a URI scheme. "
|
|
63
|
+
"Use format like ghcr.io/org/image:tag"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
lastSlash = normalized.rfind("/")
|
|
67
|
+
lastColon = normalized.rfind(":")
|
|
68
|
+
|
|
69
|
+
if lastColon > lastSlash:
|
|
70
|
+
repository = normalized[:lastColon]
|
|
71
|
+
tag = normalized[lastColon + 1 :]
|
|
72
|
+
else:
|
|
73
|
+
repository = normalized
|
|
74
|
+
tag = "latest"
|
|
75
|
+
|
|
76
|
+
if not repository or not tag:
|
|
77
|
+
raise KubCliError(
|
|
78
|
+
"Invalid Docker image reference for ORAS derivation. "
|
|
79
|
+
"Expected format like ghcr.io/org/image:tag"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
return f"oras://{repository}:{tag}-sif"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def getRuntimeCandidateImage(config: KubConfig, runtime: ResolvedRuntime) -> str | None:
|
|
86
|
+
"""Resolve configured image by runtime-specific precedence."""
|
|
87
|
+
|
|
88
|
+
if runtime == "docker":
|
|
89
|
+
candidates = [
|
|
90
|
+
config.imageOverride,
|
|
91
|
+
config.imageDocker,
|
|
92
|
+
config.image,
|
|
93
|
+
DEFAULT_DOCKER_IMAGE,
|
|
94
|
+
]
|
|
95
|
+
else:
|
|
96
|
+
candidates = [
|
|
97
|
+
config.imageOverride,
|
|
98
|
+
config.imageApptainer,
|
|
99
|
+
config.image,
|
|
100
|
+
DEFAULT_APPTAINER_IMAGE,
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
for candidate in candidates:
|
|
104
|
+
if candidate is None:
|
|
105
|
+
continue
|
|
106
|
+
normalized = candidate.strip()
|
|
107
|
+
if normalized:
|
|
108
|
+
return normalized
|
|
109
|
+
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def getRunnerValue(config: KubConfig, runtime: ResolvedRuntime) -> str:
|
|
114
|
+
"""Resolve configured runner name/path by runtime."""
|
|
115
|
+
|
|
116
|
+
if config.runner is not None and config.runner.strip():
|
|
117
|
+
return config.runner.strip()
|
|
118
|
+
|
|
119
|
+
if runtime == "apptainer":
|
|
120
|
+
return config.apptainerRunner.strip()
|
|
121
|
+
|
|
122
|
+
return config.dockerRunner.strip()
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def resolveRunnerExecutable(runnerValue: str, *, runtimeName: str) -> str:
|
|
126
|
+
"""Resolve runner executable path and validate it is runnable."""
|
|
127
|
+
|
|
128
|
+
normalized = runnerValue.strip()
|
|
129
|
+
if not normalized:
|
|
130
|
+
raise RunnerNotFoundError(
|
|
131
|
+
f"{runtimeName.capitalize()} runner is empty. Set --runner or runtime-specific runner config."
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
runnerPath = Path(normalized).expanduser()
|
|
135
|
+
hasPathSeparator = runnerPath.parent != Path(".")
|
|
136
|
+
|
|
137
|
+
if runnerPath.is_absolute() or hasPathSeparator:
|
|
138
|
+
if runnerPath.exists() and os.access(runnerPath, os.X_OK):
|
|
139
|
+
return str(runnerPath)
|
|
140
|
+
|
|
141
|
+
raise RunnerNotFoundError(
|
|
142
|
+
f"{runtimeName.capitalize()} runner not executable: '{runnerPath}'."
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
resolvedRunner = shutil.which(normalized)
|
|
146
|
+
if resolvedRunner is None:
|
|
147
|
+
if runtimeName == "apptainer":
|
|
148
|
+
installHint = (
|
|
149
|
+
"Install Apptainer: "
|
|
150
|
+
"https://apptainer.org/docs/admin/main/installation.html"
|
|
151
|
+
)
|
|
152
|
+
elif runtimeName == "docker":
|
|
153
|
+
installHint = (
|
|
154
|
+
"Install Docker Engine: "
|
|
155
|
+
"https://docs.docker.com/engine/install/"
|
|
156
|
+
)
|
|
157
|
+
else:
|
|
158
|
+
installHint = "Install the selected runtime executable."
|
|
159
|
+
|
|
160
|
+
raise RunnerNotFoundError(
|
|
161
|
+
f"Unable to find {runtimeName} runner in PATH. "
|
|
162
|
+
f"Set --runner/KUB_APP_RUNNER or install it. {installHint}"
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
return resolvedRunner
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def tryResolveRunnerExecutable(runnerValue: str) -> str | None:
|
|
169
|
+
"""Try to resolve runner executable, returning None on failure."""
|
|
170
|
+
|
|
171
|
+
normalized = runnerValue.strip()
|
|
172
|
+
if not normalized:
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
runnerPath = Path(normalized).expanduser()
|
|
176
|
+
hasPathSeparator = runnerPath.parent != Path(".")
|
|
177
|
+
|
|
178
|
+
if runnerPath.is_absolute() or hasPathSeparator:
|
|
179
|
+
if runnerPath.exists() and os.access(runnerPath, os.X_OK):
|
|
180
|
+
return str(runnerPath)
|
|
181
|
+
return None
|
|
182
|
+
|
|
183
|
+
return shutil.which(normalized)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def resolveApptainerExecutionImage(config: KubConfig) -> str:
|
|
187
|
+
"""Resolve Apptainer execution image reference (local path or oras:// URI)."""
|
|
188
|
+
|
|
189
|
+
imageReference = getRuntimeCandidateImage(config, "apptainer")
|
|
190
|
+
if imageReference is None:
|
|
191
|
+
raise ImageNotFoundError(
|
|
192
|
+
"No Apptainer image configured for runtime 'apptainer'. "
|
|
193
|
+
"Set --image, KUB_IMAGE_APPTAINER, or KUB_IMAGE."
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
normalizedReference = imageReference.strip()
|
|
197
|
+
|
|
198
|
+
if normalizedReference.startswith("docker://"):
|
|
199
|
+
raise ImageNotFoundError(
|
|
200
|
+
"Apptainer image reference must use oras:// (or a local .sif path), "
|
|
201
|
+
"not docker://."
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
if normalizedReference.startswith("oras://"):
|
|
205
|
+
return normalizedReference
|
|
206
|
+
|
|
207
|
+
if "://" in normalizedReference:
|
|
208
|
+
raise ImageNotFoundError(
|
|
209
|
+
"Unsupported Apptainer image URI scheme. "
|
|
210
|
+
"Use oras://<registry>/<image>:<tag>-sif or a local .sif path."
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
if looksLikeContainerReference(normalizedReference):
|
|
214
|
+
return f"oras://{normalizedReference}"
|
|
215
|
+
|
|
216
|
+
imagePath = Path(normalizedReference).expanduser()
|
|
217
|
+
|
|
218
|
+
if not imagePath.exists():
|
|
219
|
+
raise ImageNotFoundError(f"Container image not found: '{imagePath}'.")
|
|
220
|
+
|
|
221
|
+
if imagePath.is_dir():
|
|
222
|
+
raise ImageNotFoundError(
|
|
223
|
+
f"Container image must be a file, got directory: '{imagePath}'."
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
return str(imagePath)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def resolveDockerExecutionImage(config: KubConfig) -> str:
|
|
230
|
+
"""Resolve Docker image reference used at runtime execution."""
|
|
231
|
+
|
|
232
|
+
imageReference = getRuntimeCandidateImage(config, "docker")
|
|
233
|
+
if imageReference is None:
|
|
234
|
+
raise ImageNotFoundError(
|
|
235
|
+
"No Docker image configured for runtime 'docker'. "
|
|
236
|
+
"Set --image, KUB_IMAGE_DOCKER, or KUB_IMAGE."
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
return imageReference
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def resolveRuntimeForExecution(config: KubConfig) -> RuntimeResolution:
|
|
243
|
+
"""Resolve runtime backend, executable, and image for application execution."""
|
|
244
|
+
|
|
245
|
+
configuredRuntime = config.runtime.strip().lower()
|
|
246
|
+
if configuredRuntime not in SUPPORTED_RUNTIMES:
|
|
247
|
+
supported = ", ".join(sorted(SUPPORTED_RUNTIMES))
|
|
248
|
+
raise RuntimeSelectionError(
|
|
249
|
+
f"Invalid runtime value '{config.runtime}'. Use one of: {supported}."
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
if configuredRuntime == "apptainer":
|
|
253
|
+
return resolveApptainerRuntime(config)
|
|
254
|
+
|
|
255
|
+
if configuredRuntime == "docker":
|
|
256
|
+
return resolveDockerRuntime(config)
|
|
257
|
+
|
|
258
|
+
return resolveAutoRuntime(config)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def resolveApptainerRuntime(config: KubConfig) -> RuntimeResolution:
|
|
262
|
+
runnerValue = getRunnerValue(config, "apptainer")
|
|
263
|
+
runnerPath = resolveRunnerExecutable(runnerValue, runtimeName="apptainer")
|
|
264
|
+
imageReference = resolveApptainerExecutionImage(config)
|
|
265
|
+
return RuntimeResolution(
|
|
266
|
+
runtime="apptainer",
|
|
267
|
+
runnerPath=runnerPath,
|
|
268
|
+
imageReference=imageReference,
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def resolveDockerRuntime(config: KubConfig) -> RuntimeResolution:
|
|
273
|
+
runnerValue = getRunnerValue(config, "docker")
|
|
274
|
+
runnerPath = resolveRunnerExecutable(runnerValue, runtimeName="docker")
|
|
275
|
+
imageReference = resolveDockerExecutionImage(config)
|
|
276
|
+
return RuntimeResolution(
|
|
277
|
+
runtime="docker",
|
|
278
|
+
runnerPath=runnerPath,
|
|
279
|
+
imageReference=imageReference,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def resolveAutoRuntime(config: KubConfig) -> RuntimeResolution:
|
|
284
|
+
"""Resolve runtime in auto mode using preference order.
|
|
285
|
+
|
|
286
|
+
Policy:
|
|
287
|
+
1. Apptainer if configured and available.
|
|
288
|
+
2. Docker if configured and available.
|
|
289
|
+
3. Fail with actionable diagnostics.
|
|
290
|
+
"""
|
|
291
|
+
|
|
292
|
+
diagnostics: list[str] = []
|
|
293
|
+
|
|
294
|
+
apptainerImage = getRuntimeCandidateImage(config, "apptainer")
|
|
295
|
+
if apptainerImage is not None:
|
|
296
|
+
apptainerRunnerValue = getRunnerValue(config, "apptainer")
|
|
297
|
+
apptainerRunner = tryResolveRunnerExecutable(apptainerRunnerValue)
|
|
298
|
+
if apptainerRunner is not None:
|
|
299
|
+
try:
|
|
300
|
+
imageReference = resolveApptainerExecutionImage(config)
|
|
301
|
+
return RuntimeResolution(
|
|
302
|
+
runtime="apptainer",
|
|
303
|
+
runnerPath=apptainerRunner,
|
|
304
|
+
imageReference=imageReference,
|
|
305
|
+
)
|
|
306
|
+
except KubCliError as error:
|
|
307
|
+
diagnostics.append(f"Apptainer not selected: {error}")
|
|
308
|
+
else:
|
|
309
|
+
diagnostics.append(
|
|
310
|
+
"Apptainer not selected: runner not available in PATH or not executable."
|
|
311
|
+
)
|
|
312
|
+
else:
|
|
313
|
+
diagnostics.append(
|
|
314
|
+
"Apptainer not selected: no Apptainer image configured."
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
dockerImage = getRuntimeCandidateImage(config, "docker")
|
|
318
|
+
if dockerImage is not None:
|
|
319
|
+
dockerRunnerValue = getRunnerValue(config, "docker")
|
|
320
|
+
dockerRunner = tryResolveRunnerExecutable(dockerRunnerValue)
|
|
321
|
+
if dockerRunner is not None:
|
|
322
|
+
return RuntimeResolution(
|
|
323
|
+
runtime="docker",
|
|
324
|
+
runnerPath=dockerRunner,
|
|
325
|
+
imageReference=dockerImage,
|
|
326
|
+
)
|
|
327
|
+
diagnostics.append(
|
|
328
|
+
"Docker not selected: runner not available in PATH or not executable."
|
|
329
|
+
)
|
|
330
|
+
else:
|
|
331
|
+
diagnostics.append(
|
|
332
|
+
"Docker not selected: no Docker image configured."
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
diagnosticText = " ".join(diagnostics)
|
|
336
|
+
raise RuntimeSelectionError(
|
|
337
|
+
"Unable to resolve runtime in auto mode. "
|
|
338
|
+
"Configure a valid runtime/image pair or set --runtime explicitly. "
|
|
339
|
+
"Install Apptainer (https://apptainer.org/docs/admin/main/installation.html) "
|
|
340
|
+
"or Docker Engine (https://docs.docker.com/engine/install/). "
|
|
341
|
+
f"Details: {diagnosticText}"
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
@dataclass(frozen=True)
|
|
346
|
+
class ApptainerCommandBuilder:
|
|
347
|
+
"""Build final `apptainer run` command lines for wrapped apps."""
|
|
348
|
+
|
|
349
|
+
appName: str
|
|
350
|
+
config: KubConfig
|
|
351
|
+
|
|
352
|
+
def resolveRunner(self) -> str:
|
|
353
|
+
runnerValue = getRunnerValue(self.config, "apptainer")
|
|
354
|
+
return resolveRunnerExecutable(runnerValue, runtimeName="apptainer")
|
|
355
|
+
|
|
356
|
+
def resolveImage(self) -> str:
|
|
357
|
+
return resolveApptainerExecutionImage(self.config)
|
|
358
|
+
|
|
359
|
+
def build(self, forwardedArgs: Sequence[str]) -> list[str]:
|
|
360
|
+
runner = self.resolveRunner()
|
|
361
|
+
image = self.resolveImage()
|
|
362
|
+
|
|
363
|
+
command: list[str] = [runner, "run"]
|
|
364
|
+
|
|
365
|
+
if self.config.apptainerFlags:
|
|
366
|
+
command.extend(self.config.apptainerFlags)
|
|
367
|
+
|
|
368
|
+
for bindSpec in self.config.binds:
|
|
369
|
+
command.extend(["--bind", bindSpec])
|
|
370
|
+
|
|
371
|
+
if self.config.workdir:
|
|
372
|
+
command.extend(["--pwd", self.config.workdir])
|
|
373
|
+
|
|
374
|
+
command.extend(["--app", self.appName, image])
|
|
375
|
+
command.extend(forwardedArgs)
|
|
376
|
+
|
|
377
|
+
return command
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
@dataclass(frozen=True)
|
|
381
|
+
class DockerCommandBuilder:
|
|
382
|
+
"""Build final `docker run` command lines for wrapped apps."""
|
|
383
|
+
|
|
384
|
+
appName: str
|
|
385
|
+
config: KubConfig
|
|
386
|
+
|
|
387
|
+
def resolveRunner(self) -> str:
|
|
388
|
+
runnerValue = getRunnerValue(self.config, "docker")
|
|
389
|
+
return resolveRunnerExecutable(runnerValue, runtimeName="docker")
|
|
390
|
+
|
|
391
|
+
def resolveImage(self) -> str:
|
|
392
|
+
return resolveDockerExecutionImage(self.config)
|
|
393
|
+
|
|
394
|
+
def build(self, forwardedArgs: Sequence[str]) -> list[str]:
|
|
395
|
+
runner = self.resolveRunner()
|
|
396
|
+
imageReference = self.resolveImage()
|
|
397
|
+
|
|
398
|
+
command: list[str] = [runner, "run", "--rm"]
|
|
399
|
+
|
|
400
|
+
if self.config.dockerFlags:
|
|
401
|
+
command.extend(self.config.dockerFlags)
|
|
402
|
+
|
|
403
|
+
for bindSpec in self.config.binds:
|
|
404
|
+
command.extend(["--volume", bindSpec])
|
|
405
|
+
|
|
406
|
+
if self.config.workdir:
|
|
407
|
+
command.extend(["--workdir", self.config.workdir])
|
|
408
|
+
|
|
409
|
+
for key, value in self.config.env.items():
|
|
410
|
+
command.extend(["--env", f"{key}={value}"])
|
|
411
|
+
|
|
412
|
+
command.append(imageReference)
|
|
413
|
+
command.append(self.appName)
|
|
414
|
+
command.extend(forwardedArgs)
|
|
415
|
+
|
|
416
|
+
return command
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
@dataclass(frozen=True)
|
|
420
|
+
class KubAppRunner:
|
|
421
|
+
"""Execute wrapped containerized apps using resolved configuration."""
|
|
422
|
+
|
|
423
|
+
config: KubConfig
|
|
424
|
+
|
|
425
|
+
def run(
|
|
426
|
+
self,
|
|
427
|
+
*,
|
|
428
|
+
appName: str,
|
|
429
|
+
forwardedArgs: Sequence[str],
|
|
430
|
+
dryRun: bool = False,
|
|
431
|
+
) -> int:
|
|
432
|
+
runtimeResolution = resolveRuntimeForExecution(self.config)
|
|
433
|
+
|
|
434
|
+
if runtimeResolution.runtime == "apptainer":
|
|
435
|
+
builder = ApptainerCommandBuilder(appName=appName, config=self.config)
|
|
436
|
+
else:
|
|
437
|
+
builder = DockerCommandBuilder(appName=appName, config=self.config)
|
|
438
|
+
|
|
439
|
+
command = builder.build(forwardedArgs)
|
|
440
|
+
|
|
441
|
+
if self.config.verbose:
|
|
442
|
+
LOGGER.debug(
|
|
443
|
+
"Resolved runtime=%s command: %s",
|
|
444
|
+
runtimeResolution.runtime,
|
|
445
|
+
formatCommand(command),
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
if dryRun:
|
|
449
|
+
print(formatCommand(command))
|
|
450
|
+
return 0
|
|
451
|
+
|
|
452
|
+
executionEnv = dict(os.environ)
|
|
453
|
+
if runtimeResolution.runtime == "apptainer":
|
|
454
|
+
executionEnv.update(self.config.env)
|
|
455
|
+
|
|
456
|
+
try:
|
|
457
|
+
completed = subprocess.run(command, check=False, env=executionEnv)
|
|
458
|
+
except KeyboardInterrupt:
|
|
459
|
+
return 130
|
|
460
|
+
except OSError as error:
|
|
461
|
+
raise KubCliError(f"Unable to execute runtime command: {error}") from error
|
|
462
|
+
|
|
463
|
+
return completed.returncode
|
kub_cli/versioning.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
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
|
+
"""Version bump utilities for kub-cli development workflows."""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
import re
|
|
13
|
+
|
|
14
|
+
from .errors import KubCliError
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
SEMVER_PATTERN = re.compile(r"^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$")
|
|
18
|
+
PYPROJECT_VERSION_PATTERN = re.compile(
|
|
19
|
+
r"(^version\s*=\s*\")(\d+\.\d+\.\d+)(\"\s*$)",
|
|
20
|
+
re.MULTILINE,
|
|
21
|
+
)
|
|
22
|
+
INIT_FALLBACK_VERSION_PATTERN = re.compile(
|
|
23
|
+
r"(^\s*__version__\s*=\s*\")(\d+\.\d+\.\d+)(\"\s*$)",
|
|
24
|
+
re.MULTILINE,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class SemanticVersion:
|
|
30
|
+
"""Semantic version components."""
|
|
31
|
+
|
|
32
|
+
major: int
|
|
33
|
+
minor: int
|
|
34
|
+
patch: int
|
|
35
|
+
|
|
36
|
+
def toString(self) -> str:
|
|
37
|
+
return f"{self.major}.{self.minor}.{self.patch}"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class BumpResult:
|
|
42
|
+
"""Result of version bump planning/execution."""
|
|
43
|
+
|
|
44
|
+
oldVersion: str
|
|
45
|
+
newVersion: str
|
|
46
|
+
pyprojectPath: Path
|
|
47
|
+
initPath: Path
|
|
48
|
+
changed: bool
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def parseSemanticVersion(rawValue: str) -> SemanticVersion:
|
|
52
|
+
normalized = rawValue.strip()
|
|
53
|
+
match = SEMVER_PATTERN.match(normalized)
|
|
54
|
+
if match is None:
|
|
55
|
+
raise KubCliError(
|
|
56
|
+
f"Invalid version '{rawValue}'. Expected semantic version MAJOR.MINOR.PATCH."
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
return SemanticVersion(
|
|
60
|
+
major=int(match.group(1)),
|
|
61
|
+
minor=int(match.group(2)),
|
|
62
|
+
patch=int(match.group(3)),
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def bumpSemanticVersion(currentVersion: SemanticVersion, part: str) -> SemanticVersion:
|
|
67
|
+
normalizedPart = part.strip().lower()
|
|
68
|
+
|
|
69
|
+
if normalizedPart == "major":
|
|
70
|
+
return SemanticVersion(
|
|
71
|
+
major=currentVersion.major + 1,
|
|
72
|
+
minor=0,
|
|
73
|
+
patch=0,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
if normalizedPart == "minor":
|
|
77
|
+
return SemanticVersion(
|
|
78
|
+
major=currentVersion.major,
|
|
79
|
+
minor=currentVersion.minor + 1,
|
|
80
|
+
patch=0,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
if normalizedPart == "patch":
|
|
84
|
+
return SemanticVersion(
|
|
85
|
+
major=currentVersion.major,
|
|
86
|
+
minor=currentVersion.minor,
|
|
87
|
+
patch=currentVersion.patch + 1,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
raise KubCliError(
|
|
91
|
+
f"Invalid bump part '{part}'. Use one of: major, minor, patch."
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def bumpProjectVersion(
|
|
96
|
+
*,
|
|
97
|
+
projectRoot: Path,
|
|
98
|
+
part: str,
|
|
99
|
+
toVersion: str | None,
|
|
100
|
+
dryRun: bool,
|
|
101
|
+
) -> BumpResult:
|
|
102
|
+
pyprojectPath = projectRoot / "pyproject.toml"
|
|
103
|
+
initPath = projectRoot / "src" / "kub_cli" / "__init__.py"
|
|
104
|
+
|
|
105
|
+
oldVersion = readPyprojectVersion(pyprojectPath)
|
|
106
|
+
oldSemanticVersion = parseSemanticVersion(oldVersion)
|
|
107
|
+
|
|
108
|
+
if toVersion is not None:
|
|
109
|
+
newVersion = parseSemanticVersion(toVersion).toString()
|
|
110
|
+
else:
|
|
111
|
+
newVersion = bumpSemanticVersion(oldSemanticVersion, part).toString()
|
|
112
|
+
|
|
113
|
+
changed = oldVersion != newVersion
|
|
114
|
+
|
|
115
|
+
if not dryRun and changed:
|
|
116
|
+
replaceVersionInFile(
|
|
117
|
+
filePath=pyprojectPath,
|
|
118
|
+
pattern=PYPROJECT_VERSION_PATTERN,
|
|
119
|
+
newVersion=newVersion,
|
|
120
|
+
valueLabel="pyproject version",
|
|
121
|
+
)
|
|
122
|
+
replaceVersionInFile(
|
|
123
|
+
filePath=initPath,
|
|
124
|
+
pattern=INIT_FALLBACK_VERSION_PATTERN,
|
|
125
|
+
newVersion=newVersion,
|
|
126
|
+
valueLabel="__init__ fallback version",
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
return BumpResult(
|
|
130
|
+
oldVersion=oldVersion,
|
|
131
|
+
newVersion=newVersion,
|
|
132
|
+
pyprojectPath=pyprojectPath,
|
|
133
|
+
initPath=initPath,
|
|
134
|
+
changed=changed,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def readPyprojectVersion(pyprojectPath: Path) -> str:
|
|
139
|
+
if not pyprojectPath.exists():
|
|
140
|
+
raise KubCliError(f"pyproject.toml not found at '{pyprojectPath}'.")
|
|
141
|
+
|
|
142
|
+
content = pyprojectPath.read_text(encoding="utf-8")
|
|
143
|
+
match = PYPROJECT_VERSION_PATTERN.search(content)
|
|
144
|
+
|
|
145
|
+
if match is None:
|
|
146
|
+
raise KubCliError(
|
|
147
|
+
f"Unable to locate project version in '{pyprojectPath}'."
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
return match.group(2)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def replaceVersionInFile(
|
|
154
|
+
*,
|
|
155
|
+
filePath: Path,
|
|
156
|
+
pattern: re.Pattern[str],
|
|
157
|
+
newVersion: str,
|
|
158
|
+
valueLabel: str,
|
|
159
|
+
) -> None:
|
|
160
|
+
if not filePath.exists():
|
|
161
|
+
raise KubCliError(f"Expected file not found: '{filePath}'.")
|
|
162
|
+
|
|
163
|
+
content = filePath.read_text(encoding="utf-8")
|
|
164
|
+
|
|
165
|
+
def replacement(match: re.Match[str]) -> str:
|
|
166
|
+
return f"{match.group(1)}{newVersion}{match.group(3)}"
|
|
167
|
+
|
|
168
|
+
updatedContent, count = pattern.subn(replacement, content, count=1)
|
|
169
|
+
|
|
170
|
+
if count != 1:
|
|
171
|
+
raise KubCliError(
|
|
172
|
+
f"Unable to update {valueLabel} in '{filePath}'."
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
filePath.write_text(updatedContent, encoding="utf-8")
|