squarepeg 0.1.0__tar.gz → 0.2.1__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 (56) hide show
  1. {squarepeg-0.1.0 → squarepeg-0.2.1}/PKG-INFO +10 -1
  2. {squarepeg-0.1.0 → squarepeg-0.2.1}/README.md +9 -0
  3. {squarepeg-0.1.0 → squarepeg-0.2.1}/pyproject.toml +1 -1
  4. squarepeg-0.2.1/squarepeg/__init__.py +6 -0
  5. {squarepeg-0.1.0 → squarepeg-0.2.1}/squarepeg/cli.py +162 -39
  6. {squarepeg-0.1.0 → squarepeg-0.2.1}/squarepeg/k8s/events.py +1 -1
  7. {squarepeg-0.1.0 → squarepeg-0.2.1}/squarepeg/k8s/logs.py +2 -1
  8. {squarepeg-0.1.0 → squarepeg-0.2.1}/squarepeg/k8s/orphans.py +2 -2
  9. {squarepeg-0.1.0 → squarepeg-0.2.1}/squarepeg/k8s/runner.py +89 -21
  10. squarepeg-0.2.1/squarepeg/log.py +5 -0
  11. squarepeg-0.2.1/squarepeg/ui.py +190 -0
  12. {squarepeg-0.1.0 → squarepeg-0.2.1}/squarepeg.egg-info/PKG-INFO +10 -1
  13. {squarepeg-0.1.0 → squarepeg-0.2.1}/squarepeg.egg-info/SOURCES.txt +2 -0
  14. {squarepeg-0.1.0 → squarepeg-0.2.1}/tests/test_cli.py +95 -9
  15. {squarepeg-0.1.0 → squarepeg-0.2.1}/tests/test_runner.py +27 -0
  16. squarepeg-0.2.1/tests/test_ui.py +110 -0
  17. squarepeg-0.1.0/squarepeg/__init__.py +0 -1
  18. squarepeg-0.1.0/squarepeg/log.py +0 -6
  19. {squarepeg-0.1.0 → squarepeg-0.2.1}/setup.cfg +0 -0
  20. {squarepeg-0.1.0 → squarepeg-0.2.1}/squarepeg/__main__.py +0 -0
  21. {squarepeg-0.1.0 → squarepeg-0.2.1}/squarepeg/config.py +0 -0
  22. {squarepeg-0.1.0 → squarepeg-0.2.1}/squarepeg/dockerargs.py +0 -0
  23. {squarepeg-0.1.0 → squarepeg-0.2.1}/squarepeg/env_interpolation.py +0 -0
  24. {squarepeg-0.1.0 → squarepeg-0.2.1}/squarepeg/errors.py +0 -0
  25. {squarepeg-0.1.0 → squarepeg-0.2.1}/squarepeg/k8s/__init__.py +0 -0
  26. {squarepeg-0.1.0 → squarepeg-0.2.1}/squarepeg/k8s/apierrors.py +0 -0
  27. {squarepeg-0.1.0 → squarepeg-0.2.1}/squarepeg/k8s/dryrun.py +0 -0
  28. {squarepeg-0.1.0 → squarepeg-0.2.1}/squarepeg/k8s/podstate.py +0 -0
  29. {squarepeg-0.1.0 → squarepeg-0.2.1}/squarepeg/k8s/session.py +0 -0
  30. {squarepeg-0.1.0 → squarepeg-0.2.1}/squarepeg/labels.py +0 -0
  31. {squarepeg-0.1.0 → squarepeg-0.2.1}/squarepeg/manifest.py +0 -0
  32. {squarepeg-0.1.0 → squarepeg-0.2.1}/squarepeg/merge.py +0 -0
  33. {squarepeg-0.1.0 → squarepeg-0.2.1}/squarepeg/naming.py +0 -0
  34. {squarepeg-0.1.0 → squarepeg-0.2.1}/squarepeg/quantities.py +0 -0
  35. {squarepeg-0.1.0 → squarepeg-0.2.1}/squarepeg/runspec.py +0 -0
  36. {squarepeg-0.1.0 → squarepeg-0.2.1}/squarepeg/volumes.py +0 -0
  37. {squarepeg-0.1.0 → squarepeg-0.2.1}/squarepeg.egg-info/dependency_links.txt +0 -0
  38. {squarepeg-0.1.0 → squarepeg-0.2.1}/squarepeg.egg-info/entry_points.txt +0 -0
  39. {squarepeg-0.1.0 → squarepeg-0.2.1}/squarepeg.egg-info/requires.txt +0 -0
  40. {squarepeg-0.1.0 → squarepeg-0.2.1}/squarepeg.egg-info/top_level.txt +0 -0
  41. {squarepeg-0.1.0 → squarepeg-0.2.1}/tests/test_apierrors.py +0 -0
  42. {squarepeg-0.1.0 → squarepeg-0.2.1}/tests/test_config.py +0 -0
  43. {squarepeg-0.1.0 → squarepeg-0.2.1}/tests/test_dockerargs.py +0 -0
  44. {squarepeg-0.1.0 → squarepeg-0.2.1}/tests/test_dryrun.py +0 -0
  45. {squarepeg-0.1.0 → squarepeg-0.2.1}/tests/test_env_interpolation.py +0 -0
  46. {squarepeg-0.1.0 → squarepeg-0.2.1}/tests/test_events.py +0 -0
  47. {squarepeg-0.1.0 → squarepeg-0.2.1}/tests/test_integration.py +0 -0
  48. {squarepeg-0.1.0 → squarepeg-0.2.1}/tests/test_logs.py +0 -0
  49. {squarepeg-0.1.0 → squarepeg-0.2.1}/tests/test_manifest.py +0 -0
  50. {squarepeg-0.1.0 → squarepeg-0.2.1}/tests/test_merge.py +0 -0
  51. {squarepeg-0.1.0 → squarepeg-0.2.1}/tests/test_naming.py +0 -0
  52. {squarepeg-0.1.0 → squarepeg-0.2.1}/tests/test_orphans.py +0 -0
  53. {squarepeg-0.1.0 → squarepeg-0.2.1}/tests/test_podstate.py +0 -0
  54. {squarepeg-0.1.0 → squarepeg-0.2.1}/tests/test_quantities.py +0 -0
  55. {squarepeg-0.1.0 → squarepeg-0.2.1}/tests/test_session.py +0 -0
  56. {squarepeg-0.1.0 → squarepeg-0.2.1}/tests/test_volumes.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: squarepeg
