squarepeg 0.2.0__tar.gz → 0.3.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 (54) hide show
  1. {squarepeg-0.2.0 → squarepeg-0.3.0}/PKG-INFO +1 -1
  2. {squarepeg-0.2.0 → squarepeg-0.3.0}/pyproject.toml +1 -1
  3. {squarepeg-0.2.0 → squarepeg-0.3.0}/squarepeg/cli.py +22 -2
  4. {squarepeg-0.2.0 → squarepeg-0.3.0}/squarepeg/k8s/runner.py +49 -17
  5. {squarepeg-0.2.0 → squarepeg-0.3.0}/squarepeg/ui.py +29 -1
  6. {squarepeg-0.2.0 → squarepeg-0.3.0}/squarepeg.egg-info/PKG-INFO +1 -1
  7. {squarepeg-0.2.0 → squarepeg-0.3.0}/tests/test_cli.py +96 -0
  8. {squarepeg-0.2.0 → squarepeg-0.3.0}/tests/test_ui.py +54 -1
  9. {squarepeg-0.2.0 → squarepeg-0.3.0}/README.md +0 -0
  10. {squarepeg-0.2.0 → squarepeg-0.3.0}/setup.cfg +0 -0
  11. {squarepeg-0.2.0 → squarepeg-0.3.0}/squarepeg/__init__.py +0 -0
  12. {squarepeg-0.2.0 → squarepeg-0.3.0}/squarepeg/__main__.py +0 -0
  13. {squarepeg-0.2.0 → squarepeg-0.3.0}/squarepeg/config.py +0 -0
  14. {squarepeg-0.2.0 → squarepeg-0.3.0}/squarepeg/dockerargs.py +0 -0
  15. {squarepeg-0.2.0 → squarepeg-0.3.0}/squarepeg/env_interpolation.py +0 -0
  16. {squarepeg-0.2.0 → squarepeg-0.3.0}/squarepeg/errors.py +0 -0
  17. {squarepeg-0.2.0 → squarepeg-0.3.0}/squarepeg/k8s/__init__.py +0 -0
  18. {squarepeg-0.2.0 → squarepeg-0.3.0}/squarepeg/k8s/apierrors.py +0 -0
  19. {squarepeg-0.2.0 → squarepeg-0.3.0}/squarepeg/k8s/dryrun.py +0 -0
  20. {squarepeg-0.2.0 → squarepeg-0.3.0}/squarepeg/k8s/events.py +0 -0
  21. {squarepeg-0.2.0 → squarepeg-0.3.0}/squarepeg/k8s/logs.py +0 -0
  22. {squarepeg-0.2.0 → squarepeg-0.3.0}/squarepeg/k8s/orphans.py +0 -0
  23. {squarepeg-0.2.0 → squarepeg-0.3.0}/squarepeg/k8s/podstate.py +0 -0
  24. {squarepeg-0.2.0 → squarepeg-0.3.0}/squarepeg/k8s/session.py +0 -0
  25. {squarepeg-0.2.0 → squarepeg-0.3.0}/squarepeg/labels.py +0 -0
  26. {squarepeg-0.2.0 → squarepeg-0.3.0}/squarepeg/log.py +0 -0
  27. {squarepeg-0.2.0 → squarepeg-0.3.0}/squarepeg/manifest.py +0 -0
  28. {squarepeg-0.2.0 → squarepeg-0.3.0}/squarepeg/merge.py +0 -0
  29. {squarepeg-0.2.0 → squarepeg-0.3.0}/squarepeg/naming.py +0 -0
  30. {squarepeg-0.2.0 → squarepeg-0.3.0}/squarepeg/quantities.py +0 -0
  31. {squarepeg-0.2.0 → squarepeg-0.3.0}/squarepeg/runspec.py +0 -0
  32. {squarepeg-0.2.0 → squarepeg-0.3.0}/squarepeg/volumes.py +0 -0
  33. {squarepeg-0.2.0 → squarepeg-0.3.0}/squarepeg.egg-info/SOURCES.txt +0 -0
  34. {squarepeg-0.2.0 → squarepeg-0.3.0}/squarepeg.egg-info/dependency_links.txt +0 -0
  35. {squarepeg-0.2.0 → squarepeg-0.3.0}/squarepeg.egg-info/entry_points.txt +0 -0
  36. {squarepeg-0.2.0 → squarepeg-0.3.0}/squarepeg.egg-info/requires.txt +0 -0
  37. {squarepeg-0.2.0 → squarepeg-0.3.0}/squarepeg.egg-info/top_level.txt +0 -0
  38. {squarepeg-0.2.0 → squarepeg-0.3.0}/tests/test_apierrors.py +0 -0
  39. {squarepeg-0.2.0 → squarepeg-0.3.0}/tests/test_config.py +0 -0
  40. {squarepeg-0.2.0 → squarepeg-0.3.0}/tests/test_dockerargs.py +0 -0
  41. {squarepeg-0.2.0 → squarepeg-0.3.0}/tests/test_dryrun.py +0 -0
  42. {squarepeg-0.2.0 → squarepeg-0.3.0}/tests/test_env_interpolation.py +0 -0
  43. {squarepeg-0.2.0 → squarepeg-0.3.0}/tests/test_events.py +0 -0
  44. {squarepeg-0.2.0 → squarepeg-0.3.0}/tests/test_integration.py +0 -0
  45. {squarepeg-0.2.0 → squarepeg-0.3.0}/tests/test_logs.py +0 -0
  46. {squarepeg-0.2.0 → squarepeg-0.3.0}/tests/test_manifest.py +0 -0
  47. {squarepeg-0.2.0 → squarepeg-0.3.0}/tests/test_merge.py +0 -0
  48. {squarepeg-0.2.0 → squarepeg-0.3.0}/tests/test_naming.py +0 -0
  49. {squarepeg-0.2.0 → squarepeg-0.3.0}/tests/test_orphans.py +0 -0
  50. {squarepeg-0.2.0 → squarepeg-0.3.0}/tests/test_podstate.py +0 -0
  51. {squarepeg-0.2.0 → squarepeg-0.3.0}/tests/test_quantities.py +0 -0
  52. {squarepeg-0.2.0 → squarepeg-0.3.0}/tests/test_runner.py +0 -0
  53. {squarepeg-0.2.0 → squarepeg-0.3.0}/tests/test_session.py +0 -0
  54. {squarepeg-0.2.0 → squarepeg-0.3.0}/tests/test_volumes.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: squarepeg
