squarepeg 0.1.0__tar.gz → 0.2.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 (56) hide show
  1. {squarepeg-0.1.0 → squarepeg-0.2.0}/PKG-INFO +10 -1
  2. {squarepeg-0.1.0 → squarepeg-0.2.0}/README.md +9 -0
  3. {squarepeg-0.1.0 → squarepeg-0.2.0}/pyproject.toml +1 -1
  4. squarepeg-0.2.0/squarepeg/__init__.py +6 -0
  5. {squarepeg-0.1.0 → squarepeg-0.2.0}/squarepeg/cli.py +162 -39
  6. {squarepeg-0.1.0 → squarepeg-0.2.0}/squarepeg/k8s/events.py +1 -1
  7. {squarepeg-0.1.0 → squarepeg-0.2.0}/squarepeg/k8s/logs.py +2 -1
  8. {squarepeg-0.1.0 → squarepeg-0.2.0}/squarepeg/k8s/orphans.py +2 -2
  9. {squarepeg-0.1.0 → squarepeg-0.2.0}/squarepeg/k8s/runner.py +46 -10
  10. squarepeg-0.2.0/squarepeg/log.py +5 -0
  11. squarepeg-0.2.0/squarepeg/ui.py +190 -0
  12. {squarepeg-0.1.0 → squarepeg-0.2.0}/squarepeg.egg-info/PKG-INFO +10 -1
  13. {squarepeg-0.1.0 → squarepeg-0.2.0}/squarepeg.egg-info/SOURCES.txt +2 -0
  14. {squarepeg-0.1.0 → squarepeg-0.2.0}/tests/test_cli.py +95 -9
  15. {squarepeg-0.1.0 → squarepeg-0.2.0}/tests/test_runner.py +27 -0
  16. squarepeg-0.2.0/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.0}/setup.cfg +0 -0
  20. {squarepeg-0.1.0 → squarepeg-0.2.0}/squarepeg/__main__.py +0 -0
  21. {squarepeg-0.1.0 → squarepeg-0.2.0}/squarepeg/config.py +0 -0
  22. {squarepeg-0.1.0 → squarepeg-0.2.0}/squarepeg/dockerargs.py +0 -0
  23. {squarepeg-0.1.0 → squarepeg-0.2.0}/squarepeg/env_interpolation.py +0 -0
  24. {squarepeg-0.1.0 → squarepeg-0.2.0}/squarepeg/errors.py +0 -0
  25. {squarepeg-0.1.0 → squarepeg-0.2.0}/squarepeg/k8s/__init__.py +0 -0
  26. {squarepeg-0.1.0 → squarepeg-0.2.0}/squarepeg/k8s/apierrors.py +0 -0
  27. {squarepeg-0.1.0 → squarepeg-0.2.0}/squarepeg/k8s/dryrun.py +0 -0
  28. {squarepeg-0.1.0 → squarepeg-0.2.0}/squarepeg/k8s/podstate.py +0 -0
  29. {squarepeg-0.1.0 → squarepeg-0.2.0}/squarepeg/k8s/session.py +0 -0
  30. {squarepeg-0.1.0 → squarepeg-0.2.0}/squarepeg/labels.py +0 -0
  31. {squarepeg-0.1.0 → squarepeg-0.2.0}/squarepeg/manifest.py +0 -0
  32. {squarepeg-0.1.0 → squarepeg-0.2.0}/squarepeg/merge.py +0 -0
  33. {squarepeg-0.1.0 → squarepeg-0.2.0}/squarepeg/naming.py +0 -0
  34. {squarepeg-0.1.0 → squarepeg-0.2.0}/squarepeg/quantities.py +0 -0
  35. {squarepeg-0.1.0 → squarepeg-0.2.0}/squarepeg/runspec.py +0 -0
  36. {squarepeg-0.1.0 → squarepeg-0.2.0}/squarepeg/volumes.py +0 -0
  37. {squarepeg-0.1.0 → squarepeg-0.2.0}/squarepeg.egg-info/dependency_links.txt +0 -0
  38. {squarepeg-0.1.0 → squarepeg-0.2.0}/squarepeg.egg-info/entry_points.txt +0 -0
  39. {squarepeg-0.1.0 → squarepeg-0.2.0}/squarepeg.egg-info/requires.txt +0 -0
  40. {squarepeg-0.1.0 → squarepeg-0.2.0}/squarepeg.egg-info/top_level.txt +0 -0
  41. {squarepeg-0.1.0 → squarepeg-0.2.0}/tests/test_apierrors.py +0 -0
  42. {squarepeg-0.1.0 → squarepeg-0.2.0}/tests/test_config.py +0 -0
  43. {squarepeg-0.1.0 → squarepeg-0.2.0}/tests/test_dockerargs.py +0 -0
  44. {squarepeg-0.1.0 → squarepeg-0.2.0}/tests/test_dryrun.py +0 -0
  45. {squarepeg-0.1.0 → squarepeg-0.2.0}/tests/test_env_interpolation.py +0 -0
  46. {squarepeg-0.1.0 → squarepeg-0.2.0}/tests/test_events.py +0 -0
  47. {squarepeg-0.1.0 → squarepeg-0.2.0}/tests/test_integration.py +0 -0
  48. {squarepeg-0.1.0 → squarepeg-0.2.0}/tests/test_logs.py +0 -0
  49. {squarepeg-0.1.0 → squarepeg-0.2.0}/tests/test_manifest.py +0 -0
  50. {squarepeg-0.1.0 → squarepeg-0.2.0}/tests/test_merge.py +0 -0
  51. {squarepeg-0.1.0 → squarepeg-0.2.0}/tests/test_naming.py +0 -0
  52. {squarepeg-0.1.0 → squarepeg-0.2.0}/tests/test_orphans.py +0 -0
  53. {squarepeg-0.1.0 → squarepeg-0.2.0}/tests/test_podstate.py +0 -0
  54. {squarepeg-0.1.0 → squarepeg-0.2.0}/tests/test_quantities.py +0 -0
  55. {squarepeg-0.1.0 → squarepeg-0.2.0}/tests/test_session.py +0 -0
  56. {squarepeg-0.1.0 → squarepeg-0.2.0}/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.0
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.0"
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
@@ -164,9 +165,10 @@ class _InterruptHandler:
164
165
  chatter(
165
166
  f"interrupted; cleaning up {self.mode} {self.name!r} (press Ctrl+C again to leave it running)",
166
167
  quiet=self.quiet,
168
+ level="warn",
167
169
  )
