squarepeg 0.1.0__tar.gz

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.
Files changed (52) hide show
  1. squarepeg-0.1.0/PKG-INFO +52 -0
  2. squarepeg-0.1.0/README.md +38 -0
  3. squarepeg-0.1.0/pyproject.toml +41 -0
  4. squarepeg-0.1.0/setup.cfg +4 -0
  5. squarepeg-0.1.0/squarepeg/__init__.py +1 -0
  6. squarepeg-0.1.0/squarepeg/__main__.py +4 -0
  7. squarepeg-0.1.0/squarepeg/cli.py +345 -0
  8. squarepeg-0.1.0/squarepeg/config.py +190 -0
  9. squarepeg-0.1.0/squarepeg/dockerargs.py +60 -0
  10. squarepeg-0.1.0/squarepeg/env_interpolation.py +89 -0
  11. squarepeg-0.1.0/squarepeg/errors.py +34 -0
  12. squarepeg-0.1.0/squarepeg/k8s/__init__.py +0 -0
  13. squarepeg-0.1.0/squarepeg/k8s/apierrors.py +80 -0
  14. squarepeg-0.1.0/squarepeg/k8s/dryrun.py +47 -0
  15. squarepeg-0.1.0/squarepeg/k8s/events.py +19 -0
  16. squarepeg-0.1.0/squarepeg/k8s/logs.py +126 -0
  17. squarepeg-0.1.0/squarepeg/k8s/orphans.py +198 -0
  18. squarepeg-0.1.0/squarepeg/k8s/podstate.py +93 -0
  19. squarepeg-0.1.0/squarepeg/k8s/runner.py +233 -0
  20. squarepeg-0.1.0/squarepeg/k8s/session.py +79 -0
  21. squarepeg-0.1.0/squarepeg/labels.py +23 -0
  22. squarepeg-0.1.0/squarepeg/log.py +6 -0
  23. squarepeg-0.1.0/squarepeg/manifest.py +175 -0
  24. squarepeg-0.1.0/squarepeg/merge.py +81 -0
  25. squarepeg-0.1.0/squarepeg/naming.py +55 -0
  26. squarepeg-0.1.0/squarepeg/quantities.py +58 -0
  27. squarepeg-0.1.0/squarepeg/runspec.py +41 -0
  28. squarepeg-0.1.0/squarepeg/volumes.py +112 -0
  29. squarepeg-0.1.0/squarepeg.egg-info/PKG-INFO +52 -0
  30. squarepeg-0.1.0/squarepeg.egg-info/SOURCES.txt +50 -0
  31. squarepeg-0.1.0/squarepeg.egg-info/dependency_links.txt +1 -0
  32. squarepeg-0.1.0/squarepeg.egg-info/entry_points.txt +2 -0
  33. squarepeg-0.1.0/squarepeg.egg-info/requires.txt +8 -0
  34. squarepeg-0.1.0/squarepeg.egg-info/top_level.txt +1 -0
  35. squarepeg-0.1.0/tests/test_apierrors.py +43 -0
  36. squarepeg-0.1.0/tests/test_cli.py +283 -0
  37. squarepeg-0.1.0/tests/test_config.py +422 -0
  38. squarepeg-0.1.0/tests/test_dockerargs.py +53 -0
  39. squarepeg-0.1.0/tests/test_dryrun.py +137 -0
  40. squarepeg-0.1.0/tests/test_env_interpolation.py +137 -0
  41. squarepeg-0.1.0/tests/test_events.py +49 -0
  42. squarepeg-0.1.0/tests/test_integration.py +113 -0
  43. squarepeg-0.1.0/tests/test_logs.py +144 -0
  44. squarepeg-0.1.0/tests/test_manifest.py +223 -0
  45. squarepeg-0.1.0/tests/test_merge.py +147 -0
  46. squarepeg-0.1.0/tests/test_naming.py +47 -0
  47. squarepeg-0.1.0/tests/test_orphans.py +377 -0
  48. squarepeg-0.1.0/tests/test_podstate.py +143 -0
  49. squarepeg-0.1.0/tests/test_quantities.py +55 -0
  50. squarepeg-0.1.0/tests/test_runner.py +540 -0
  51. squarepeg-0.1.0/tests/test_session.py +121 -0
  52. squarepeg-0.1.0/tests/test_volumes.py +142 -0