3
- Version: 0.1.0
3
+ Version: 0.2.1
4
4
  Summary: Run a docker-run-style command as a Kubernetes Pod or Job, streaming its output like a local process
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -12,6 +12,15 @@ Requires-Dist: pytest>=8; extra == "dev"
12
12
  Requires-Dist: pytest-cov>=5; extra == "dev"
13
13
  Requires-Dist: ruff>=0.4; extra == "dev"
14
14
 
15
+ ```
16
+ _____
17
+ / ___/____ ___ ______ _________ ____ ___ ____ _
18
+ \__ \/ __ `/ / / / __ `/ ___/ _ \/ __ \/ _ \/ __ `/
19
+ ___/ / /_/ / /_/ / /_/ / / / __/ /_/ / __/ /_/ /
20
+ /____/\__, /\__,_/\__,_/_/ \___/ .___/\___/\__, /
21
+ /_/ /_/ /____/
22
+ ```
23
+
15
24
  # squarepeg
16
25
 
17
26
  Run a `docker run`-style command as a Kubernetes Pod or Job, streaming its
@@ -1,3 +1,12 @@
1
+ ```
2
+ _____
3
+ / ___/____ ___ ______ _________ ____ ___ ____ _
4
+ \__ \/ __ `/ / / / __ `/ ___/ _ \/ __ \/ _ \/ __ `/
5
+ ___/ / /_/ / /_/ / /_/ / / / __/ /_/ / __/ /_/ /
6
+ /____/\__, /\__,_/\__,_/_/ \___/ .___/\___/\__, /
7
+ /_/ /_/ /____/
8
+ ```
9
+
1
10
  # squarepeg
2
11
 
3
12
  Run a `docker run`-style command as a Kubernetes Pod or Job, streaming its
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "squarepeg"
7
- version = "0.1.0"
7
+ version = "0.2.1"
8
8
  description = "Run a docker-run-style command as a Kubernetes Pod or Job, streaming its output like a local process"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -0,0 +1,6 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ try:
4
+ __version__ = version("squarepeg")
5
+ except PackageNotFoundError: # running from a source checkout that was never pip-installed
6
+ __version__ = "0.0.0+unknown"
@@ -1,13 +1,23 @@
1
1
  import click
2
2
  import yaml