168
170
  else:
169
- chatter(f"leaving {self.mode} {self.name!r} running", quiet=False)
171
+ chatter(f"leaving {self.mode} {self.name!r} running", quiet=False, level="warn")
170
172
 
171
173
  @property
172
174
  def interrupted(self) -> bool:
@@ -184,10 +186,34 @@ def run_manifest(session, spec: RunSpec, manifest: dict) -> int:
184
186
  with _InterruptHandler(name, spec.mode, spec.quiet) as handler:
185
187
  log_thread = None
186
188
  try:
187
- create_resource(session, spec, manifest)
188
- pod_name = discover_job_pod(session, name, spec.timeout) if spec.mode == "job" else name
189
+ with ui.Status(f"creating {spec.mode} {name!r} in namespace {session.namespace!r}", quiet=spec.quiet):
190
+ create_resource(session, spec, manifest)
191
+
192
+ if spec.mode == "job":
193
+ with ui.Status(
194
+ f"waiting for job {name!r} to create a pod",
195
+ quiet=spec.quiet,
196
+ slow_hint="the job controller has not produced a pod yet",
197
+ slow_after=15,
198
+ ):
199
+ pod_name = discover_job_pod(session, name, spec.timeout)
200
+ else:
201
+ pod_name = name
202
+
203
+ with ui.Status(
204
+ f"waiting for pod {pod_name!r} to start",
205
+ quiet=spec.quiet,
206
+ slow_hint="still scheduling or pulling the image; a cold pull of a large image can take a while",
207
+ slow_after=20,
208
+ spinner_delay=0.0,
209
+ ):
210
+ phase = wait_until_running_or_terminal(session, pod_name, spec.timeout, quiet=spec.quiet)
189
211
 