3
- Version: 0.2.0
3
+ Version: 0.3.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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "squarepeg"
7
- version = "0.2.0"
7
+ version = "0.3.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"
@@ -1,3 +1,5 @@
1
+ import secrets
2
+
1
3
  import click
2
4
  import yaml
3
5
 
@@ -17,6 +19,7 @@ from squarepeg.errors import SquarepegError, UsageError
17
19
  from squarepeg.k8s.dryrun import server_dry_run
18
20
  from squarepeg.k8s.runner import run_manifest
19
21
  from squarepeg.k8s.session import Session
22
+ from squarepeg.labels import RUN_ID_LABEL
20
23
  from squarepeg.log import chatter
21
24
  from squarepeg.manifest import build_job, build_pod
22
25
  from squarepeg.naming import generate_name, validate_rfc1123
@@ -217,6 +220,7 @@ def run(
217
220
  ):
218
221
  """Run IMAGE [COMMAND...] as a Kubernetes Pod or Job."""
219
222
  ui.set_color_override(False if no_color else None)
223
+ ui.set_run_tag(None)
220
224
  check_supported_image(image)
221
225
  if rm_flag and keep:
222
226
  raise UsageError("--rm and --keep are mutually exclusive")
@@ -302,6 +306,9 @@ def run(
302
306
  else:
303
307
  name = generate_name(image)
304
308
 
309
+ run_id = secrets.token_hex(4)
310
+ ui.set_run_tag(run_id)
311
+
305
312
  if namespace is not None:
306
313
  claims.add("/metadata/namespace")
307
314
  else:
@@ -393,9 +400,21 @@ def run(
393
400
  kubernetes_passthrough = resolved_config.get("kubernetes")
394
401
  job_passthrough = resolved_config.get("job")
395
402
  if spec.mode == "job":
396
- manifest = build_job(spec, kubernetes_passthrough, job_passthrough)
403
+ manifest = build_job(spec, kubernetes_passthrough, job_passthrough, run_id=run_id)
397
404
  else:
398
- manifest = build_pod(spec, kubernetes_passthrough)
405
+ manifest = build_pod(spec, kubernetes_passthrough, run_id=run_id)
406
+
407
+ # kubernetes.metadata passthrough can legally overwrite the run-id label (it's not a
408
+ # claimed field) -- if it did, the tag on screen must match what's actually on the
409
+ # cluster, not the value we generated, so the printed tag is always kubectl -l ready.
410
+ effective_run_id = manifest.get("metadata", {}).get("labels", {}).get(RUN_ID_LABEL)
411
+ if effective_run_id and effective_run_id != run_id:
412
+ chatter(
413
+ f"config passthrough overrode the {RUN_ID_LABEL!r} label "
414
+ f"(was {run_id!r}, now {effective_run_id!r})",
415
+ level="warn",
416
+ )
417
+ ui.set_run_tag(effective_run_id)
399
418
 
400
419
  if dry_run_server:
401
420
  session = Session(namespace=spec.namespace, context=context, quiet=spec.quiet)
@@ -414,6 +433,7 @@ def run(
414
433
 
415
434
 
416
435
  def _print_config(config_paths, no_default_config, profile):
436
+ ui.set_run_tag(None) # config has no run context, even if invoked right after a 'run' in-process
417
437
  resolved_config, sources = _resolve_config(config_paths, no_default_config, profile)
418
438
  for path, origin in sources:
419
439
  chatter(f"{path} ({origin})", level="info")
@@ -22,9 +22,9 @@ from squarepeg.runspec import RunSpec
22
22
  # kept as module-level aliases: existing callers/tests referencing these names still work
23
23
  _wrap_api_exception = wrap_api_exception
24
24
 
25
- # how long a single watch call may block waiting for the pod to become terminal,
26
- # once it has already started; the loop simply re-watches if this elapses.
27
- 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
28
28
 
29
29
 
30
30
  def create_resource(session, spec: RunSpec, manifest: dict) -> None:
@@ -49,13 +49,17 @@ def discover_job_pod(session, job_name: str, timeout: int) -> str:
49
49
  ):
50
50
  return event["object"].metadata.name
51
51
  except ApiException as exc:
52
- 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
53
55
  finally:
54
56
  watcher.stop()
55
57
  raise RunnerError(f"timed out waiting for job {job_name!r} to create a pod")
56
58
 
57
59
 
58
- 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:
59
63
  """Wait until the pod is Running/Succeeded/Failed, or fail fast on a known-bad waiting reason.
60
64
 
61
65
  This bounds only the *startup* window (image pull, scheduling, container create); once the
@@ -82,17 +86,23 @@ def _watch_for_start(session, pod_name: str, timeout: int) -> str:
82
86
  for cs in pod.status.container_statuses or []:
83
87
  waiting = cs.state.waiting
84
88
  if waiting is not None and waiting.reason in FAIL_FAST_REASONS:
85
- 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
+ )
86
92
  if phase in ("Running", "Succeeded", "Failed"):
87
93
  return phase
88
94
  except ApiException as exc:
89
95
  raise _wrap_api_exception(exc, f"failed to watch pod {pod_name!r}") from exc
90
96
  finally:
91
97
  watcher.stop()
92
- 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
+ )
93
101
 
94
102
 
95
- 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:
96
106
  """Wait, with no overall timeout, until the pod is Succeeded/Failed, or stop_event is set."""
97
107
  while not stop_event.is_set():
98
108
  watcher = watch.Watch()
@@ -116,7 +126,9 @@ def wait_for_terminal(session, pod_name: str, stop_event: threading.Event) -> st
116
126
  return None
117
127
 
118
128
 
119
- 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]:
120
132
  try:
121
133
  pod = session.core.read_namespaced_pod(pod_name, session.namespace)
122
134
  except ApiException as exc:
@@ -168,7 +180,9 @@ class _InterruptHandler:
168
180
  level="warn",
169
181
  )
170
182
  else:
171
- chatter(f"leaving {self.mode} {self.name!r} running", quiet=False, level="warn")
183
+ chatter(
184
+ f"leaving {self.mode} {self.name!r} running", quiet=False, level="warn"
185
+ )
172
186
 
173
187
  @property
174
188
  def interrupted(self) -> bool:
@@ -186,7 +200,10 @@ def run_manifest(session, spec: RunSpec, manifest: dict) -> int:
186
200
  with _InterruptHandler(name, spec.mode, spec.quiet) as handler:
187
201
  log_thread = None
188
202
  try:
189
- with ui.Status(f"creating {spec.mode} {name!r} in namespace {session.namespace!r}", quiet=spec.quiet):
203
+ with ui.Status(
204
+ f"creating {spec.mode} {name!r} in namespace {session.namespace!r}",
205
+ quiet=spec.quiet,
206
+ ):
190
207
  create_resource(session, spec, manifest)
191
208
 
192
209
  if spec.mode == "job":
@@ -207,7 +224,9 @@ def run_manifest(session, spec: RunSpec, manifest: dict) -> int:
207
224
  slow_after=20,
208
225
  spinner_delay=0.0,
209
226
  ):
210
- phase = wait_until_running_or_terminal(session, pod_name, spec.timeout, quiet=spec.quiet)
227
+ phase = wait_until_running_or_terminal(
228
+ session, pod_name, spec.timeout, quiet=spec.quiet
229
+ )
211
230
 
212
231
  chatter(
213
232
  f"streaming logs from pod {pod_name!r} (waiting for it to finish; Ctrl+C to stop)",
@@ -243,9 +262,15 @@ def run_manifest(session, spec: RunSpec, manifest: dict) -> int:
243
262
  if handler.interrupted:
244
263
  raise InterruptError(f"interrupted while running {spec.mode} {name!r}")
245
264
 
246
- 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
+ )
247
268
  if reason == "OOMKilled":
248
- chatter(f"container was OOMKilled (exit code {exit_code})", quiet=spec.quiet, level="error")
269
+ chatter(
270
+ f"container was OOMKilled (exit code {exit_code})",
271
+ quiet=spec.quiet,
272
+ level="error",
273
+ )
249
274
  return exit_code
250
275
  finally:
251
276
  handler.stop_event.set()
@@ -261,9 +286,16 @@ def run_manifest(session, spec: RunSpec, manifest: dict) -> int:
261
286
  level="warn",
262
287
  )
263
288
  if spec.orphan_sweep and not handler.abandoned:
264
- 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
+ )
265
292
  try:
266
- with ui.Status("sweeping orphaned resources from previous runs", quiet=spec.quiet):
293
+ with ui.Status(
294
+ "sweeping orphaned resources from previous runs",
295
+ quiet=spec.quiet,
296
+ ):
267
297
  sweep_orphans(session, spec, exclude_run_id=own_run_id)
268
298
  except Exception as exc:
269
- chatter(f"orphan sweep skipped: {exc}", quiet=spec.quiet, level="warn")
299
+ chatter(
300
+ f"orphan sweep skipped: {exc}", quiet=spec.quiet, level="warn"
301
+ )
@@ -10,6 +10,12 @@ those call sites needed to change.
10
10
  Colour/animation precedence (highest first): --quiet (nothing at all) > --no-color
11
11
  (forces colour off) > $NO_COLOR (forces colour off) > $FORCE_COLOR (forces colour on) >
12
12
  autodetect from sys.stderr.isatty() (click's own default behaviour).
13
+
14
+ When a run is in progress, the prefix also carries that run's id -- `[squarepeg:1a2b3c4]
15
+ ...` -- so that several `squarepeg run` invocations whose stderr gets interleaved (parallel
16
+ shell jobs, a CI matrix, an aggregated log) stay attributable line by line. The tag lives
17
+ strictly in the prefix span, never inside the message body, so it can never break a test
18
+ asserting a message body is a contiguous substring of the output.
13
19
  """
14
20
 
15
21
  import itertools
@@ -42,6 +48,8 @@ _ACTIVE: "Status | None" = None
42
48
 
43
49
  _color_override: bool | None = None # None = no override, resolved from env/TTY instead
44
50
 
51
+ _run_tag: str | None = None # None = no run associated with current output (config, pre-run errors)
52
+
45
53
 
46
54
  def set_color_override(value: bool | None) -> None:
47
55
  """Called once from cli.run() for --no-color. None restores auto-detection."""
@@ -49,6 +57,24 @@ def set_color_override(value: bool | None) -> None:
49
57
  _color_override = value
50
58
 
51
59
 
60
+ def set_run_tag(tag: str | None) -> None:
61
+ """Set the run id shown in every subsequent status line's prefix. None (the default,
62
+ and what cli.py's `config` command resets it to) means no run is associated with the
63
+ current output, producing the legacy plain `[squarepeg] ` prefix.
64
+
65
+ Written exactly once per real run, on the main thread, before any thread that reads it
66
+ (the log-streaming thread, a Status spinner thread) is created -- threading.Thread.start()
67
+ establishes a happens-before edge, so no lock is needed for readers. The signal handler in
68
+ _InterruptHandler also only reads this value, which is what makes it signal-handler-safe
69
+ to read (acquiring a lock there would not be)."""
70
+ global _run_tag
71
+ _run_tag = tag
72
+
73
+
74
+ def current_run_tag() -> str | None:
75
+ return _run_tag
76
+
77
+
52
78
  def color_enabled() -> bool | None:
53
79
  """Tri-state, passed straight through to click.echo(color=...): True/False force
54
80
  colour on/off, None lets click autodetect from sys.stderr.isatty()."""
@@ -79,7 +105,9 @@ def _spinner_frames() -> list[str]:
79
105
 
80
106
 
81
107
  def _styled_line(message: str, level: Level) -> str:
82
- prefix = click.style("[squarepeg] ", dim=True)
108
+ tag = current_run_tag()
109
+ label = f"[squarepeg:{tag}] " if tag else "[squarepeg] "
110
+ prefix = click.style(label, dim=True)
83
111
  body = click.style(message, **_STYLES[level])
84
112
  return prefix + body
85
113
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: squarepeg
3
- Version: 0.2.0
3
+ Version: 0.3.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
@@ -7,6 +7,16 @@ from squarepeg.cli import cli
7
7
  from squarepeg.errors import RunnerError
8
8
 
9
9
 
10
+ def _separated_runner():
11
+ """A CliRunner with stdout/stderr captured separately, across the click 8.1/8.2 API
12
+ split: 8.1.x needs mix_stderr=False to get a usable .stderr; 8.2+ removed that kwarg
13
+ entirely and always separates the two streams by default."""
14
+ try:
15
+ return CliRunner(mix_stderr=False)
16
+ except TypeError:
17
+ return CliRunner()
18
+
19
+
10
20
  class FakeSession:
11
21
  """Stands in for squarepeg.k8s.session.Session so CLI-level dry-run-server tests never
12
22
  touch a real cluster; records the args it was constructed with for assertions."""
@@ -367,3 +377,89 @@ def test_config_unknown_subcommand_errors():
367
377
  result = CliRunner().invoke(cli, ["config", "bogus"])
368
378
  assert result.exit_code != 0
369
379
  assert "No such command 'bogus'" in result.output
380
+
381
+
382
+ # --- run-id tag ---
383
+
384
+
385
+ def _mock_run(monkeypatch):
386
+ """Patches Session/run_manifest so 'run' never touches a cluster, and returns a dict
387
+ that gets filled in with the manifest run_manifest was actually handed."""
388
+ captured = {}
389
+ monkeypatch.setattr(cli_module, "Session", FakeSession)
390
+
391
+ def fake_run_manifest(session, spec, manifest):
392
+ captured["manifest"] = manifest
393
+ return 0
394
+
395
+ monkeypatch.setattr(cli_module, "run_manifest", fake_run_manifest)
396
+ return captured
397
+
398
+
399
+ def test_run_stderr_tag_matches_manifest_run_id_label(monkeypatch):
400
+ """-t (without -i) triggers a real chatter() warning inside cli.py itself, before Session
401
+ is even constructed -- a guaranteed, already-existing status line to check the tag on."""
402
+ import re
403
+
404
+ captured = _mock_run(monkeypatch)
405
+ result = _separated_runner().invoke(cli, ["run", "--no-default-config", "-t", "alpine"])
406
+ assert result.exit_code == 0, result.output
407
+
408
+ label = captured["manifest"]["metadata"]["labels"]["squarepeg.io/run-id"]
409
+ assert re.fullmatch(r"[0-9a-f]{8}", label)
410
+ assert f"[squarepeg:{label}]" in result.stderr
411
+
412
+
413
+ def test_run_job_mode_stderr_tag_matches_manifest_run_id_label(monkeypatch):
414
+ captured = _mock_run(monkeypatch)
415
+ result = _separated_runner().invoke(
416
+ cli, ["run", "--no-default-config", "--mode", "job", "-t", "alpine"]
417
+ )
418
+ assert result.exit_code == 0, result.output
419
+
420
+ label = captured["manifest"]["metadata"]["labels"]["squarepeg.io/run-id"]
421
+ assert f"[squarepeg:{label}]" in result.stderr
422
+
423
+
424
+ def test_config_output_has_no_run_tag():
425
+ result = _separated_runner().invoke(cli, ["config", "--no-default-config"])
426
+ assert result.exit_code == 0, result.stderr
427
+ assert "[squarepeg:" not in result.stderr
428
+ assert "[squarepeg] no config files loaded" in result.stderr
429
+
430
+
431
+ def test_config_after_run_has_no_run_tag(monkeypatch):
432
+ """A 'run' followed by 'config' in the same process (as CliRunner does) must not leak
433
+ the run's tag into config's output."""
434
+ _mock_run(monkeypatch)
435
+ _separated_runner().invoke(cli, ["run", "--no-default-config", "alpine"])
436
+ result = _separated_runner().invoke(cli, ["config", "--no-default-config"])
437
+ assert result.exit_code == 0, result.stderr
438
+ assert "[squarepeg:" not in result.stderr
439
+
440
+
441
+ def test_dry_run_stdout_has_no_tag():
442
+ result = _separated_runner().invoke(cli, ["run", "--no-default-config", "--dry-run", "alpine"])
443
+ assert result.exit_code == 0, result.stderr
444
+ assert "[squarepeg" not in result.stdout
445
+
446
+
447
+ def test_dry_run_server_stderr_is_tagged(monkeypatch):
448
+ monkeypatch.setattr(cli_module, "Session", FakeSession)
449
+ monkeypatch.setattr(cli_module, "server_dry_run", lambda session, spec, manifest: manifest)
450
+ result = _separated_runner().invoke(
451
+ cli, ["run", "--no-default-config", "-t", "--dry-run-server", "alpine"]
452
+ )
453
+ assert result.exit_code == 0, result.stderr
454
+ assert "[squarepeg:" in result.stderr
455
+
456
+
457
+ def test_two_run_invocations_get_different_tags(monkeypatch):
458
+ captured1 = _mock_run(monkeypatch)
459
+ CliRunner().invoke(cli, ["run", "--no-default-config", "alpine"])
460
+ captured2 = _mock_run(monkeypatch)
461
+ CliRunner().invoke(cli, ["run", "--no-default-config", "alpine"])
462
+
463
+ label1 = captured1["manifest"]["metadata"]["labels"]["squarepeg.io/run-id"]
464
+ label2 = captured2["manifest"]["metadata"]["labels"]["squarepeg.io/run-id"]
465
+ assert label1 != label2
@@ -8,10 +8,12 @@ LEVELS = ["info", "step", "wait", "success", "warn", "error", "detail"]
8
8
 
9
9
 
10
10
  @pytest.fixture(autouse=True)
11
- def _reset_color_override():
11
+ def _reset_ui_state():
12
12
  ui.set_color_override(None)
13
+ ui.set_run_tag(None)
13
14
  yield
14
15
  ui.set_color_override(None)
16
+ ui.set_run_tag(None)
15
17
 
16
18
 
17
19
  # --- emit: colour / env var precedence ---
@@ -108,3 +110,54 @@ def test_status_propagates_exceptions_and_leaves_no_live_thread():
108
110
  with ui.Status("doing a thing"):
109
111
  raise RunnerError("boom")
110
112
  assert threading.active_count() == before
113
+
114
+
115
+ # --- run tag ---
116
+
117
+
118
+ def test_set_run_tag_appears_in_prefix(capsys):
119
+ ui.set_run_tag("abc12345")
120
+ ui.emit("hello")
121
+ assert "[squarepeg:abc12345] hello" in capsys.readouterr().err
122
+
123
+
124
+ def test_no_run_tag_produces_legacy_prefix(capsys):
125
+ ui.emit("hello")
126
+ err = capsys.readouterr().err
127
+ assert err.strip() == "[squarepeg] hello"
128
+ assert "[squarepeg:" not in err
129
+
130
+
131
+ @pytest.mark.parametrize("level", LEVELS)
132
+ def test_run_tag_does_not_corrupt_body_substring(monkeypatch, capsys, level):
133
+ monkeypatch.setenv("FORCE_COLOR", "1")
134
+ ui.set_run_tag("abc12345")
135
+ ui.emit("swept 3 orphaned resource(s) from previous runs", level=level)
136
+ assert "swept 3 orphaned resource(s) from previous runs" in capsys.readouterr().err
137
+
138
+
139
+ def test_status_line_carries_run_tag(capsys):
140
+ ui.set_run_tag("abc12345")
141
+ with ui.Status("waiting for pod to start", slow_hint="still pulling", slow_after=999):
142
+ pass
143
+ err = capsys.readouterr().err
144
+ assert "[squarepeg:abc12345]" in err
145
+ assert "waiting for pod to start" in err
146
+ assert "still pulling" in err
147
+
148
+
149
+ def test_status_success_line_carries_run_tag(capsys):
150
+ ui.set_run_tag("abc12345")
151
+ with ui.Status("doing a thing", success="all done"):
152
+ pass
153
+ err = capsys.readouterr().err
154
+ assert "[squarepeg:abc12345] all done" in err
155
+
156
+
157
+ def test_set_run_tag_none_clears_a_previously_set_tag(capsys):
158
+ ui.set_run_tag("abc12345")
159
+ ui.set_run_tag(None)
160
+ ui.emit("hello")
161
+ err = capsys.readouterr().err
162
+ assert "[squarepeg:" not in err
163
+ assert "[squarepeg] hello" in err
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes