squarepeg 0.2.1__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.1 → squarepeg-0.3.0}/PKG-INFO +1 -1
  2. {squarepeg-0.2.1 → squarepeg-0.3.0}/pyproject.toml +1 -1
  3. {squarepeg-0.2.1 → squarepeg-0.3.0}/squarepeg/cli.py +22 -2
  4. {squarepeg-0.2.1 → squarepeg-0.3.0}/squarepeg/ui.py +29 -1
  5. {squarepeg-0.2.1 → squarepeg-0.3.0}/squarepeg.egg-info/PKG-INFO +1 -1
  6. {squarepeg-0.2.1 → squarepeg-0.3.0}/tests/test_cli.py +96 -0
  7. {squarepeg-0.2.1 → squarepeg-0.3.0}/tests/test_ui.py +54 -1
  8. {squarepeg-0.2.1 → squarepeg-0.3.0}/README.md +0 -0
  9. {squarepeg-0.2.1 → squarepeg-0.3.0}/setup.cfg +0 -0
  10. {squarepeg-0.2.1 → squarepeg-0.3.0}/squarepeg/__init__.py +0 -0
  11. {squarepeg-0.2.1 → squarepeg-0.3.0}/squarepeg/__main__.py +0 -0
  12. {squarepeg-0.2.1 → squarepeg-0.3.0}/squarepeg/config.py +0 -0
  13. {squarepeg-0.2.1 → squarepeg-0.3.0}/squarepeg/dockerargs.py +0 -0
  14. {squarepeg-0.2.1 → squarepeg-0.3.0}/squarepeg/env_interpolation.py +0 -0
  15. {squarepeg-0.2.1 → squarepeg-0.3.0}/squarepeg/errors.py +0 -0
  16. {squarepeg-0.2.1 → squarepeg-0.3.0}/squarepeg/k8s/__init__.py +0 -0
  17. {squarepeg-0.2.1 → squarepeg-0.3.0}/squarepeg/k8s/apierrors.py +0 -0
  18. {squarepeg-0.2.1 → squarepeg-0.3.0}/squarepeg/k8s/dryrun.py +0 -0
  19. {squarepeg-0.2.1 → squarepeg-0.3.0}/squarepeg/k8s/events.py +0 -0
  20. {squarepeg-0.2.1 → squarepeg-0.3.0}/squarepeg/k8s/logs.py +0 -0
  21. {squarepeg-0.2.1 → squarepeg-0.3.0}/squarepeg/k8s/orphans.py +0 -0
  22. {squarepeg-0.2.1 → squarepeg-0.3.0}/squarepeg/k8s/podstate.py +0 -0
  23. {squarepeg-0.2.1 → squarepeg-0.3.0}/squarepeg/k8s/runner.py +0 -0
  24. {squarepeg-0.2.1 → squarepeg-0.3.0}/squarepeg/k8s/session.py +0 -0
  25. {squarepeg-0.2.1 → squarepeg-0.3.0}/squarepeg/labels.py +0 -0
  26. {squarepeg-0.2.1 → squarepeg-0.3.0}/squarepeg/log.py +0 -0
  27. {squarepeg-0.2.1 → squarepeg-0.3.0}/squarepeg/manifest.py +0 -0
  28. {squarepeg-0.2.1 → squarepeg-0.3.0}/squarepeg/merge.py +0 -0
  29. {squarepeg-0.2.1 → squarepeg-0.3.0}/squarepeg/naming.py +0 -0
  30. {squarepeg-0.2.1 → squarepeg-0.3.0}/squarepeg/quantities.py +0 -0
  31. {squarepeg-0.2.1 → squarepeg-0.3.0}/squarepeg/runspec.py +0 -0
  32. {squarepeg-0.2.1 → squarepeg-0.3.0}/squarepeg/volumes.py +0 -0
  33. {squarepeg-0.2.1 → squarepeg-0.3.0}/squarepeg.egg-info/SOURCES.txt +0 -0
  34. {squarepeg-0.2.1 → squarepeg-0.3.0}/squarepeg.egg-info/dependency_links.txt +0 -0
  35. {squarepeg-0.2.1 → squarepeg-0.3.0}/squarepeg.egg-info/entry_points.txt +0 -0
  36. {squarepeg-0.2.1 → squarepeg-0.3.0}/squarepeg.egg-info/requires.txt +0 -0
  37. {squarepeg-0.2.1 → squarepeg-0.3.0}/squarepeg.egg-info/top_level.txt +0 -0
  38. {squarepeg-0.2.1 → squarepeg-0.3.0}/tests/test_apierrors.py +0 -0
  39. {squarepeg-0.2.1 → squarepeg-0.3.0}/tests/test_config.py +0 -0
  40. {squarepeg-0.2.1 → squarepeg-0.3.0}/tests/test_dockerargs.py +0 -0
  41. {squarepeg-0.2.1 → squarepeg-0.3.0}/tests/test_dryrun.py +0 -0
  42. {squarepeg-0.2.1 → squarepeg-0.3.0}/tests/test_env_interpolation.py +0 -0
  43. {squarepeg-0.2.1 → squarepeg-0.3.0}/tests/test_events.py +0 -0
  44. {squarepeg-0.2.1 → squarepeg-0.3.0}/tests/test_integration.py +0 -0
  45. {squarepeg-0.2.1 → squarepeg-0.3.0}/tests/test_logs.py +0 -0
  46. {squarepeg-0.2.1 → squarepeg-0.3.0}/tests/test_manifest.py +0 -0
  47. {squarepeg-0.2.1 → squarepeg-0.3.0}/tests/test_merge.py +0 -0
  48. {squarepeg-0.2.1 → squarepeg-0.3.0}/tests/test_naming.py +0 -0
  49. {squarepeg-0.2.1 → squarepeg-0.3.0}/tests/test_orphans.py +0 -0
  50. {squarepeg-0.2.1 → squarepeg-0.3.0}/tests/test_podstate.py +0 -0
  51. {squarepeg-0.2.1 → squarepeg-0.3.0}/tests/test_quantities.py +0 -0
  52. {squarepeg-0.2.1 → squarepeg-0.3.0}/tests/test_runner.py +0 -0
  53. {squarepeg-0.2.1 → squarepeg-0.3.0}/tests/test_session.py +0 -0
  54. {squarepeg-0.2.1 → 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.1
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.1"
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")
@@ -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.1
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