190
- phase = wait_until_running_or_terminal(session, pod_name, spec.timeout, quiet=spec.quiet)
212
+ chatter(
213
+ f"streaming logs from pod {pod_name!r} (waiting for it to finish; Ctrl+C to stop)",
214
+ quiet=spec.quiet,
215
+ level="step",
216
+ )
191
217
 
192
218
  log_thread = threading.Thread(
193
219
  target=stream_logs,
@@ -206,7 +232,11 @@ def run_manifest(session, spec: RunSpec, manifest: dict) -> int:
206
232
  # truncate output for fast-exiting containers.
207
233
  log_thread.join(timeout=30)
208
234
  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)
235
+ chatter(
236
+ f"log stream for pod {pod_name!r} did not finish on its own; stopping it",
237
+ quiet=spec.quiet,
238
+ level="warn",
239
+ )
210
240
  handler.stop_event.set()
211
241
  log_thread.join(timeout=5)
212
242
 
@@ -215,19 +245,25 @@ def run_manifest(session, spec: RunSpec, manifest: dict) -> int:
215
245
 
216
246
  exit_code, reason = extract_exit_code(session, pod_name, spec.container_name)
217
247
  if reason == "OOMKilled":
218
- chatter(f"container was OOMKilled (exit code {exit_code})", quiet=spec.quiet)
248
+ chatter(f"container was OOMKilled (exit code {exit_code})", quiet=spec.quiet, level="error")
219
249
  return exit_code
220
250
  finally:
221
251
  handler.stop_event.set()
222
252
  if log_thread is not None:
223
253
  log_thread.join(timeout=2)
224
254
  if spec.cleanup and not handler.abandoned:
225
- cleanup(session, spec, name)
255
+ with ui.Status(f"deleting {spec.mode} {name!r}", quiet=spec.quiet):
256
+ cleanup(session, spec, name)
226
257
  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)
258
+ chatter(
259
+ f"kept {spec.mode} {name!r}; inspect with 'kubectl describe {spec.mode} {name}'",
260
+ quiet=False,
261
+ level="warn",
262
+ )
228
263
  if spec.orphan_sweep and not handler.abandoned:
229
264
  own_run_id = manifest.get("metadata", {}).get("labels", {}).get(RUN_ID_LABEL)
230
265
  try:
231
- sweep_orphans(session, spec, exclude_run_id=own_run_id)
266
+ with ui.Status("sweeping orphaned resources from previous runs", quiet=spec.quiet):
267
+ sweep_orphans(session, spec, exclude_run_id=own_run_id)
232
268
  except Exception as exc:
233
- chatter(f"orphan sweep skipped: {exc}", quiet=spec.quiet)
269
+ chatter(f"orphan sweep skipped: {exc}", quiet=spec.quiet, level="warn")
@@ -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)
@@ -0,0 +1,190 @@
1
+ """squarepeg's own status output: coloured, levelled lines plus an optional animated
2
+ "what's happening now" spinner for stages that can take a noticeable while.
3
+
4
+ Everything here writes to stderr only, never stdout -- stdout is reserved for the
5
+ container's own output (and, for --dry-run/--dry-run-server, the manifest/response YAML),
6
+ and that separation must never be crossed. See squarepeg/log.py, which every existing
7
+ call site still imports; chatter() is now a thin wrapper around emit() below so none of
8
+ those call sites needed to change.
9
+
10
+ Colour/animation precedence (highest first): --quiet (nothing at all) > --no-color
11
+ (forces colour off) > $NO_COLOR (forces colour off) > $FORCE_COLOR (forces colour on) >
12
+ autodetect from sys.stderr.isatty() (click's own default behaviour).
13
+ """
14
+
15
+ import itertools
16
+ import os
17
+ import sys
18
+ import threading
19
+ import time
20
+ from typing import Literal
21
+
22
+ import click
23
+
24
+ Level = Literal["info", "step", "wait", "success", "warn", "error", "detail"]
25
+
26
+ _STYLES: dict[Level, dict] = {
27
+ "info": {},
28
+ "step": {"fg": "cyan"},
29
+ "wait": {"fg": "cyan", "dim": True},
30
+ "success": {"fg": "green"},
31
+ "warn": {"fg": "yellow"},
32
+ "error": {"fg": "red", "bold": True},
33
+ "detail": {"fg": "bright_black"},
34
+ }
35
+
36
+ _SPINNER_FRAMES_UNICODE = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
37
+ _SPINNER_FRAMES_ASCII = ["|", "/", "-", "\\"]
38
+ _SPINNER_INTERVAL = 0.1
39
+
40
+ _LOCK = threading.RLock()
41
+ _ACTIVE: "Status | None" = None
42
+
43
+ _color_override: bool | None = None # None = no override, resolved from env/TTY instead
44
+
45
+
46
+ def set_color_override(value: bool | None) -> None:
47
+ """Called once from cli.run() for --no-color. None restores auto-detection."""
48
+ global _color_override
49
+ _color_override = value
50
+
51
+
52
+ def color_enabled() -> bool | None:
53
+ """Tri-state, passed straight through to click.echo(color=...): True/False force
54
+ colour on/off, None lets click autodetect from sys.stderr.isatty()."""
55
+ if _color_override is not None:
56
+ return _color_override
57
+ if os.environ.get("NO_COLOR"):
58
+ return False
59
+ if os.environ.get("FORCE_COLOR"):
60
+ return True
61
+ return None
62
+
63
+
64
+ def animation_enabled() -> bool:
65
+ if _color_override is False:
66
+ return False
67
+ if os.environ.get("NO_COLOR"):
68
+ return False
69
+ if os.environ.get("TERM") == "dumb":
70
+ return False
71
+ return getattr(sys.stderr, "isatty", lambda: False)()
72
+
73
+
74
+ def _spinner_frames() -> list[str]:
75
+ encoding = getattr(sys.stderr, "encoding", None) or ""
76
+ if "utf" in encoding.lower():
77
+ return _SPINNER_FRAMES_UNICODE
78
+ return _SPINNER_FRAMES_ASCII
79
+
80
+
81
+ def _styled_line(message: str, level: Level) -> str:
82
+ prefix = click.style("[squarepeg] ", dim=True)
83
+ body = click.style(message, **_STYLES[level])
84
+ return prefix + body
85
+
86
+
87
+ def emit(message: str, *, level: Level = "info", quiet: bool = False) -> None:
88
+ """Print one status line at `level`. Never touches stdout."""
89
+ if quiet:
90
+ return
91
+ with _LOCK:
92
+ if _ACTIVE is not None:
93
+ _ACTIVE._erase()
94
+ click.echo(_styled_line(message, level), file=sys.stderr, color=color_enabled())
95
+ # the active Status's own animation loop redraws unconditionally on its next tick,
96
+ # so nothing further is needed here to restore the spinner line
97
+
98
+
99
+ class Status:
100
+ """Context manager for a stage that may take a while.
101
+
102
+ On a real terminal, shows an animated spinner line (stderr only) with an elapsed
103
+ timer and, past `slow_after` seconds, a `slow_hint`. Everywhere else (non-TTY,
104
+ --no-color, --quiet, TERM=dumb) it degrades to a single static line printed once on
105
+ entry -- including the slow_hint text upfront, since there is no timer to add it
106
+ later -- and nothing further is printed on exit besides the optional `success` line.
107
+ """
108
+
109
+ def __init__(
110
+ self,
111
+ message: str,
112
+ *,
113
+ quiet: bool = False,
114
+ slow_hint: str | None = None,
115
+ slow_after: float = 20.0,
116
+ spinner_delay: float = 0.4,
117
+ success: str | None = None,
118
+ ):
119
+ self.message = message
120
+ self.quiet = quiet
121
+ self.slow_hint = slow_hint
122
+ self.slow_after = slow_after
123
+ self.spinner_delay = spinner_delay
124
+ self.success = success
125
+ self._thread: threading.Thread | None = None
126
+ self._stop = threading.Event()
127
+ self._start_time: float | None = None
128
+ self._line_drawn = False
129
+
130
+ def update(self, message: str) -> None:
131
+ with _LOCK:
132
+ self.message = message
133
+
134
+ def __enter__(self) -> "Status":
135
+ global _ACTIVE
136
+ if self.quiet:
137
+ return self
138
+ if not animation_enabled():
139
+ text = self.message
140
+ if self.slow_hint:
141
+ text = f"{self.message} ({self.slow_hint})"
142
+ click.echo(_styled_line(text, "wait"), file=sys.stderr, color=color_enabled())
143
+ return self
144
+
145
+ self._start_time = time.monotonic()
146
+ with _LOCK:
147
+ click.echo(_styled_line(self.message, "wait"), file=sys.stderr, nl=False, color=color_enabled())
148
+ self._line_drawn = True
149
+ _ACTIVE = self
150
+ self._thread = threading.Thread(target=self._animate, daemon=True)
151
+ self._thread.start()
152
+ return self
153
+
154
+ def __exit__(self, exc_type, exc, tb) -> Literal[False]:
155
+ global _ACTIVE
156
+ self._stop.set()
157
+ if self._thread is not None:
158
+ self._thread.join(timeout=2)
159
+ with _LOCK:
160
+ self._erase()
161
+ if _ACTIVE is self:
162
+ _ACTIVE = None
163
+ if self.success is not None and exc_type is None and not self.quiet:
164
+ click.echo(_styled_line(self.success, "success"), file=sys.stderr, color=color_enabled())
165
+ return False
166
+
167
+ def _erase(self) -> None:
168
+ """Erase the currently-drawn spinner line, if any. Caller holds _LOCK."""
169
+ if self._line_drawn:
170
+ click.echo("\r\x1b[2K", file=sys.stderr, nl=False, color=color_enabled())
171
+ self._line_drawn = False
172
+
173
+ def _animate(self) -> None:
174
+ frames = itertools.cycle(_spinner_frames())
175
+ # Below spinner_delay we leave the plain static line (drawn in __enter__) alone --
176
+ # this is what keeps a fast create/delete from ever showing spinner motion at all.
177
+ while not self._stop.is_set() and time.monotonic() - self._start_time < self.spinner_delay:
178
+ self._stop.wait(_SPINNER_INTERVAL)
179
+ while not self._stop.is_set():
180
+ with _LOCK:
181
+ elapsed = time.monotonic() - self._start_time
182
+ text = self.message
183
+ if elapsed >= 2:
184
+ text = f"{text} ({int(elapsed)}s)"
185
+ if self.slow_hint and elapsed >= self.slow_after:
186
+ text = f"{text} — {self.slow_hint}"
187
+ line = f"\r\x1b[2K{_styled_line(f'{next(frames)} {text}', 'wait')}"
188
+ click.echo(line, file=sys.stderr, nl=False, color=color_enabled())
189
+ self._line_drawn = True
190
+ self._stop.wait(_SPINNER_INTERVAL)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: squarepeg
3
- Version: 0.1.0
3
+ Version: 0.2.0
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
@@ -14,6 +14,7 @@ squarepeg/merge.py
14
14
  squarepeg/naming.py