@@ -0,0 +1,52 @@
1
+ Metadata-Version: 2.4
2
+ Name: squarepeg
3
+ Version: 0.1.0
4
+ Summary: Run a docker-run-style command as a Kubernetes Pod or Job, streaming its output like a local process
5
+ Requires-Python: >=3.11
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: click>=8.1
8
+ Requires-Dist: kubernetes>=31
9
+ Requires-Dist: PyYAML>=6
10
+ Provides-Extra: dev
11
+ Requires-Dist: pytest>=8; extra == "dev"
12
+ Requires-Dist: pytest-cov>=5; extra == "dev"
13
+ Requires-Dist: ruff>=0.4; extra == "dev"
14
+
15
+ # squarepeg
16
+
17
+ Run a `docker run`-style command as a Kubernetes Pod or Job, streaming its
18
+ output like a local process and exiting with the container's exit code.
19
+
20
+ ```
21
+ squarepeg run alpine echo "hello from a pod"
22
+ ```
23
+
24
+ ## Install
25
+
26
+ ```
27
+ pip install squarepeg
28
+ ```
29
+
30
+ ## Docs
31
+
32
+ Full documentation lives in the [wiki](https://github.com/BioWilko/squarepeg/wiki):
33
+
34
+ - [Getting Started](https://github.com/BioWilko/squarepeg/wiki/Getting-Started)
35
+ - [CLI Reference](https://github.com/BioWilko/squarepeg/wiki/CLI-Reference)
36
+ - [Configuration](https://github.com/BioWilko/squarepeg/wiki/Configuration)
37
+ - [Environment Variable Interpolation](https://github.com/BioWilko/squarepeg/wiki/Environment-Variable-Interpolation)
38
+ - [Docker Compatibility](https://github.com/BioWilko/squarepeg/wiki/Docker-Compatibility)
39
+ - [Execution Lifecycle](https://github.com/BioWilko/squarepeg/wiki/Execution-Lifecycle)
40
+ - [Troubleshooting](https://github.com/BioWilko/squarepeg/wiki/Troubleshooting)
41
+ - [Development](https://github.com/BioWilko/squarepeg/wiki/Development)
42
+
43
+ See also [`examples/config.yaml`](examples/config.yaml) for a fully worked
44
+ config file.
45
+
46
+ ## Development
47
+
48
+ ```
49
+ pytest # unit tests
50
+ pytest -m integration # integration tests, requires SQUAREPEG_INTEGRATION=1 and a kind cluster
51
+ ruff check . # lint
52
+ ```
@@ -0,0 +1,38 @@
1
+ # squarepeg
2
+
3
+ Run a `docker run`-style command as a Kubernetes Pod or Job, streaming its
4
+ output like a local process and exiting with the container's exit code.
5
+
6
+ ```
7
+ squarepeg run alpine echo "hello from a pod"
8
+ ```
9
+
10
+ ## Install
11
+
12
+ ```
13
+ pip install squarepeg
14
+ ```
15
+
16
+ ## Docs
17
+
18
+ Full documentation lives in the [wiki](https://github.com/BioWilko/squarepeg/wiki):
19
+
20
+ - [Getting Started](https://github.com/BioWilko/squarepeg/wiki/Getting-Started)
21
+ - [CLI Reference](https://github.com/BioWilko/squarepeg/wiki/CLI-Reference)
22
+ - [Configuration](https://github.com/BioWilko/squarepeg/wiki/Configuration)
23
+ - [Environment Variable Interpolation](https://github.com/BioWilko/squarepeg/wiki/Environment-Variable-Interpolation)
24
+ - [Docker Compatibility](https://github.com/BioWilko/squarepeg/wiki/Docker-Compatibility)
25
+ - [Execution Lifecycle](https://github.com/BioWilko/squarepeg/wiki/Execution-Lifecycle)
26
+ - [Troubleshooting](https://github.com/BioWilko/squarepeg/wiki/Troubleshooting)
27
+ - [Development](https://github.com/BioWilko/squarepeg/wiki/Development)
28
+
29
+ See also [`examples/config.yaml`](examples/config.yaml) for a fully worked
30
+ config file.
31
+
32
+ ## Development
33
+
34
+ ```
35
+ pytest # unit tests
36
+ pytest -m integration # integration tests, requires SQUAREPEG_INTEGRATION=1 and a kind cluster
37
+ ruff check . # lint
38
+ ```
@@ -0,0 +1,41 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "squarepeg"
7
+ version = "0.1.0"
8
+ description = "Run a docker-run-style command as a Kubernetes Pod or Job, streaming its output like a local process"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ dependencies = [
12
+ "click>=8.1",
13
+ "kubernetes>=31",
14
+ "PyYAML>=6",
15
+ ]
16
+
17
+ [project.optional-dependencies]
18
+ dev = [
19
+ "pytest>=8",
20
+ "pytest-cov>=5",
21
+ "ruff>=0.4",
22
+ ]
23
+
24
+ [project.scripts]
25
+ squarepeg = "squarepeg.cli:main"
26
+
27
+ [tool.setuptools.packages.find]
28
+ include = ["squarepeg*"]
29
+
30
+ [tool.ruff]
31
+ line-length = 120
32
+
33
+ [tool.ruff.lint]
34
+ select = ["E", "F", "I"]
35
+
36
+ [tool.pytest.ini_options]
37
+ testpaths = ["tests"]
38
+ markers = [
39
+ "integration: requires a reachable kubernetes cluster (opt in with SQUAREPEG_INTEGRATION=1)",
40
+ ]
41
+ addopts = "-m 'not integration'"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ from squarepeg.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,345 @@
1
+ import click
2
+ import yaml
3
+
4
+ from squarepeg import __version__
5
+ from squarepeg.config import coerce_bool, coerce_int, load_effective_config, select_profile
6
+ from squarepeg.dockerargs import check_supported_image, parse_env_entries, reject_unsupported
7
+ from squarepeg.errors import SquarepegError, UsageError
8
+ from squarepeg.k8s.dryrun import server_dry_run
9
+ from squarepeg.k8s.runner import run_manifest
10
+ from squarepeg.k8s.session import Session
11
+ from squarepeg.manifest import build_job, build_pod
12
+ from squarepeg.naming import generate_name, validate_rfc1123
13
+ from squarepeg.quantities import docker_cpus_to_k8s, docker_memory_to_k8s
14
+ from squarepeg.runspec import ResourceSpec, RunSpec
15
+ from squarepeg.volumes import auto_mounts_from_config, parse_volume_flag
16
+
17
+ _CONFIG_OPTIONS = [
18
+ click.option(
19
+ "--config", "config_paths", multiple=True, type=click.Path(), help="repeatable, layered left-to-right"
20
+ ),
21
+ click.option("--no-default-config", "no_default_config", is_flag=True),
22
+ click.option("--profile", "profile", default=None),
23
+ ]
24
+
25
+
26
+ def _add_config_options(cmd):
27
+ for option in _CONFIG_OPTIONS:
28
+ cmd = option(cmd)
29
+ return cmd
30
+
31
+
32
+ def _resolve_config(config_paths, no_default_config, profile):
33
+ effective, sources = load_effective_config(config_paths, no_default_config=no_default_config)
34
+ resolved = select_profile(effective, profile)
35
+ return resolved, sources
36
+
37
+
38
+ def _unsupported_callback(ctx, param, value):
39
+ if value:
40
+ reject_unsupported(param.opts[0])
41
+ return value
42
+
43
+
44
+ # flag names -> whether the flag takes a value (False) or is a boolean switch (True)
45
+ _UNSUPPORTED_GROUPS: list[tuple[list[str], bool]] = [
46
+ (["--network"], False),
47
+ (["--privileged"], True),
48
+ (["--gpus"], False),
49
+ (["-p", "--publish"], False),
50
+ (["-d", "--detach"], True),
51
+ (["--restart"], False),
52
+ (["-u", "--user"], False),
53
+ (["--mount"], False),
54
+ (["--cap-add"], False),
55
+ (["--cap-drop"], False),
56
+ (["--add-host"], False),
57
+ (["--link"], False),
58
+ (["--platform"], False),
59
+ (["--env-file"], False),
60
+ (["--hostname"], False),
61
+ (["--tmpfs"], False),
62
+ ]
63
+
64
+
65
+ def _add_unsupported_options(cmd):
66
+ for flag_names, is_flag in _UNSUPPORTED_GROUPS:
67
+ cmd = click.option(
68
+ *flag_names,
69
+ is_flag=is_flag,
70
+ hidden=True,
71
+ expose_value=False,
72
+ callback=_unsupported_callback,
73
+ )(cmd)
74
+ return cmd
75
+
76
+
77
+ @click.group()
78
+ @click.version_option(__version__, prog_name="squarepeg")
79
+ def cli():
80
+ """Run a docker-run-style command as a Kubernetes Pod or Job."""
81
+
82
+
83
+ @cli.command(
84
+ context_settings={"allow_interspersed_args": False, "ignore_unknown_options": True},
85
+ )
86
+ @click.option("-e", "--env", "env_entries", multiple=True, help="KEY=VALUE, repeatable")
87
+ @click.option("-v", "--volume", "volume_entries", multiple=True, help="[NAME|/host]:/container[:ro|rw], repeatable")
88
+ @click.option("--name", "name", default=None, help="Pod/Job name")
89
+ @click.option("-w", "--workdir", "workdir", default=None)
90
+ @click.option("--entrypoint", "entrypoint", default=None, help="Override the image ENTRYPOINT (-> k8s 'command')")
91
+ @click.option("-i", "--interactive", "interactive", is_flag=True, help="accepted; stdin is not forwarded")
92
+ @click.option("-t", "--tty", "tty", is_flag=True)
93
+ @click.option("--rm", "rm_flag", is_flag=True, help="accepted no-op; cleanup is already the default")
94
+ @click.option("--pull", "pull", type=click.Choice(["always", "missing", "never"]), default=None)
95
+ @click.option("--cpus", "cpus", default=None)
96
+ @click.option("-m", "--memory", "memory", default=None)
97
+ @click.option("--request-cpu", "request_cpu", default=None)
98
+ @click.option("--limit-cpu", "limit_cpu", default=None)
99
+ @click.option("--request-memory", "request_memory", default=None)
100
+ @click.option("--limit-memory", "limit_memory", default=None)
101
+ @click.option("--mode", "mode", type=click.Choice(["pod", "job"]), default=None)
102
+ @click.option("-n", "--namespace", "namespace", default=None)
103
+ @click.option("--keep", "keep", is_flag=True, help="do not delete the pod/job after it finishes")
104
+ @click.option("--dry-run", "dry_run", is_flag=True, help="render the manifest without creating it")
105
+ @click.option(
106
+ "--dry-run-server",
107
+ "dry_run_server",
108
+ is_flag=True,
109
+ help="like --dry-run, but additionally validates the manifest against the apiserver",
110
+ )
111
+ @click.option("--timeout", "timeout", type=int, default=None)
112
+ @click.option("--quiet", "quiet", is_flag=True)
113
+ @click.option("--context", "context", default=None, help="kubeconfig context to use")
114
+ @click.option("--container-name", "container_name", default="main")
115
+ @click.argument("image")
116
+ @click.argument("command", nargs=-1, type=click.UNPROCESSED)
117
+ @_add_config_options
118
+ @_add_unsupported_options
119
+ def run(
120
+ env_entries,
121
+ volume_entries,
122
+ name,
123
+ workdir,
124
+ entrypoint,
125
+ interactive,
126
+ tty,
127
+ rm_flag,
128
+ pull,
129
+ cpus,
130
+ memory,
131
+ request_cpu,
132
+ limit_cpu,
133
+ request_memory,
134
+ limit_memory,
135
+ mode,
136
+ namespace,
137
+ keep,
138
+ dry_run,
139
+ dry_run_server,
140
+ config_paths,
141
+ no_default_config,
142
+ profile,
143
+ timeout,
144
+ quiet,
145
+ context,
146
+ container_name,
147
+ image,
148
+ command,
149
+ ):
150
+ """Run IMAGE [COMMAND...] as a Kubernetes Pod or Job."""
151
+ check_supported_image(image)
152
+ if rm_flag and keep:
153
+ raise UsageError("--rm and --keep are mutually exclusive")
154
+ if dry_run and dry_run_server:
155
+ raise UsageError("--dry-run and --dry-run-server are mutually exclusive")
156
+
157
+ resolved_config, _sources = _resolve_config(config_paths, no_default_config, profile)
158
+ config_defaults = resolved_config.get("defaults") or {}
159
+ config_volumes = resolved_config.get("volumes") or {}
160
+ allow_host_path_mounts = coerce_bool(resolved_config.get("allow_host_path_mounts", False), "allow_host_path_mounts")
161
+
162
+ claims: set[str] = set()
163
+
164
+ env = {**config_defaults.get("env", {}), **parse_env_entries(env_entries)}
165
+ for key in parse_env_entries(env_entries):
166
+ claims.add(f"/spec/containers/[name={container_name}]/env/[name={key}]")
167
+
168
+ cli_volumes = [
169
+ parse_volume_flag(v, config_volumes=config_volumes, allow_host_path_mounts=allow_host_path_mounts)
170
+ for v in volume_entries
171
+ ]
172
+ for vol in cli_volumes:
173
+ claims.add(f"/spec/volumes/[name={vol.volume_name}]")
174
+ claims.add(f"/spec/containers/[name={container_name}]/volumeMounts/[name={vol.volume_name}]")
175
+
176
+ # config-declared auto-mounts (volumes.NAME with a mount_path) are not claimed -- they're
177
+ # config-driven, not CLI-driven, so kubernetes.spec passthrough in the same config can still
178
+ # override them, same as any other config default (e.g. defaults.cpus).
179
+ volumes = auto_mounts_from_config(config_volumes) + cli_volumes
180
+
181
+ resources = ResourceSpec()
182
+ cli_set_cpus, cli_set_memory = cpus is not None, memory is not None
183
+ if cpus is None and "cpus" in config_defaults:
184
+ cpus = config_defaults["cpus"]
185
+ if memory is None and "memory" in config_defaults:
186
+ memory = config_defaults["memory"]
187
+ if cpus is not None:
188
+ converted = docker_cpus_to_k8s(cpus)
189
+ resources.request_cpu = converted
190
+ resources.limit_cpu = converted
191
+ if memory is not None:
192
+ converted = docker_memory_to_k8s(memory)
193
+ resources.request_memory = converted
194
+ resources.limit_memory = converted
195
+ if request_cpu is not None:
196
+ resources.request_cpu = request_cpu
197
+ claims.add(f"/spec/containers/[name={container_name}]/resources/requests/cpu")
198
+ if limit_cpu is not None:
199
+ resources.limit_cpu = limit_cpu
200
+ claims.add(f"/spec/containers/[name={container_name}]/resources/limits/cpu")
201
+ if request_memory is not None:
202
+ resources.request_memory = request_memory
203
+ claims.add(f"/spec/containers/[name={container_name}]/resources/requests/memory")
204
+ if limit_memory is not None:
205
+ resources.limit_memory = limit_memory
206
+ claims.add(f"/spec/containers/[name={container_name}]/resources/limits/memory")
207
+ if cli_set_cpus and request_cpu is None:
208
+ claims.add(f"/spec/containers/[name={container_name}]/resources/requests/cpu")
209
+ if cli_set_cpus and limit_cpu is None:
210
+ claims.add(f"/spec/containers/[name={container_name}]/resources/limits/cpu")
211
+ if cli_set_memory and request_memory is None:
212
+ claims.add(f"/spec/containers/[name={container_name}]/resources/requests/memory")
213
+ if cli_set_memory and limit_memory is None:
214
+ claims.add(f"/spec/containers/[name={container_name}]/resources/limits/memory")
215
+
216
+ if name is not None:
217
+ validate_rfc1123(name, what="--name")
218
+ claims.add("/metadata/name")
219
+ else:
220
+ name = generate_name(image)
221
+
222
+ if namespace is not None:
223
+ claims.add("/metadata/namespace")
224
+ else:
225
+ namespace = resolved_config.get("namespace")
226
+
227
+ pull_policy = {"always": "Always", "missing": "IfNotPresent", "never": "Never"}.get(pull) if pull else None
228
+ if pull_policy is not None:
229
+ claims.add(f"/spec/containers/[name={container_name}]/imagePullPolicy")
230
+ elif "image_pull_policy" in config_defaults:
231
+ pull_policy = config_defaults["image_pull_policy"]
232
+
233
+ if workdir is not None:
234
+ claims.add(f"/spec/containers/[name={container_name}]/workingDir")
235
+ elif "workdir" in config_defaults:
236
+ workdir = config_defaults["workdir"]
237
+
238
+ mode = mode or resolved_config.get("mode", "pod")
239
+ timeout = timeout if timeout is not None else coerce_int(resolved_config.get("timeout", 300), "timeout")
240
+ quiet = quiet or coerce_bool(resolved_config.get("quiet", False), "quiet")
241
+ cleanup = False if keep else coerce_bool(resolved_config.get("cleanup", True), "cleanup")
242
+ orphan_sweep = coerce_bool(resolved_config.get("orphan_sweep", True), "orphan_sweep")
243
+ orphan_sweep_min_age = coerce_int(resolved_config.get("orphan_sweep_min_age", 300), "orphan_sweep_min_age")
244
+ if orphan_sweep_min_age < 0:
245
+ raise UsageError(f"'orphan_sweep_min_age' must not be negative, got {orphan_sweep_min_age}")
246
+
247
+ entrypoint_tuple = (entrypoint,) if entrypoint is not None else None
248
+ if entrypoint_tuple is not None:
249
+ claims.add(f"/spec/containers/[name={container_name}]/command")
250
+ if command:
251
+ claims.add(f"/spec/containers/[name={container_name}]/args")
252
+
253
+ if tty:
254
+ claims.add(f"/spec/containers/[name={container_name}]/tty")
255
+ if not interactive:
256
+ # some clusters reject tty=true with stdin=false; -i is accepted but never
257
+ # forwards real stdin, so this can hang a container that then blocks reading it.
258
+ click.echo(
259
+ "squarepeg: -t implies stdin is opened on the container (stdinOnce), but stdin is "
260
+ "never actually forwarded; a process that blocks reading stdin will hang. Pass -i "
261
+ "explicitly to acknowledge this.",
262
+ err=True,
263
+ )
264
+ interactive = True
265
+ if interactive:
266
+ claims.add(f"/spec/containers/[name={container_name}]/stdin")
267
+
268
+ spec = RunSpec(
269
+ image=image,
270
+ args=tuple(command),
271
+ entrypoint=entrypoint_tuple,
272
+ env=env,
273
+ workdir=workdir,
274
+ volumes=volumes,
275
+ tty=tty,
276
+ stdin=interactive,
277
+ pull_policy=pull_policy,
278
+ mode=mode,
279
+ namespace=namespace,
280
+ name=name,
281
+ container_name=container_name,
282
+ resources=resources,
283
+ cleanup=cleanup,
284
+ timeout=timeout,
285
+ quiet=quiet,
286
+ orphan_sweep=orphan_sweep,
287
+ orphan_sweep_min_age=orphan_sweep_min_age,
288
+ claims=claims,
289
+ )
290
+
291
+ kubernetes_passthrough = resolved_config.get("kubernetes")
292
+ job_passthrough = resolved_config.get("job")
293
+ if spec.mode == "job":
294
+ manifest = build_job(spec, kubernetes_passthrough, job_passthrough)
295
+ else:
296
+ manifest = build_pod(spec, kubernetes_passthrough)
297
+
298
+ if dry_run_server:
299
+ session = Session(namespace=spec.namespace, context=context, quiet=spec.quiet)
300
+ spec.namespace = session.namespace
301
+ validated = server_dry_run(session, spec, manifest)
302
+ click.echo(yaml.safe_dump(validated, sort_keys=False), nl=False)
303
+ return
304
+ if dry_run:
305
+ click.echo(yaml.safe_dump(manifest, sort_keys=False), nl=False)
306
+ return
307
+
308
+ session = Session(namespace=spec.namespace, context=context, quiet=spec.quiet)
309
+ spec.namespace = session.namespace
310
+ exit_code = run_manifest(session, spec, manifest)
311
+ raise SystemExit(exit_code)
312
+
313
+
314
+ @cli.group("config")
315
+ def config_group():
316
+ """Inspect the resolved config."""
317
+
318
+
319
+ @config_group.command("show")
320
+ @_add_config_options
321
+ def config_show(config_paths, no_default_config, profile):
322
+ """Print the merged effective config (stdout) and its sources (stderr)."""
323
+ resolved_config, sources = _resolve_config(config_paths, no_default_config, profile)
324
+ for path, origin in sources:
325
+ click.echo(f"[squarepeg] {path} ({origin})", err=True)
326
+ if not sources:
327
+ click.echo("[squarepeg] no config files loaded", err=True)
328
+ click.echo(yaml.safe_dump(resolved_config, sort_keys=False), nl=False)
329
+
330
+
331
+ def main():
332
+ try:
333
+ cli(standalone_mode=False)
334
+ except click.ClickException as exc:
335
+ exc.show()
336
+ raise SystemExit(exc.exit_code) from exc
337
+ except click.exceptions.Exit as exc:
338
+ raise SystemExit(exc.exit_code) from exc
339
+ except SquarepegError as exc:
340
+ click.echo(f"squarepeg: {exc}", err=True)
341
+ raise SystemExit(exc.exit_code) from exc
342
+
343
+
344
+ if __name__ == "__main__":
345
+ main()
@@ -0,0 +1,190 @@
1
+ """Nextflow-style layered config loading.
2
+
3
+ Resolution order, lowest to highest precedence:
4
+ 1. built-in defaults (not a file; handled by callers)
5
+ 2. the single default path ~/.config/squarepeg/config.yaml, if present
6
+ 3. $SQUAREPEG_CONFIG: a colon-separated list of paths, left-to-right
7
+ 4. each --config PATH given on the CLI, in order, left-to-right
8
+ 5. individual CLI flags (applied by callers, not here)
9
+
10
+ Every file in 2-4 is merged with the same key-level deep_merge used for k8s
11
+ passthrough (with an empty claim set) -- see merge.py for why whole-file
12
+ override would defeat the point of layering.
13
+ """
14
+
15
+ import os
16
+ from pathlib import Path
17
+
18
+ import yaml
19
+
20
+ from squarepeg.env_interpolation import interpolate_document
21
+ from squarepeg.errors import ConfigError
22
+ from squarepeg.merge import deep_merge
23
+
24
+ ENV_VAR = "SQUAREPEG_CONFIG"
25
+ DEFAULT_CONFIG_PATH = Path.home() / ".config" / "squarepeg" / "config.yaml"
26
+
27
+ ALLOWED_TOP_KEYS = {
28
+ "namespace",
29
+ "mode",
30
+ "cleanup",
31
+ "timeout",
32
+ "quiet",
33
+ "allow_host_path_mounts",
34
+ "split_streams",
35
+ "orphan_sweep",
36
+ "orphan_sweep_min_age",
37
+ "defaults",
38
+ "volumes",
39
+ "kubernetes",
40
+ "job",
41
+ "profiles",
42
+ }
43
+ ALLOWED_DEFAULTS_KEYS = {"cpus", "memory", "image_pull_policy", "workdir", "env"}
44
+
45
+
46
+ def _resolve_env_paths() -> list[tuple[Path, str]]:
47
+ raw = os.environ.get(ENV_VAR, "")
48
+ resolved = []
49
+ for segment in raw.split(":"):
50
+ if not segment:
51
+ continue
52
+ path = Path(segment).expanduser()
53
+ if not path.exists():
54
+ raise ConfigError(f"config file {path} (from ${ENV_VAR}) does not exist")
55
+ resolved.append((path, f"${ENV_VAR}"))
56
+ return resolved
57
+
58
+
59
+ def _resolve_cli_paths(cli_config_paths: tuple[str, ...]) -> list[tuple[Path, str]]:
60
+ resolved = []
61
+ for raw in cli_config_paths:
62
+ path = Path(raw).expanduser()
63
+ if not path.exists():
64
+ raise ConfigError(f"config file {path} (from --config {raw}) does not exist")
65
+ resolved.append((path, f"--config {raw}"))
66
+ return resolved
67
+
68
+
69
+ def resolve_config_sources(
70
+ cli_config_paths: tuple[str, ...] = (), *, no_default_config: bool = False
71
+ ) -> list[tuple[Path, str]]:
72
+ """Return [(path, origin_label)] in the order they should be merged, lowest precedence first."""
73
+ sources: list[tuple[Path, str]] = []
74
+ if not no_default_config and DEFAULT_CONFIG_PATH.exists():
75
+ sources.append((DEFAULT_CONFIG_PATH, "default location"))
76
+ sources.extend(_resolve_env_paths())
77
+ sources.extend(_resolve_cli_paths(cli_config_paths))
78
+ return sources
79
+
80
+
81
+ def load_yaml_document(path: Path) -> dict:
82
+ try:
83
+ text = path.read_text()
84
+ except OSError as exc:
85
+ raise ConfigError(f"cannot read config file {path}: {exc}") from exc
86
+ try:
87
+ data = yaml.safe_load(text)
88
+ except yaml.YAMLError as exc:
89
+ raise ConfigError(f"malformed YAML in {path}: {exc}") from exc
90
+ if data is None:
91
+ return {}
92
+ if not isinstance(data, dict):
93
+ raise ConfigError(f"config file {path} must be a YAML mapping at the top level")
94
+ return data
95
+
96
+
97
+ def validate_document(doc: dict, source: str, *, allow_profiles: bool = True) -> None:
98
+ for key in doc:
99
+ if key not in ALLOWED_TOP_KEYS:
100
+ raise ConfigError(f"unknown config key {key!r} in {source}")
101
+ defaults = doc.get("defaults")
102
+ if defaults:
103
+ for key in defaults:
104
+ if key not in ALLOWED_DEFAULTS_KEYS:
105
+ raise ConfigError(f"unknown key 'defaults.{key}' in {source}")
106
+ profiles = doc.get("profiles")
107
+ if profiles:
108
+ if not allow_profiles:
109
+ raise ConfigError(f"nested 'profiles' is not allowed inside a profile (in {source})")
110
+ for name, body in profiles.items():
111
+ if not isinstance(body, dict):
112
+ raise ConfigError(f"profile {name!r} in {source} must be a mapping")
113
+ validate_document(body, f"{source} (profile {name!r})", allow_profiles=False)
114
+ volumes = doc.get("volumes")
115
+ if volumes:
116
+ if not isinstance(volumes, dict):
117
+ raise ConfigError(f"'volumes' in {source} must be a mapping")
118
+ for name, entry in volumes.items():
119
+ if not isinstance(entry, dict):
120
+ raise ConfigError(f"volume {name!r} in {source} must be a mapping")
121
+ # squarepeg only validates its own keys here; the rest of the entry is an
122
+ # opaque k8s volume source (persistentVolumeClaim, emptyDir, ...) that the
123
+ # apiserver validates -- same exemption as the 'kubernetes'/'job' sections.
124
+ if "mount_path" in entry:
125
+ mount_path = entry["mount_path"]
126
+ if not isinstance(mount_path, str) or not mount_path.startswith("/"):
127
+ raise ConfigError(
128
+ f"volumes.{name}.mount_path in {source} must be an absolute container "
129
+ f"path (got {mount_path!r})"
130
+ )
131
+ if "read_only" in entry:
132
+ coerce_bool(entry["read_only"], f"volumes.{name}.read_only (in {source})")
133
+
134
+
135
+ def load_effective_config(
136
+ cli_config_paths: tuple[str, ...] = (), *, no_default_config: bool = False
137
+ ) -> tuple[dict, list[tuple[Path, str]]]:
138
+ """Load and merge every config layer. Returns (effective_config, sources)."""
139
+ sources = resolve_config_sources(cli_config_paths, no_default_config=no_default_config)
140
+ effective: dict = {}
141
+ for path, origin in sources:
142
+ source = f"{path} ({origin})"
143
+ doc = load_yaml_document(path)
144
+ doc = interpolate_document(doc, source)
145
+ validate_document(doc, source)
146
+ effective = deep_merge(effective, doc)
147
+ return effective, sources
148
+
149
+
150
+ _TRUE_STRINGS = {"true", "1", "yes", "on"}
151
+ _FALSE_STRINGS = {"false", "0", "no", "off"}
152
+
153
+
154
+ def coerce_int(value, key: str) -> int:
155
+ """Coerce a config value to int, accepting an int-looking string (e.g. from ${VAR} interpolation)."""
156
+ if isinstance(value, bool): # bool is an int subclass; reject it explicitly
157
+ raise ConfigError(f"expected an integer for {key!r}, got {value!r}")
158
+ if isinstance(value, int):
159
+ return value
160
+ if isinstance(value, str):
161
+ try:
162
+ return int(value.strip())
163
+ except ValueError:
164
+ pass
165
+ raise ConfigError(f"expected an integer for {key!r}, got {value!r}")
166
+
167
+
168
+ def coerce_bool(value, key: str) -> bool:
169
+ """Coerce a config value to bool, accepting a bool-looking string (e.g. from ${VAR} interpolation)."""
170
+ if isinstance(value, bool):
171
+ return value
172
+ if isinstance(value, str):
173
+ lowered = value.strip().lower()
174
+ if lowered in _TRUE_STRINGS:
175
+ return True
176
+ if lowered in _FALSE_STRINGS:
177
+ return False
178
+ raise ConfigError(f"expected a boolean for {key!r}, got {value!r}")
179
+
180
+
181
+ def select_profile(effective_config: dict, profile_name: str | None) -> dict:
182
+ """Fold the named profile over the rest of the effective config (L1+L2)."""
183
+ if profile_name is None:
184
+ return {k: v for k, v in effective_config.items() if k != "profiles"}
185
+ profiles = effective_config.get("profiles") or {}
186
+ if profile_name not in profiles:
187
+ available = ", ".join(sorted(profiles)) or "(none defined)"
188
+ raise ConfigError(f"unknown profile {profile_name!r}; available profiles: {available}")
189
+ base = {k: v for k, v in effective_config.items() if k != "profiles"}
190
+ return deep_merge(base, profiles[profile_name])