3
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
4
+ from squarepeg import __version__, ui
5
+ from squarepeg.config import (
6
+ coerce_bool,
7
+ coerce_int,
8
+ load_effective_config,
9
+ select_profile,
10
+ )
11
+ from squarepeg.dockerargs import (
12
+ check_supported_image,
13
+ parse_env_entries,
14
+ reject_unsupported,
15
+ )
7
16
  from squarepeg.errors import SquarepegError, UsageError
8
17
  from squarepeg.k8s.dryrun import server_dry_run
9
18
  from squarepeg.k8s.runner import run_manifest
10
19
  from squarepeg.k8s.session import Session
20
+ from squarepeg.log import chatter
11
21
  from squarepeg.manifest import build_job, build_pod
12
22
  from squarepeg.naming import generate_name, validate_rfc1123
13
23
  from squarepeg.quantities import docker_cpus_to_k8s, docker_memory_to_k8s
@@ -16,7 +26,11 @@ from squarepeg.volumes import auto_mounts_from_config, parse_volume_flag
16
26
 
17
27
  _CONFIG_OPTIONS = [
18
28
  click.option(
19
- "--config", "config_paths", multiple=True, type=click.Path(), help="repeatable, layered left-to-right"
29
+ "--config",
30
+ "config_paths",
31
+ multiple=True,
32
+ type=click.Path(),
33
+ help="repeatable, layered left-to-right",
20
34
  ),
21
35
  click.option("--no-default-config", "no_default_config", is_flag=True),
22
36
  click.option("--profile", "profile", default=None),
@@ -30,7 +44,9 @@ def _add_config_options(cmd):
30
44
 
31
45
 
32
46
  def _resolve_config(config_paths, no_default_config, profile):
33
- effective, sources = load_effective_config(config_paths, no_default_config=no_default_config)
47
+ effective, sources = load_effective_config(
48
+ config_paths, no_default_config=no_default_config
49
+ )
34
50
  resolved = select_profile(effective, profile)
35
51
  return resolved, sources
36
52
 
@@ -74,7 +90,24 @@ def _add_unsupported_options(cmd):
74
90
  return cmd
75
91
 
76
92
 
77
- @click.group()
93
+ _LOGO = r"""
94
+ _____
95
+ / ___/____ ___ ______ _________ ____ ___ ____ _
96
+ \__ \/ __ `/ / / / __ `/ ___/ _ \/ __ \/ _ \/ __ `/
97
+ ___/ / /_/ / /_/ / /_/ / / / __/ /_/ / __/ /_/ /
98
+ /____/\__, /\__,_/\__,_/_/ \___/ .___/\___/\__, /
99
+ /_/ /_/ /____/
100
+ """.strip("\n")
101
+
102
+ class _LogoGroup(click.Group):
103
+ """Prints the ASCII logo above the 'Usage:' line, rather than as part of the help body."""
104
+
105
+ def format_usage(self, ctx, formatter):
106
+ formatter.write(_LOGO + "\n\n")
107
+ super().format_usage(ctx, formatter)
108
+
109
+
110
+ @click.group(cls=_LogoGroup)
78
111
  @click.version_option(__version__, prog_name="squarepeg")
79
112
  def cli():
80
113
  """Run a docker-run-style command as a Kubernetes Pod or Job."""
@@ -84,14 +117,38 @@ def cli():
84
117
  context_settings={"allow_interspersed_args": False, "ignore_unknown_options": True},
85
118
  )
86
119
  @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")
120
+ @click.option(
121
+ "-v",
122
+ "--volume",
123
+ "volume_entries",
124
+ multiple=True,
125
+ help="[NAME|/host]:/container[:ro|rw], repeatable",
126
+ )
88
127
  @click.option("--name", "name", default=None, help="Pod/Job name")
89
128
  @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")
129
+ @click.option(
130
+ "--entrypoint",
131
+ "entrypoint",
132
+ default=None,
133
+ help="Override the image ENTRYPOINT (-> k8s 'command')",
134
+ )
135
+ @click.option(
136
+ "-i",
137
+ "--interactive",
138
+ "interactive",
139
+ is_flag=True,
140
+ help="accepted; stdin is not forwarded",
141
+ )
92
142
  @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)
143
+ @click.option(
144
+ "--rm",
145
+ "rm_flag",
146
+ is_flag=True,
147
+ help="accepted no-op; cleanup is already the default",
148
+ )
149
+ @click.option(
150
+ "--pull", "pull", type=click.Choice(["always", "missing", "never"]), default=None
151
+ )
95
152
  @click.option("--cpus", "cpus", default=None)
96
153
  @click.option("-m", "--memory", "memory", default=None)
97
154
  @click.option("--request-cpu", "request_cpu", default=None)
@@ -100,8 +157,12 @@ def cli():
100
157
  @click.option("--limit-memory", "limit_memory", default=None)
101
158
  @click.option("--mode", "mode", type=click.Choice(["pod", "job"]), default=None)
102
159
  @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")
160
+ @click.option(
161
+ "--keep", "keep", is_flag=True, help="do not delete the pod/job after it finishes"
162
+ )
163
+ @click.option(
164
+ "--dry-run", "dry_run", is_flag=True, help="render the manifest without creating it"
165
+ )
105
166
  @click.option(
106
167
  "--dry-run-server",
107
168
  "dry_run_server",
@@ -110,6 +171,12 @@ def cli():
110
171
  )
111
172
  @click.option("--timeout", "timeout", type=int, default=None)
112
173
  @click.option("--quiet", "quiet", is_flag=True)
174
+ @click.option(
175
+ "--no-color",
176
+ "no_color",
177
+ is_flag=True,
178
+ help="disable colour/animation in squarepeg's own stderr output",
179
+ )
113
180
  @click.option("--context", "context", default=None, help="kubeconfig context to use")
114
181
  @click.option("--container-name", "container_name", default="main")
115
182
  @click.argument("image")
@@ -142,22 +209,28 @@ def run(
142
209
  profile,
143
210
  timeout,
144
211
  quiet,
212
+ no_color,
145
213
  context,
146
214
  container_name,
147
215
  image,
148
216
  command,
149
217
  ):
150
218
  """Run IMAGE [COMMAND...] as a Kubernetes Pod or Job."""
219
+ ui.set_color_override(False if no_color else None)
151
220
  check_supported_image(image)
152
221
  if rm_flag and keep:
153
222
  raise UsageError("--rm and --keep are mutually exclusive")
154
223
  if dry_run and dry_run_server:
155
224
  raise UsageError("--dry-run and --dry-run-server are mutually exclusive")
156
225
 
157
- resolved_config, _sources = _resolve_config(config_paths, no_default_config, profile)
226
+ resolved_config, _sources = _resolve_config(
227
+ config_paths, no_default_config, profile
228
+ )
158
229
  config_defaults = resolved_config.get("defaults") or {}
159
230
  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")
231
+ allow_host_path_mounts = coerce_bool(
232
+ resolved_config.get("allow_host_path_mounts", False), "allow_host_path_mounts"
233
+ )
161
234
 
162
235
  claims: set[str] = set()
163
236
 