15
15
  squarepeg/quantities.py
16
16
  squarepeg/runspec.py
17
+ squarepeg/ui.py
17
18
  squarepeg/volumes.py
18
19
  squarepeg.egg-info/PKG-INFO
19
20
  squarepeg.egg-info/SOURCES.txt
@@ -47,4 +48,5 @@ tests/test_podstate.py
47
48
  tests/test_quantities.py
48
49
  tests/test_runner.py
49
50
  tests/test_session.py
51
+ tests/test_ui.py
50
52
  tests/test_volumes.py
@@ -31,6 +31,28 @@ def test_run_dry_run_does_not_touch_a_cluster():
31
31
  assert "kind: Pod" in result.output
32
32
 
33
33
 
34
+ def _without_run_id(manifest):
35
+ manifest = dict(manifest)
36
+ manifest["metadata"] = dict(manifest["metadata"])
37
+ manifest["metadata"]["labels"] = {
38
+ k: v for k, v in manifest["metadata"]["labels"].items() if k != "squarepeg.io/run-id"
39
+ }
40
+ return manifest
41
+
42
+
43
+ def test_run_dry_run_output_unaffected_by_force_color(monkeypatch):
44
+ """--dry-run's YAML is machine-parseable output and must never be colourised/decorated,
45
+ regardless of $FORCE_COLOR -- only squarepeg's own status chatter goes through ui.py."""
46
+ args = ["run", "--no-default-config", "--name", "squarepeg-pinned-name", "--dry-run", "alpine"]
47
+ plain = CliRunner().invoke(cli, args)
48
+ monkeypatch.setenv("FORCE_COLOR", "1")
49
+ forced = CliRunner().invoke(cli, args)
50
+ assert "\x1b" not in forced.output
51
+ # squarepeg.io/run-id is a fresh random UUID per invocation by design; everything else
52
+ # must be byte-identical regardless of $FORCE_COLOR.
53
+ assert _without_run_id(yaml.safe_load(plain.output)) == _without_run_id(yaml.safe_load(forced.output))
54
+
55
+
34
56
  def test_run_missing_image_errors():
