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 ADDED
@@ -0,0 +1,17 @@
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 package."""
7
+
8
+ from importlib.metadata import PackageNotFoundError, version
9
+
10
+
11
+ try:
12
+ __version__ = version("kub-cli")
13
+ except PackageNotFoundError:
14
+ __version__ = "0.2.0"
15
+
16
+
17
+ __all__ = ["__version__"]
kub_cli/__main__.py ADDED
@@ -0,0 +1,12 @@
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
+ """Module entrypoint for `python -m kub_cli`."""
7
+
8
+ from .cli import metaMain
9
+
10
+
11
+ if __name__ == "__main__":
12
+ metaMain()
kub_cli/cli.py ADDED
@@ -0,0 +1,292 @@
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
+ """Typer CLI entrypoints for kub-cli wrapper commands."""
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+ from typing import Sequence
12
+
13
+ import typer
14
+
15
+ from . import __version__
16
+ from .commands import WrapperOptions, runWrapperCommand
17
+ from .errors import KubCliError
18
+ from .versioning import bumpProjectVersion
19
+
20
+
21
+ CONTEXT_SETTINGS = {
22
+ "allow_extra_args": True,
23
+ "ignore_unknown_options": True,
24
+ }
25
+
26
+
27
+ def normalizeForwardedArgs(args: Sequence[str]) -> list[str]:
28
+ forwarded = list(args)
29
+ if forwarded and forwarded[0] == "--":
30
+ return forwarded[1:]
31
+ return forwarded
32
+
33
+
34
+ def executeWrapperCommand(
35
+ *,
36
+ appName: str,
37
+ ctx: typer.Context,
38
+ runtime: str | None,
39
+ image: str | None,
40
+ bind: Sequence[str],
41
+ pwd: str | None,
42
+ runner: str | None,
43
+ dryRun: bool,
44
+ verbose: bool | None,
45
+ apptainerFlags: Sequence[str],
46
+ dockerFlags: Sequence[str],
47
+ envVars: Sequence[str],
48
+ showConfig: bool,
49
+ ) -> None:
50
+ forwardedArgs = normalizeForwardedArgs(ctx.args)
51
+ options = WrapperOptions(
52
+ runtime=runtime,
53
+ image=image,
54
+ binds=tuple(bind),
55
+ pwd=pwd,
56
+ runner=runner,
57
+ dryRun=dryRun,
58
+ verbose=verbose,
59
+ apptainerFlags=tuple(apptainerFlags),
60
+ dockerFlags=tuple(dockerFlags),
61
+ envVars=tuple(envVars),
62
+ showConfig=showConfig,
63
+ )
64
+
65
+ try:
66
+ exitCode = runWrapperCommand(
67
+ appName=appName,
68
+ forwardedArgs=forwardedArgs,
69
+ options=options,
70
+ )
71
+ except KubCliError as error:
72
+ typer.secho(str(error), fg=typer.colors.RED, err=True)
73
+ raise typer.Exit(code=error.exit_code) from error
74
+
75
+ raise typer.Exit(code=exitCode)
76
+
77
+
78
+ def createWrapperApp(*, appName: str, helpText: str) -> typer.Typer:
79
+ app = typer.Typer(
80
+ add_completion=False,
81
+ no_args_is_help=False,
82
+ help=helpText,
83
+ )
84
+
85
+ @app.command(context_settings=CONTEXT_SETTINGS)
86
+ def wrapper(
87
+ ctx: typer.Context,
88
+ runtime: str | None = typer.Option(
89
+ None,
90
+ "--runtime",
91
+ metavar="{auto,apptainer,docker}",
92
+ help="Container runtime selection.",
93
+ ),
94
+ image: str | None = typer.Option(
95
+ None,
96
+ "--image",
97
+ metavar="IMAGE",
98
+ help="Runtime image reference/path (runtime-dependent).",
99
+ ),
100
+ bind: list[str] | None = typer.Option(
101
+ None,
102
+ "--bind",
103
+ metavar="SRC:DST",
104
+ help="Bind mount or volume mapping (repeatable).",
105
+ ),
106
+ pwd: str | None = typer.Option(
107
+ None,
108
+ "--pwd",
109
+ metavar="PATH",
110
+ help="Working directory passed to runtime command.",
111
+ ),
112
+ runner: str | None = typer.Option(
113
+ None,
114
+ "--runner",
115
+ metavar="PATH",
116
+ help="Runtime executable path or command name.",
117
+ ),
118
+ dryRun: bool = typer.Option(
119
+ False,
120
+ "--dry-run",
121
+ help="Print the resolved command without running it.",
122
+ ),
123
+ verbose: bool | None = typer.Option(
124
+ None,
125
+ "--verbose/--no-verbose",
126
+ help="Enable or disable verbose wrapper logs.",
127
+ ),
128
+ apptainerFlags: list[str] | None = typer.Option(
129
+ None,
130
+ "--apptainer-flag",
131
+ metavar="FLAG",
132
+ help="Extra Apptainer flag (repeatable).",
133
+ ),
134
+ dockerFlags: list[str] | None = typer.Option(
135
+ None,
136
+ "--docker-flag",
137
+ metavar="FLAG",
138
+ help="Extra Docker flag (repeatable).",
139
+ ),
140
+ envVars: list[str] | None = typer.Option(
141
+ None,
142
+ "--env",
143
+ metavar="KEY=VALUE",
144
+ help="Environment variable assignment for the process/container (repeatable).",
145
+ ),
146
+ showConfig: bool = typer.Option(
147
+ False,
148
+ "--show-config",
149
+ help="Print effective kub-cli configuration as JSON.",
150
+ ),
151
+ version: bool = typer.Option(
152
+ False,
153
+ "--version",
154
+ is_eager=True,
155
+ help="Show kub-cli version and exit.",
156
+ ),
157
+ ) -> None:
158
+ if version:
159
+ typer.echo(f"kub-cli {__version__}")
160
+ raise typer.Exit(code=0)
161
+
162
+ executeWrapperCommand(
163
+ appName=appName,
164
+ ctx=ctx,
165
+ runtime=runtime,
166
+ image=image,
167
+ bind=bind or [],
168
+ pwd=pwd,
169
+ runner=runner,
170
+ dryRun=dryRun,
171
+ verbose=verbose,
172
+ apptainerFlags=apptainerFlags or [],
173
+ dockerFlags=dockerFlags or [],
174
+ envVars=envVars or [],
175
+ showConfig=showConfig,
176
+ )
177
+
178
+ return app
179
+
180
+
181
+ def createMetaApp() -> typer.Typer:
182
+ app = typer.Typer(
183
+ add_completion=False,
184
+ no_args_is_help=False,
185
+ help="kub-cli meta command.",
186
+ )
187
+
188
+ @app.callback(invoke_without_command=True)
189
+ def meta(
190
+ ctx: typer.Context,
191
+ version: bool = typer.Option(
192
+ False,
193
+ "--version",
194
+ is_eager=True,
195
+ help="Show kub-cli version and exit.",
196
+ ),
197
+ ) -> None:
198
+ if version:
199
+ typer.echo(f"kub-cli {__version__}")
200
+ raise typer.Exit(code=0)
201
+
202
+ if ctx.invoked_subcommand is None:
203
+ typer.echo(
204
+ "kub-cli thin wrapper. Use: kub-dataset, kub-simulate, kub-dashboard, kub-img."
205
+ )
206
+
207
+ @app.command("bump")
208
+ def bumpCommand(
209
+ part: str = typer.Argument(
210
+ "patch",
211
+ metavar="PART",
212
+ help="Semantic version part to bump: major, minor, patch.",
213
+ ),
214
+ toVersion: str | None = typer.Option(
215
+ None,
216
+ "--to",
217
+ metavar="VERSION",
218
+ help="Set an explicit semantic version (MAJOR.MINOR.PATCH).",
219
+ ),
220
+ projectRoot: Path = typer.Option(
221
+ Path("."),
222
+ "--project-root",
223
+ metavar="PATH",
224
+ help="Project root containing pyproject.toml.",
225
+ ),
226
+ dryRun: bool = typer.Option(
227
+ False,
228
+ "--dry-run",
229
+ help="Print planned version changes without writing files.",
230
+ ),
231
+ ) -> None:
232
+ try:
233
+ result = bumpProjectVersion(
234
+ projectRoot=projectRoot.resolve(),
235
+ part=part,
236
+ toVersion=toVersion,
237
+ dryRun=dryRun,
238
+ )
239
+ except KubCliError as error:
240
+ typer.secho(str(error), fg=typer.colors.RED, err=True)
241
+ raise typer.Exit(code=error.exit_code) from error
242
+
243
+ if result.changed:
244
+ action = "Planned" if dryRun else "Updated"
245
+ typer.echo(f"{action} version: {result.oldVersion} -> {result.newVersion}")
246
+ else:
247
+ typer.echo(f"Version unchanged: {result.newVersion}")
248
+
249
+ typer.echo(f"pyproject: {result.pyprojectPath}")
250
+ typer.echo(f"fallback: {result.initPath}")
251
+
252
+ return app
253
+
254
+
255
+ datasetApp = createWrapperApp(
256
+ appName="kub-dataset",
257
+ helpText=(
258
+ "Run the kub-dataset app inside the configured container runtime "
259
+ "(Apptainer/Docker)."
260
+ ),
261
+ )
262
+ simulateApp = createWrapperApp(
263
+ appName="kub-simulate",
264
+ helpText=(
265
+ "Run the kub-simulate app inside the configured container runtime "
266
+ "(Apptainer/Docker)."
267
+ ),
268
+ )
269
+ dashboardApp = createWrapperApp(
270
+ appName="kub-dashboard",
271
+ helpText=(
272
+ "Run the kub-dashboard app inside the configured container runtime "
273
+ "(Apptainer/Docker)."
274
+ ),
275
+ )
276
+ metaApp = createMetaApp()
277
+
278
+
279
+ def datasetMain() -> None:
280
+ datasetApp(prog_name="kub-dataset")
281
+
282
+
283
+ def simulateMain() -> None:
284
+ simulateApp(prog_name="kub-simulate")
285
+
286
+
287
+ def dashboardMain() -> None:
288
+ dashboardApp(prog_name="kub-dashboard")
289
+
290
+
291
+ def metaMain() -> None:
292
+ metaApp(prog_name="kub-cli")
kub_cli/commands.py ADDED
@@ -0,0 +1,164 @@
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
+ """High-level command orchestration for kub-cli wrappers."""
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ import json
12
+ from pathlib import Path
13
+ from typing import Any, Mapping, Sequence
14
+
15
+ from .config import KubConfig, KubConfigOverrides, loadKubConfig
16
+ from .errors import ConfigError
17
+ from .img_integration import (
18
+ KubImgCommandRunner,
19
+ buildKubImgInfoRequest,
20
+ buildKubImgPullRequest,
21
+ )
22
+ from .logging_utils import configureLogging
23
+ from .runtime import KubAppRunner
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class WrapperOptions:
28
+ """Wrapper-specific options parsed from the CLI layer."""
29
+
30
+ runtime: str | None = None
31
+ image: str | None = None
32
+ binds: tuple[str, ...] = ()
33
+ pwd: str | None = None
34
+ runner: str | None = None
35
+ dryRun: bool = False
36
+ verbose: bool | None = None
37
+ apptainerFlags: tuple[str, ...] = ()
38
+ dockerFlags: tuple[str, ...] = ()
39
+ envVars: tuple[str, ...] = ()
40
+ showConfig: bool = False
41
+
42
+
43
+ def runWrapperCommand(
44
+ *,
45
+ appName: str,
46
+ forwardedArgs: Sequence[str],
47
+ options: WrapperOptions,
48
+ cwd: Path | None = None,
49
+ env: Mapping[str, str] | None = None,
50
+ userConfigPath: Path | None = None,
51
+ ) -> int:
52
+ """Resolve config and execute one wrapped in-container app."""
53
+
54
+ effectiveConfig = resolveEffectiveConfig(
55
+ options=options,
56
+ cwd=cwd,
57
+ env=env,
58
+ userConfigPath=userConfigPath,
59
+ )
60
+
61
+ configureLogging(effectiveConfig.verbose)
62
+
63
+ if options.showConfig:
64
+ print(json.dumps(effectiveConfig.toDict(), indent=2, sort_keys=True))
65
+ if not forwardedArgs and not options.dryRun:
66
+ return 0
67
+
68
+ runner = KubAppRunner(config=effectiveConfig)
69
+ return runner.run(appName=appName, forwardedArgs=forwardedArgs, dryRun=options.dryRun)
70
+
71
+
72
+ def pullSelectedRuntimeImage(
73
+ *,
74
+ options: WrapperOptions,
75
+ cwd: Path | None = None,
76
+ env: Mapping[str, str] | None = None,
77
+ userConfigPath: Path | None = None,
78
+ ) -> int:
79
+ """Invoke kub-img pull for the runtime/image selected by config precedence."""
80
+
81
+ effectiveConfig = resolveEffectiveConfig(
82
+ options=options,
83
+ cwd=cwd,
84
+ env=env,
85
+ userConfigPath=userConfigPath,
86
+ )
87
+ configureLogging(effectiveConfig.verbose)
88
+
89
+ request = buildKubImgPullRequest(effectiveConfig)
90
+ kubImgRunner = KubImgCommandRunner(verbose=effectiveConfig.verbose)
91
+ return kubImgRunner.pullImage(request, dryRun=options.dryRun)
92
+
93
+
94
+ def inspectSelectedRuntimeImage(
95
+ *,
96
+ options: WrapperOptions,
97
+ cwd: Path | None = None,
98
+ env: Mapping[str, str] | None = None,
99
+ userConfigPath: Path | None = None,
100
+ ) -> dict[str, Any]:
101
+ """Invoke kub-img info for the runtime/image selected by config precedence."""
102
+
103
+ effectiveConfig = resolveEffectiveConfig(
104
+ options=options,
105
+ cwd=cwd,
106
+ env=env,
107
+ userConfigPath=userConfigPath,
108
+ )
109
+ configureLogging(effectiveConfig.verbose)
110
+
111
+ request = buildKubImgInfoRequest(effectiveConfig)
112
+ kubImgRunner = KubImgCommandRunner(verbose=effectiveConfig.verbose)
113
+ return kubImgRunner.inspectImageInfo(request)
114
+
115
+
116
+ def resolveEffectiveConfig(
117
+ *,
118
+ options: WrapperOptions,
119
+ cwd: Path | None = None,
120
+ env: Mapping[str, str] | None = None,
121
+ userConfigPath: Path | None = None,
122
+ ) -> KubConfig:
123
+ """Build the effective config from defaults, files, env, then CLI overrides."""
124
+
125
+ overrideEnv = parseEnvAssignments(options.envVars)
126
+ overrides = KubConfigOverrides(
127
+ runtime=options.runtime,
128
+ image=options.image,
129
+ binds=options.binds,
130
+ workdir=options.pwd,
131
+ runner=options.runner,
132
+ verbose=options.verbose,
133
+ apptainerFlags=options.apptainerFlags,
134
+ dockerFlags=options.dockerFlags,
135
+ env=overrideEnv,
136
+ )
137
+
138
+ return loadKubConfig(
139
+ cwd=cwd,
140
+ env=env,
141
+ overrides=overrides,
142
+ userConfigPath=userConfigPath,
143
+ )
144
+
145
+
146
+ def parseEnvAssignments(assignments: Sequence[str]) -> dict[str, str]:
147
+ envMapping: dict[str, str] = {}
148
+
149
+ for entry in assignments:
150
+ if "=" not in entry:
151
+ raise ConfigError(
152
+ f"Invalid --env assignment '{entry}'. Expected KEY=VALUE syntax."
153
+ )
154
+
155
+ key, value = entry.split("=", maxsplit=1)
156
+ normalizedKey = key.strip()
157
+ if not normalizedKey:
158
+ raise ConfigError(
159
+ f"Invalid --env assignment '{entry}'. Environment key cannot be empty."
160
+ )
161
+
162
+ envMapping[normalizedKey] = value
163
+
164
+ return envMapping