@@ -166,12 +239,18 @@ def run(
166
239
  claims.add(f"/spec/containers/[name={container_name}]/env/[name={key}]")
167
240
 
168
241
  cli_volumes = [
169
- parse_volume_flag(v, config_volumes=config_volumes, allow_host_path_mounts=allow_host_path_mounts)
242
+ parse_volume_flag(
243
+ v,
244
+ config_volumes=config_volumes,
245
+ allow_host_path_mounts=allow_host_path_mounts,
246
+ )
170
247
  for v in volume_entries
171
248
  ]
172
249
  for vol in cli_volumes:
173
250
  claims.add(f"/spec/volumes/[name={vol.volume_name}]")
174
- claims.add(f"/spec/containers/[name={container_name}]/volumeMounts/[name={vol.volume_name}]")
251
+ claims.add(
252
+ f"/spec/containers/[name={container_name}]/volumeMounts/[name={vol.volume_name}]"
253
+ )
175
254
 
176
255
  # config-declared auto-mounts (volumes.NAME with a mount_path) are not claimed -- they're
177
256
  # config-driven, not CLI-driven, so kubernetes.spec passthrough in the same config can still
@@ -200,7 +279,9 @@ def run(
200
279
  claims.add(f"/spec/containers/[name={container_name}]/resources/limits/cpu")
201
280
  if request_memory is not None:
202
281
  resources.request_memory = request_memory
203
- claims.add(f"/spec/containers/[name={container_name}]/resources/requests/memory")
282
+ claims.add(
283
+ f"/spec/containers/[name={container_name}]/resources/requests/memory"
284
+ )
204
285
  if limit_memory is not None:
205
286
  resources.limit_memory = limit_memory
206
287
  claims.add(f"/spec/containers/[name={container_name}]/resources/limits/memory")
@@ -209,7 +290,9 @@ def run(
209
290
  if cli_set_cpus and limit_cpu is None:
210
291
  claims.add(f"/spec/containers/[name={container_name}]/resources/limits/cpu")
211
292
  if cli_set_memory and request_memory is None:
212
- claims.add(f"/spec/containers/[name={container_name}]/resources/requests/memory")
293
+ claims.add(
294
+ f"/spec/containers/[name={container_name}]/resources/requests/memory"
295
+ )
213
296
  if cli_set_memory and limit_memory is None:
214
297
  claims.add(f"/spec/containers/[name={container_name}]/resources/limits/memory")
215
298
 
@@ -224,7 +307,11 @@ def run(
224
307
  else:
225
308
  namespace = resolved_config.get("namespace")
226
309
 
227
- pull_policy = {"always": "Always", "missing": "IfNotPresent", "never": "Never"}.get(pull) if pull else None
310
+ pull_policy = (
311
+ {"always": "Always", "missing": "IfNotPresent", "never": "Never"}.get(pull)
312
+ if pull
313
+ else None
314
+ )
228
315
  if pull_policy is not None:
229
316
  claims.add(f"/spec/containers/[name={container_name}]/imagePullPolicy")
230
317
  elif "image_pull_policy" in config_defaults:
@@ -236,13 +323,25 @@ def run(
236
323
  workdir = config_defaults["workdir"]
237
324
 
238
325
  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")
326
+ timeout = (
327
+ timeout
328
+ if timeout is not None
329
+ else coerce_int(resolved_config.get("timeout", 300), "timeout")
330
+ )
240
331
  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")
332
+ cleanup = (
333
+ False if keep else coerce_bool(resolved_config.get("cleanup", True), "cleanup")
334
+ )
335
+ orphan_sweep = coerce_bool(
336
+ resolved_config.get("orphan_sweep", True), "orphan_sweep"
337
+ )
338
+ orphan_sweep_min_age = coerce_int(
339
+ resolved_config.get("orphan_sweep_min_age", 300), "orphan_sweep_min_age"
340
+ )
244
341
  if orphan_sweep_min_age < 0:
245
- raise UsageError(f"'orphan_sweep_min_age' must not be negative, got {orphan_sweep_min_age}")
342
+ raise UsageError(
343
+ f"'orphan_sweep_min_age' must not be negative, got {orphan_sweep_min_age}"
344
+ )
246
345
 
247
346
  entrypoint_tuple = (entrypoint,) if entrypoint is not None else None
248
347
  if entrypoint_tuple is not None:
@@ -255,11 +354,14 @@ def run(
255
354
  if not interactive:
256
355
  # some clusters reject tty=true with stdin=false; -i is accepted but never
257
356
  # 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 "
357
+ # This is a real hazard warning, so it's deliberately never suppressed by
358
+ # --quiet (quiet=False below), unlike squarepeg's other informational chatter.
359
+ chatter(
360
+ "-t implies stdin is opened on the container (stdinOnce), but stdin is "
260
361
  "never actually forwarded; a process that blocks reading stdin will hang. Pass -i "
261
362
  "explicitly to acknowledge this.",
262
- err=True,
363
+ quiet=False,
364
+ level="warn",
263
365
  )
264
366
  interactive = True
265
367
  if interactive:
@@ -311,21 +413,38 @@ def run(
311
413
  raise SystemExit(exit_code)
312
414
 
313
415
 
314
- @cli.group("config")
315
- def config_group():
316
- """Inspect the resolved config."""
416
+ def _print_config(config_paths, no_default_config, profile):
417
+ resolved_config, sources = _resolve_config(config_paths, no_default_config, profile)
418
+ for path, origin in sources:
419
+ chatter(f"{path} ({origin})", level="info")
420
+ if not sources:
421
+ chatter("no config files loaded", level="info")
422
+ click.echo(yaml.safe_dump(resolved_config, sort_keys=False), nl=False)
423
+
424
+
425
+ @cli.group("config", invoke_without_command=True)
426
+ @_add_config_options
427
+ @click.pass_context
428
+ def config_group(ctx, config_paths, no_default_config, profile):
429
+ """Inspect the resolved config (same as 'config show')."""
430
+ if ctx.invoked_subcommand is None:
431
+ _print_config(config_paths, no_default_config, profile)
432
+ return
433
+ default_map = {}
434
+ if config_paths:
435
+ default_map["config_paths"] = config_paths
436
+ if no_default_config:
437
+ default_map["no_default_config"] = no_default_config
438
+ if profile is not None:
439
+ default_map["profile"] = profile
440
+ ctx.default_map = {"show": default_map}
317
441
 
318
442
 
319
443
  @config_group.command("show")
320
444
  @_add_config_options
321
445
  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)
446
+ """Print the merged effective config (stdout) and its sources (stderr). Same as bare 'config'."""
447
+ _print_config(config_paths, no_default_config, profile)
329
448
 
330
449
 
331
450
  def main():
@@ -337,7 +456,11 @@ def main():
337
456
  except click.exceptions.Exit as exc:
338
457
  raise SystemExit(exc.exit_code) from exc
339
458
  except SquarepegError as exc:
340
- click.echo(f"squarepeg: {exc}", err=True)
459
+ click.echo(
460
+ click.style(f"squarepeg: {exc}", fg="red", bold=True),
461
+ err=True,
462
+ color=ui.color_enabled(),
463
+ )
341
464
  raise SystemExit(exc.exit_code) from exc
342
465
 
343
466
 
@@ -16,4 +16,4 @@ def print_pod_events(session, pod_name: str, *, quiet: bool = False) -> None:
16
16
  return # best-effort diagnostics; never fail the run because event listing failed
17
17
 
18
18
  for event in events.items or []:
19
- chatter(f"{pod_name}: {event.reason}: {event.message}", quiet=quiet)
19
+ chatter(f"{pod_name}: {event.reason}: {event.message}", quiet=quiet, level="detail")
@@ -115,12 +115,13 @@ def stream_logs(session, pod_name: str, container_name: str, stop_event, *, quie
115
115
  f"log stream for pod {pod_name!r} failed after {attempt - 1} reconnect attempts ({exc}); "
116
116
  "still waiting for the pod to finish",
117
117
  quiet=quiet,
118
+ level="warn",
118
119
  )
119
120
  return
120
121
  if dedupe.last_ts is not None:
121
122
  since_seconds = since_seconds_from(dedupe.last_ts)
122
123
  backoff = min(2**attempt, MAX_BACKOFF_SECONDS)
123
- chatter(f"log stream for pod {pod_name!r} dropped, reconnecting in {backoff}s", quiet=quiet)
124
+ chatter(f"log stream for pod {pod_name!r} dropped, reconnecting in {backoff}s", quiet=quiet, level="warn")
124
125
  time.sleep(backoff)
125
126
  finally:
126
127
  response.close()
@@ -191,8 +191,8 @@ def sweep_orphans(
191
191
  if delete_resource(session, kind, name):
192
192
  deleted += 1
193
193
  except Exception as exc: # one bad delete must not abort the rest of the sweep
194
- chatter(f"orphan sweep: failed to delete {kind} {name!r}: {exc}", quiet=spec.quiet)
194
+ chatter(f"orphan sweep: failed to delete {kind} {name!r}: {exc}", quiet=spec.quiet, level="warn")
195
195
 
196
196
  if deleted:
197
- chatter(f"swept {deleted} orphaned resource(s) from previous runs", quiet=spec.quiet)
197
+ chatter(f"swept {deleted} orphaned resource(s) from previous runs", quiet=spec.quiet, level="success")
198
198
  return deleted
@@ -8,6 +8,7 @@ import threading
8
8
  from kubernetes import watch
9
9
  from kubernetes.client.rest import ApiException
10
10
 
11
+ from squarepeg import ui
11
12
  from squarepeg.errors import InterruptError, RunnerError
12
13
  from squarepeg.k8s.apierrors import delete_resource, wrap_api_exception
13
14
  from squarepeg.k8s.events import print_pod_events
@@ -21,9 +22,9 @@ from squarepeg.runspec import RunSpec
21
22
  # kept as module-level aliases: existing callers/tests referencing these names still work
22
23
  _wrap_api_exception = wrap_api_exception
23
24
 
24
- # how long a single watch call may block waiting for the pod to become terminal,
25
- # once it has already started; the loop simply re-watches if this elapses.
26
- TERMINAL_WATCH_POLL_SECONDS = 3600
25
+ # how long a single watch call may block waiting for the pod to become terminal, once it
26
+ # has already started; the loop simply re-watches if this elapses.
27
+ TERMINAL_WATCH_POLL_SECONDS = 5
27
28
 
28
29
 
29
30
  def create_resource(session, spec: RunSpec, manifest: dict) -> None:
@@ -48,13 +49,17 @@ def discover_job_pod(session, job_name: str, timeout: int) -> str:
48
49
  ):
49
50
  return event["object"].metadata.name
50
51
  except ApiException as exc:
51
- raise _wrap_api_exception(exc, f"failed to watch for the pod created by job {job_name!r}") from exc
52
+ raise _wrap_api_exception(
53
+ exc, f"failed to watch for the pod created by job {job_name!r}"
54
+ ) from exc
52
55
  finally:
53
56
  watcher.stop()
54
57
  raise RunnerError(f"timed out waiting for job {job_name!r} to create a pod")
55
58
 
56
59
 
57
- def wait_until_running_or_terminal(session, pod_name: str, timeout: int, *, quiet: bool = False) -> str:
60
+ def wait_until_running_or_terminal(
61
+ session, pod_name: str, timeout: int, *, quiet: bool = False
62
+ ) -> str:
58
63
  """Wait until the pod is Running/Succeeded/Failed, or fail fast on a known-bad waiting reason.
59
64
 
60
65
  This bounds only the *startup* window (image pull, scheduling, container create); once the
@@ -81,17 +86,23 @@ def _watch_for_start(session, pod_name: str, timeout: int) -> str:
81
86
  for cs in pod.status.container_statuses or []:
82
87
  waiting = cs.state.waiting
83
88
  if waiting is not None and waiting.reason in FAIL_FAST_REASONS:
84
- raise RunnerError(f"container failed to start ({waiting.reason}): {waiting.message}")
89
+ raise RunnerError(
90
+ f"container failed to start ({waiting.reason}): {waiting.message}"
91
+ )
85
92
  if phase in ("Running", "Succeeded", "Failed"):
86
93
  return phase
87
94
  except ApiException as exc:
88
95
  raise _wrap_api_exception(exc, f"failed to watch pod {pod_name!r}") from exc
89
96
  finally:
90
97
  watcher.stop()
91
- raise RunnerError(f"timed out after {timeout}s waiting for pod {pod_name!r} to start")
98
+ raise RunnerError(
99
+ f"timed out after {timeout}s waiting for pod {pod_name!r} to start"
100
+ )
92
101
 
93
102
 
94
- def wait_for_terminal(session, pod_name: str, stop_event: threading.Event) -> str | None:
103
+ def wait_for_terminal(
104
+ session, pod_name: str, stop_event: threading.Event
105
+ ) -> str | None:
95
106
  """Wait, with no overall timeout, until the pod is Succeeded/Failed, or stop_event is set."""
96
107
  while not stop_event.is_set():
97
108
  watcher = watch.Watch()
@@ -115,7 +126,9 @@ def wait_for_terminal(session, pod_name: str, stop_event: threading.Event) -> st
115
126
  return None
116
127
 
117
128
 
118
- def extract_exit_code(session, pod_name: str, container_name: str) -> tuple[int, str | None]:
129
+ def extract_exit_code(
130
+ session, pod_name: str, container_name: str
131
+ ) -> tuple[int, str | None]:
119
132
  try:
120
133
  pod = session.core.read_namespaced_pod(pod_name, session.namespace)
121
134
  except ApiException as exc:
@@ -164,9 +177,12 @@ class _InterruptHandler:
164
177
  chatter(
165
178
  f"interrupted; cleaning up {self.mode} {self.name!r} (press Ctrl+C again to leave it running)",
166
179
  quiet=self.quiet,
180
+ level="warn",
167
181
  )
168
182
  else:
169
- chatter(f"leaving {self.mode} {self.name!r} running", quiet=False)
183
+ chatter(
184
+ f"leaving {self.mode} {self.name!r} running", quiet=False, level="warn"
185
+ )
170
186
 
171
187
  @property
172
188
  def interrupted(self) -> bool:
@@ -184,10 +200,39 @@ def run_manifest(session, spec: RunSpec, manifest: dict) -> int:
184
200
  with _InterruptHandler(name, spec.mode, spec.quiet) as handler:
185
201
  log_thread = None
186
202
  try:
187
- create_resource(session, spec, manifest)
188
- pod_name = discover_job_pod(session, name, spec.timeout) if spec.mode == "job" else name
203
+ with ui.Status(
204
+ f"creating {spec.mode} {name!r} in namespace {session.namespace!r}",
205
+ quiet=spec.quiet,
206
+ ):
207
+ create_resource(session, spec, manifest)
208
+
209
+ if spec.mode == "job":
210
+ with ui.Status(
211
+ f"waiting for job {name!r} to create a pod",
212
+ quiet=spec.quiet,
213
+ slow_hint="the job controller has not produced a pod yet",
214
+ slow_after=15,
215
+ ):
216
+ pod_name = discover_job_pod(session, name, spec.timeout)
217
+ else:
218
+ pod_name = name
219
+
220
+ with ui.Status(
221
+ f"waiting for pod {pod_name!r} to start",
222
+ quiet=spec.quiet,
223
+ slow_hint="still scheduling or pulling the image; a cold pull of a large image can take a while",
224
+ slow_after=20,
225
+ spinner_delay=0.0,
226
+ ):
227
+ phase = wait_until_running_or_terminal(
228
+ session, pod_name, spec.timeout, quiet=spec.quiet
229
+ )
189
230
 
190
- phase = wait_until_running_or_terminal(session, pod_name, spec.timeout, quiet=spec.quiet)
231
+ chatter(
232
+ f"streaming logs from pod {pod_name!r} (waiting for it to finish; Ctrl+C to stop)",
233
+ quiet=spec.quiet,
234
+ level="step",
235
+ )
191
236
 
192
237
  log_thread = threading.Thread(
193
238
  target=stream_logs,
@@ -206,28 +251,51 @@ def run_manifest(session, spec: RunSpec, manifest: dict) -> int:
206
251
  # truncate output for fast-exiting containers.
207
252
  log_thread.join(timeout=30)
208
253
  if log_thread.is_alive():
209
- chatter(f"log stream for pod {pod_name!r} did not finish on its own; stopping it", quiet=spec.quiet)
254
+ chatter(
255
+ f"log stream for pod {pod_name!r} did not finish on its own; stopping it",
256
+ quiet=spec.quiet,
257
+ level="warn",
258
+ )
210
259
  handler.stop_event.set()
211
260
  log_thread.join(timeout=5)
212
261
 
213
262
  if handler.interrupted:
214
263
  raise InterruptError(f"interrupted while running {spec.mode} {name!r}")
215
264
 
216
- exit_code, reason = extract_exit_code(session, pod_name, spec.container_name)
265
+ exit_code, reason = extract_exit_code(
266
+ session, pod_name, spec.container_name
267
+ )
217
268
  if reason == "OOMKilled":
218
- chatter(f"container was OOMKilled (exit code {exit_code})", quiet=spec.quiet)
269
+ chatter(
270
+ f"container was OOMKilled (exit code {exit_code})",
271
+ quiet=spec.quiet,
272
+ level="error",
273
+ )
219
274
  return exit_code
220
275
  finally:
221
276
  handler.stop_event.set()
222
277
  if log_thread is not None:
223
278
  log_thread.join(timeout=2)
224
279
  if spec.cleanup and not handler.abandoned:
225
- cleanup(session, spec, name)
280
+ with ui.Status(f"deleting {spec.mode} {name!r}", quiet=spec.quiet):
281
+ cleanup(session, spec, name)
226
282
  elif not handler.abandoned and not spec.quiet:
227
- chatter(f"kept {spec.mode} {name!r}; inspect with 'kubectl describe {spec.mode} {name}'", quiet=False)
283
+ chatter(
284
+ f"kept {spec.mode} {name!r}; inspect with 'kubectl describe {spec.mode} {name}'",
285
+ quiet=False,
286
+ level="warn",
287
+ )
228
288
  if spec.orphan_sweep and not handler.abandoned:
229
- own_run_id = manifest.get("metadata", {}).get("labels", {}).get(RUN_ID_LABEL)
289
+ own_run_id = (
290
+ manifest.get("metadata", {}).get("labels", {}).get(RUN_ID_LABEL)
291
+ )
230
292
  try:
231
- sweep_orphans(session, spec, exclude_run_id=own_run_id)
293
+ with ui.Status(
294
+ "sweeping orphaned resources from previous runs",
295
+ quiet=spec.quiet,
296
+ ):
297
+ sweep_orphans(session, spec, exclude_run_id=own_run_id)
232
298
  except Exception as exc:
233
- chatter(f"orphan sweep skipped: {exc}", quiet=spec.quiet)
299
+ chatter(
300
+ f"orphan sweep skipped: {exc}", quiet=spec.quiet, level="warn"
301
+ )
@@ -0,0 +1,5 @@
1
+ from squarepeg import ui
2
+
3
+
4
+ def chatter(message: str, *, quiet: bool = False, level: ui.Level = "info") -> None:
5
+ ui.emit(message, level=level, quiet=quiet)