35
57
  result = CliRunner().invoke(cli, ["run"])
36
58
  assert result.exit_code != 0
@@ -221,15 +243,7 @@ def test_dry_run_server_sends_identical_manifest_to_dry_run(monkeypatch):
221
243
 
222
244
  # squarepeg.io/run-id is a fresh random UUID per invocation by design; strip it before
223
245
  # comparing, since it's the one label that's expected to legitimately differ here.
224
- def without_run_id(manifest):
225
- manifest = dict(manifest)
226
- manifest["metadata"] = dict(manifest["metadata"])
227
- manifest["metadata"]["labels"] = {
228
- k: v for k, v in manifest["metadata"]["labels"].items() if k != "squarepeg.io/run-id"
229
- }
230
- return manifest
231
-
232
- assert without_run_id(captured["manifest"]) == without_run_id(yaml.safe_load(dry_run_result.output))
246
+ assert _without_run_id(captured["manifest"]) == _without_run_id(yaml.safe_load(dry_run_result.output))
233
247
 
234
248
 
235
249
  def test_dry_run_server_constructs_session_with_context_and_quiet(monkeypatch):
@@ -281,3 +295,75 @@ def test_dry_run_server_no_cluster_connection_exits_125(monkeypatch):
281
295
  assert result.exit_code != 0
282
296
  assert result.exception.exit_code == 125
283
297
  assert "kubeconfig" in str(result.exception)
298
+
299
+
300
+ # --- config group: bare 'config' defaults to 'show' ---
301
+
302
+
303
+ def test_config_bare_equals_config_show():
304
+ bare = CliRunner().invoke(cli, ["config", "--no-default-config"])
305
+ show = CliRunner().invoke(cli, ["config", "show", "--no-default-config"])
306
+ assert bare.exit_code == 0, bare.output
307
+ assert show.exit_code == 0, show.output
308
+ assert bare.output == show.output
309
+
310
+
311
+ def test_config_bare_no_sources_message():
312
+ result = CliRunner().invoke(cli, ["config", "--no-default-config"])
313
+ assert result.exit_code == 0, result.output
314
+ assert "no config files loaded" in result.output
315
+
316
+
317
+ def test_config_bare_respects_profile(tmp_path):
318
+ config_file = tmp_path / "config.yaml"
319
+ config_file.write_text("profiles:\n p:\n namespace: from-profile\n")
320
+ result = CliRunner().invoke(
321
+ cli, ["config", "--no-default-config", "--config", str(config_file), "--profile", "p"]
322
+ )
323
+ assert result.exit_code == 0, result.output
324
+ assert "from-profile" in result.output
325
+
326
+
327
+ def test_config_option_before_subcommand_matches_bare(tmp_path):
328
+ """Group-level options given before 'show' must still apply -- proves the default_map
329
+ seeding in the group callback actually wires the options through, rather than an
330
+ explicit 'show' silently discarding them."""
331
+ config_file = tmp_path / "config.yaml"
332
+ config_file.write_text("namespace: from-config\n")
333
+ before = CliRunner().invoke(cli, ["config", "--no-default-config", "--config", str(config_file), "show"])
334
+ bare = CliRunner().invoke(cli, ["config", "--no-default-config", "--config", str(config_file)])
335
+ assert before.exit_code == 0, before.output
336
+ assert bare.exit_code == 0, bare.output
337
+ assert before.output == bare.output
338
+ assert "from-config" in before.output
339
+
340
+
341
+ def test_config_explicit_show_option_wins_over_group_level(tmp_path):
342
+ """An explicit 'config show --profile q' must win over a group-level '--profile p'."""
343
+ config_file = tmp_path / "config.yaml"
344
+ config_file.write_text(
345
+ "profiles:\n p:\n namespace: from-p\n q:\n namespace: from-q\n"
346
+ )
347
+ result = CliRunner().invoke(
348
+ cli,
349
+ [
350
+ "config",
351
+ "--no-default-config",
352
+ "--config",
353
+ str(config_file),
354
+ "--profile",
355
+ "p",
356
+ "show",
357
+ "--profile",
358
+ "q",
359
+ ],
360
+ )
361
+ assert result.exit_code == 0, result.output
362
+ assert "from-q" in result.output
363
+ assert "from-p" not in result.output
364
+
365
+
366
+ def test_config_unknown_subcommand_errors():
367
+ result = CliRunner().invoke(cli, ["config", "bogus"])
368
+ assert result.exit_code != 0
369
+ assert "No such command 'bogus'" in result.output
@@ -294,6 +294,33 @@ def test_run_manifest_happy_path_calls_in_order(monkeypatch):
294
294
  session.core.delete_namespaced_pod.assert_called_once_with("squarepeg-alpine-abc123", "default")
295
295
 
296
296
 
297
+ def test_run_manifest_never_writes_status_output_to_stdout(monkeypatch, capsys):
298
+ """The colour/spinner mechanism must never touch stdout -- only the container's own
299
+ output (here faked by stream_logs) may land there."""
300
+
301
+ def fake_stream_logs(session, pod_name, container_name, stop_event, *, quiet=False):
302
+ import sys
303
+
304
+ sys.stdout.buffer.write(b"container output line\n")
305
+ sys.stdout.buffer.flush()
306
+
307
+ monkeypatch.setattr(runner, "stream_logs", fake_stream_logs)
308
+ session = make_session()
309
+ monkeypatch.setattr(runner.watch, "Watch", lambda: FakeWatch([{"object": pod_object(phase="Succeeded")}]))
310
+ cs = container_status(name="main", terminated_code=0, terminated_reason="Completed")
311
+ session.core.read_namespaced_pod.return_value = pod_object(container_statuses=[cs])
312
+
313
+ manifest = {"metadata": {"name": "squarepeg-alpine-abc123"}}
314
+ code = runner.run_manifest(session, basic_spec(mode="pod"), manifest)
315
+
316
+ captured = capsys.readouterr()
317
+ assert code == 0
318
+ assert captured.out == "container output line\n"
319
+ assert "container output line" not in captured.err
320
+ assert "creating" in captured.err
321
+ assert "deleting" in captured.err
322
+
323
+
297
324
  def test_run_manifest_does_not_truncate_logs_for_already_terminal_pod(monkeypatch):
298
325
  """Regression test: when the pod is already Succeeded/Failed by the time
299
326
  wait_until_running_or_terminal returns (typical for fast-exiting containers), the log
@@ -0,0 +1,110 @@
1
+ import threading
2
+
3
+ import pytest
4
+
5
+ from squarepeg import ui
6
+
7
+ LEVELS = ["info", "step", "wait", "success", "warn", "error", "detail"]
8
+
9
+
10
+ @pytest.fixture(autouse=True)
11
+ def _reset_color_override():
12
+ ui.set_color_override(None)
13
+ yield
14
+ ui.set_color_override(None)
15
+
16
+
17
+ # --- emit: colour / env var precedence ---
18
+
19
+
20
+ @pytest.mark.parametrize("level", LEVELS)
21
+ def test_emit_no_ansi_under_capsys_by_default(capsys, level):
22
+ """capsys is a non-TTY, so click.echo's own colour autodetection should strip ANSI."""
23
+ ui.emit("hello world", level=level)
24
+ assert "\x1b" not in capsys.readouterr().err
25
+
26
+
27
+ def test_emit_force_color_adds_ansi(monkeypatch, capsys):
28
+ monkeypatch.setenv("FORCE_COLOR", "1")
29
+ ui.emit("hello", level="step")
30
+ assert "\x1b[36m" in capsys.readouterr().err
31
+
32
+
33
+ def test_no_color_beats_force_color(monkeypatch, capsys):
34
+ monkeypatch.setenv("FORCE_COLOR", "1")
35
+ monkeypatch.setenv("NO_COLOR", "1")
36
+ ui.emit("hello", level="step")
37
+ assert "\x1b" not in capsys.readouterr().err
38
+
39
+
40
+ def test_no_color_override_beats_force_color(monkeypatch, capsys):
41
+ monkeypatch.setenv("FORCE_COLOR", "1")
42
+ ui.set_color_override(False)
43
+ ui.emit("hello", level="step")
44
+ assert "\x1b" not in capsys.readouterr().err
45
+
46
+
47
+ @pytest.mark.parametrize("level", LEVELS)
48
+ def test_emit_quiet_prints_nothing(capsys, level):
49
+ ui.emit("should not appear", level=level, quiet=True)
50
+ assert capsys.readouterr().err == ""
51
+
52
+
53
+ @pytest.mark.parametrize("level", LEVELS)
54
+ def test_emit_body_is_contiguous_substring_even_with_color_forced(monkeypatch, capsys, level):
55
+ """Guards the never-style-a-sub-span rule: existing tests assert on exact substrings of
56
+ chatter output via capsys, and must keep passing even if colour is forced on."""
57
+ monkeypatch.setenv("FORCE_COLOR", "1")
58
+ ui.emit("swept 3 orphaned resource(s) from previous runs", level=level)
59
+ assert "swept 3 orphaned resource(s) from previous runs" in capsys.readouterr().err
60
+
61
+
62
+ # --- Status: non-TTY degrades to a single static line, no thread ---
63
+
64
+
65
+ def test_status_non_tty_prints_one_static_line_including_slow_hint(capsys):
66
+ before = threading.active_count()
67
+ with ui.Status("waiting for pod to start", slow_hint="still pulling", slow_after=999):
68
+ assert threading.active_count() == before
69
+ err = capsys.readouterr().err
70
+ assert "waiting for pod to start" in err
71
+ assert "still pulling" in err
72
+
73
+
74
+ def test_status_non_tty_no_thread_spawned(capsys):
75
+ before = threading.active_count()
76
+ with ui.Status("doing a thing"):
77
+ assert threading.active_count() == before
78
+ assert threading.active_count() == before
79
+
80
+
81
+ def test_status_quiet_emits_nothing_including_success(capsys):
82
+ with ui.Status("doing a thing", quiet=True, success="done"):
83
+ pass
84
+ assert capsys.readouterr().err == ""
85
+
86
+
87
+ def test_status_success_printed_on_clean_exit(capsys):
88
+ with ui.Status("doing a thing", success="all done"):
89
+ pass
90
+ assert "all done" in capsys.readouterr().err
91
+
92
+
93
+ def test_status_success_not_printed_on_exception(capsys):
94
+ from squarepeg.errors import RunnerError
95
+
96
+ with pytest.raises(RunnerError):
97
+ with ui.Status("doing a thing", success="all done"):
98
+ raise RunnerError("boom")
99
+ err = capsys.readouterr().err
100
+ assert "all done" not in err
101
+
102
+
103
+ def test_status_propagates_exceptions_and_leaves_no_live_thread():
104
+ from squarepeg.errors import RunnerError
105
+
106
+ before = threading.active_count()
107
+ with pytest.raises(RunnerError):
108
+ with ui.Status("doing a thing"):
109
+ raise RunnerError("boom")
110
+ assert threading.active_count() == before
@@ -1 +0,0 @@
1
- __version__ = "0.1.0"
@@ -1,6 +0,0 @@
1
- import sys
2
-
3
-
4
- def chatter(message: str, *, quiet: bool = False) -> None:
5
- if not quiet:
6
- print(f"[squarepeg] {message}", file=sys.stderr)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes