From ea98c9cf9a4f2e984c7eb23305c62bd664676e23 Mon Sep 17 00:00:00 2001 From: Biowilko Date: Thu, 6 Aug 2026 11:51:19 +0100 Subject: [PATCH 1/4] Make CLI *flashier* --- squarepeg/cli.py | 54 +++++++---- squarepeg/k8s/events.py | 2 +- squarepeg/k8s/logs.py | 3 +- squarepeg/k8s/orphans.py | 4 +- squarepeg/k8s/runner.py | 56 +++++++++--- squarepeg/log.py | 7 +- squarepeg/ui.py | 190 +++++++++++++++++++++++++++++++++++++++ tests/test_cli.py | 104 +++++++++++++++++++-- tests/test_runner.py | 27 ++++++ tests/test_ui.py | 110 +++++++++++++++++++++++ 10 files changed, 515 insertions(+), 42 deletions(-) create mode 100644 squarepeg/ui.py create mode 100644 tests/test_ui.py diff --git a/squarepeg/cli.py b/squarepeg/cli.py index 4fe4fd5..5033621 100644 --- a/squarepeg/cli.py +++ b/squarepeg/cli.py @@ -1,13 +1,14 @@ import click import yaml -from squarepeg import __version__ +from squarepeg import __version__, ui from squarepeg.config import coerce_bool, coerce_int, load_effective_config, select_profile from squarepeg.dockerargs import check_supported_image, parse_env_entries, reject_unsupported from squarepeg.errors import SquarepegError, UsageError from squarepeg.k8s.dryrun import server_dry_run from squarepeg.k8s.runner import run_manifest from squarepeg.k8s.session import Session +from squarepeg.log import chatter from squarepeg.manifest import build_job, build_pod from squarepeg.naming import generate_name, validate_rfc1123 from squarepeg.quantities import docker_cpus_to_k8s, docker_memory_to_k8s @@ -110,6 +111,7 @@ def cli(): ) @click.option("--timeout", "timeout", type=int, default=None) @click.option("--quiet", "quiet", is_flag=True) +@click.option("--no-color", "no_color", is_flag=True, help="disable colour/animation in squarepeg's own stderr output") @click.option("--context", "context", default=None, help="kubeconfig context to use") @click.option("--container-name", "container_name", default="main") @click.argument("image") @@ -142,12 +144,14 @@ def run( profile, timeout, quiet, + no_color, context, container_name, image, command, ): """Run IMAGE [COMMAND...] as a Kubernetes Pod or Job.""" + ui.set_color_override(False if no_color else None) check_supported_image(image) if rm_flag and keep: raise UsageError("--rm and --keep are mutually exclusive") @@ -255,11 +259,14 @@ def run( if not interactive: # some clusters reject tty=true with stdin=false; -i is accepted but never # forwards real stdin, so this can hang a container that then blocks reading it. - click.echo( - "squarepeg: -t implies stdin is opened on the container (stdinOnce), but stdin is " + # This is a real hazard warning, so it's deliberately never suppressed by + # --quiet (quiet=False below), unlike squarepeg's other informational chatter. + chatter( + "-t implies stdin is opened on the container (stdinOnce), but stdin is " "never actually forwarded; a process that blocks reading stdin will hang. Pass -i " "explicitly to acknowledge this.", - err=True, + quiet=False, + level="warn", ) interactive = True if interactive: @@ -311,21 +318,38 @@ def run( raise SystemExit(exit_code) -@cli.group("config") -def config_group(): - """Inspect the resolved config.""" +def _print_config(config_paths, no_default_config, profile): + resolved_config, sources = _resolve_config(config_paths, no_default_config, profile) + for path, origin in sources: + chatter(f"{path} ({origin})", level="info") + if not sources: + chatter("no config files loaded", level="info") + click.echo(yaml.safe_dump(resolved_config, sort_keys=False), nl=False) + + +@cli.group("config", invoke_without_command=True) +@_add_config_options +@click.pass_context +def config_group(ctx, config_paths, no_default_config, profile): + """Inspect the resolved config (same as 'config show').""" + if ctx.invoked_subcommand is None: + _print_config(config_paths, no_default_config, profile) + return + default_map = {} + if config_paths: + default_map["config_paths"] = config_paths + if no_default_config: + default_map["no_default_config"] = no_default_config + if profile is not None: + default_map["profile"] = profile + ctx.default_map = {"show": default_map} @config_group.command("show") @_add_config_options def config_show(config_paths, no_default_config, profile): - """Print the merged effective config (stdout) and its sources (stderr).""" - resolved_config, sources = _resolve_config(config_paths, no_default_config, profile) - for path, origin in sources: - click.echo(f"[squarepeg] {path} ({origin})", err=True) - if not sources: - click.echo("[squarepeg] no config files loaded", err=True) - click.echo(yaml.safe_dump(resolved_config, sort_keys=False), nl=False) + """Print the merged effective config (stdout) and its sources (stderr). Same as bare 'config'.""" + _print_config(config_paths, no_default_config, profile) def main(): @@ -337,7 +361,7 @@ def main(): except click.exceptions.Exit as exc: raise SystemExit(exc.exit_code) from exc except SquarepegError as exc: - click.echo(f"squarepeg: {exc}", err=True) + click.echo(click.style(f"squarepeg: {exc}", fg="red", bold=True), err=True, color=ui.color_enabled()) raise SystemExit(exc.exit_code) from exc diff --git a/squarepeg/k8s/events.py b/squarepeg/k8s/events.py index 1acffcd..ec71273 100644 --- a/squarepeg/k8s/events.py +++ b/squarepeg/k8s/events.py @@ -16,4 +16,4 @@ def print_pod_events(session, pod_name: str, *, quiet: bool = False) -> None: return # best-effort diagnostics; never fail the run because event listing failed for event in events.items or []: - chatter(f"{pod_name}: {event.reason}: {event.message}", quiet=quiet) + chatter(f"{pod_name}: {event.reason}: {event.message}", quiet=quiet, level="detail") diff --git a/squarepeg/k8s/logs.py b/squarepeg/k8s/logs.py index 8163de3..bc4e013 100644 --- a/squarepeg/k8s/logs.py +++ b/squarepeg/k8s/logs.py @@ -115,12 +115,13 @@ def stream_logs(session, pod_name: str, container_name: str, stop_event, *, quie f"log stream for pod {pod_name!r} failed after {attempt - 1} reconnect attempts ({exc}); " "still waiting for the pod to finish", quiet=quiet, + level="warn", ) return if dedupe.last_ts is not None: since_seconds = since_seconds_from(dedupe.last_ts) backoff = min(2**attempt, MAX_BACKOFF_SECONDS) - chatter(f"log stream for pod {pod_name!r} dropped, reconnecting in {backoff}s", quiet=quiet) + chatter(f"log stream for pod {pod_name!r} dropped, reconnecting in {backoff}s", quiet=quiet, level="warn") time.sleep(backoff) finally: response.close() diff --git a/squarepeg/k8s/orphans.py b/squarepeg/k8s/orphans.py index 5ebbcb8..777e3f6 100644 --- a/squarepeg/k8s/orphans.py +++ b/squarepeg/k8s/orphans.py @@ -191,8 +191,8 @@ def sweep_orphans( if delete_resource(session, kind, name): deleted += 1 except Exception as exc: # one bad delete must not abort the rest of the sweep - chatter(f"orphan sweep: failed to delete {kind} {name!r}: {exc}", quiet=spec.quiet) + chatter(f"orphan sweep: failed to delete {kind} {name!r}: {exc}", quiet=spec.quiet, level="warn") if deleted: - chatter(f"swept {deleted} orphaned resource(s) from previous runs", quiet=spec.quiet) + chatter(f"swept {deleted} orphaned resource(s) from previous runs", quiet=spec.quiet, level="success") return deleted diff --git a/squarepeg/k8s/runner.py b/squarepeg/k8s/runner.py index ee902fc..3b36a97 100644 --- a/squarepeg/k8s/runner.py +++ b/squarepeg/k8s/runner.py @@ -8,6 +8,7 @@ from kubernetes import watch from kubernetes.client.rest import ApiException +from squarepeg import ui from squarepeg.errors import InterruptError, RunnerError from squarepeg.k8s.apierrors import delete_resource, wrap_api_exception from squarepeg.k8s.events import print_pod_events @@ -164,9 +165,10 @@ def _handle(self, _signum, _frame): chatter( f"interrupted; cleaning up {self.mode} {self.name!r} (press Ctrl+C again to leave it running)", quiet=self.quiet, + level="warn", ) else: - chatter(f"leaving {self.mode} {self.name!r} running", quiet=False) + chatter(f"leaving {self.mode} {self.name!r} running", quiet=False, level="warn") @property def interrupted(self) -> bool: @@ -184,10 +186,34 @@ def run_manifest(session, spec: RunSpec, manifest: dict) -> int: with _InterruptHandler(name, spec.mode, spec.quiet) as handler: log_thread = None try: - create_resource(session, spec, manifest) - pod_name = discover_job_pod(session, name, spec.timeout) if spec.mode == "job" else name + with ui.Status(f"creating {spec.mode} {name!r} in namespace {session.namespace!r}", quiet=spec.quiet): + create_resource(session, spec, manifest) + + if spec.mode == "job": + with ui.Status( + f"waiting for job {name!r} to create a pod", + quiet=spec.quiet, + slow_hint="the job controller has not produced a pod yet", + slow_after=15, + ): + pod_name = discover_job_pod(session, name, spec.timeout) + else: + pod_name = name + + with ui.Status( + f"waiting for pod {pod_name!r} to start", + quiet=spec.quiet, + slow_hint="still scheduling or pulling the image; a cold pull of a large image can take a while", + slow_after=20, + spinner_delay=0.0, + ): + phase = wait_until_running_or_terminal(session, pod_name, spec.timeout, quiet=spec.quiet) - phase = wait_until_running_or_terminal(session, pod_name, spec.timeout, quiet=spec.quiet) + chatter( + f"streaming logs from pod {pod_name!r} (waiting for it to finish; Ctrl+C to stop)", + quiet=spec.quiet, + level="step", + ) log_thread = threading.Thread( target=stream_logs, @@ -206,7 +232,11 @@ def run_manifest(session, spec: RunSpec, manifest: dict) -> int: # truncate output for fast-exiting containers. log_thread.join(timeout=30) if log_thread.is_alive(): - chatter(f"log stream for pod {pod_name!r} did not finish on its own; stopping it", quiet=spec.quiet) + chatter( + f"log stream for pod {pod_name!r} did not finish on its own; stopping it", + quiet=spec.quiet, + level="warn", + ) handler.stop_event.set() log_thread.join(timeout=5) @@ -215,19 +245,25 @@ def run_manifest(session, spec: RunSpec, manifest: dict) -> int: exit_code, reason = extract_exit_code(session, pod_name, spec.container_name) if reason == "OOMKilled": - chatter(f"container was OOMKilled (exit code {exit_code})", quiet=spec.quiet) + chatter(f"container was OOMKilled (exit code {exit_code})", quiet=spec.quiet, level="error") return exit_code finally: handler.stop_event.set() if log_thread is not None: log_thread.join(timeout=2) if spec.cleanup and not handler.abandoned: - cleanup(session, spec, name) + with ui.Status(f"deleting {spec.mode} {name!r}", quiet=spec.quiet): + cleanup(session, spec, name) elif not handler.abandoned and not spec.quiet: - chatter(f"kept {spec.mode} {name!r}; inspect with 'kubectl describe {spec.mode} {name}'", quiet=False) + chatter( + f"kept {spec.mode} {name!r}; inspect with 'kubectl describe {spec.mode} {name}'", + quiet=False, + level="warn", + ) if spec.orphan_sweep and not handler.abandoned: own_run_id = manifest.get("metadata", {}).get("labels", {}).get(RUN_ID_LABEL) try: - sweep_orphans(session, spec, exclude_run_id=own_run_id) + with ui.Status("sweeping orphaned resources from previous runs", quiet=spec.quiet): + sweep_orphans(session, spec, exclude_run_id=own_run_id) except Exception as exc: - chatter(f"orphan sweep skipped: {exc}", quiet=spec.quiet) + chatter(f"orphan sweep skipped: {exc}", quiet=spec.quiet, level="warn") diff --git a/squarepeg/log.py b/squarepeg/log.py index 096cf3c..688fc10 100644 --- a/squarepeg/log.py +++ b/squarepeg/log.py @@ -1,6 +1,5 @@ -import sys +from squarepeg import ui -def chatter(message: str, *, quiet: bool = False) -> None: - if not quiet: - print(f"[squarepeg] {message}", file=sys.stderr) +def chatter(message: str, *, quiet: bool = False, level: ui.Level = "info") -> None: + ui.emit(message, level=level, quiet=quiet) diff --git a/squarepeg/ui.py b/squarepeg/ui.py new file mode 100644 index 0000000..74189e8 --- /dev/null +++ b/squarepeg/ui.py @@ -0,0 +1,190 @@ +"""squarepeg's own status output: coloured, levelled lines plus an optional animated +"what's happening now" spinner for stages that can take a noticeable while. + +Everything here writes to stderr only, never stdout -- stdout is reserved for the +container's own output (and, for --dry-run/--dry-run-server, the manifest/response YAML), +and that separation must never be crossed. See squarepeg/log.py, which every existing +call site still imports; chatter() is now a thin wrapper around emit() below so none of +those call sites needed to change. + +Colour/animation precedence (highest first): --quiet (nothing at all) > --no-color +(forces colour off) > $NO_COLOR (forces colour off) > $FORCE_COLOR (forces colour on) > +autodetect from sys.stderr.isatty() (click's own default behaviour). +""" + +import itertools +import os +import sys +import threading +import time +from typing import Literal + +import click + +Level = Literal["info", "step", "wait", "success", "warn", "error", "detail"] + +_STYLES: dict[Level, dict] = { + "info": {}, + "step": {"fg": "cyan"}, + "wait": {"fg": "cyan", "dim": True}, + "success": {"fg": "green"}, + "warn": {"fg": "yellow"}, + "error": {"fg": "red", "bold": True}, + "detail": {"fg": "bright_black"}, +} + +_SPINNER_FRAMES_UNICODE = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] +_SPINNER_FRAMES_ASCII = ["|", "/", "-", "\\"] +_SPINNER_INTERVAL = 0.1 + +_LOCK = threading.RLock() +_ACTIVE: "Status | None" = None + +_color_override: bool | None = None # None = no override, resolved from env/TTY instead + + +def set_color_override(value: bool | None) -> None: + """Called once from cli.run() for --no-color. None restores auto-detection.""" + global _color_override + _color_override = value + + +def color_enabled() -> bool | None: + """Tri-state, passed straight through to click.echo(color=...): True/False force + colour on/off, None lets click autodetect from sys.stderr.isatty().""" + if _color_override is not None: + return _color_override + if os.environ.get("NO_COLOR"): + return False + if os.environ.get("FORCE_COLOR"): + return True + return None + + +def animation_enabled() -> bool: + if _color_override is False: + return False + if os.environ.get("NO_COLOR"): + return False + if os.environ.get("TERM") == "dumb": + return False + return getattr(sys.stderr, "isatty", lambda: False)() + + +def _spinner_frames() -> list[str]: + encoding = getattr(sys.stderr, "encoding", None) or "" + if "utf" in encoding.lower(): + return _SPINNER_FRAMES_UNICODE + return _SPINNER_FRAMES_ASCII + + +def _styled_line(message: str, level: Level) -> str: + prefix = click.style("[squarepeg] ", dim=True) + body = click.style(message, **_STYLES[level]) + return prefix + body + + +def emit(message: str, *, level: Level = "info", quiet: bool = False) -> None: + """Print one status line at `level`. Never touches stdout.""" + if quiet: + return + with _LOCK: + if _ACTIVE is not None: + _ACTIVE._erase() + click.echo(_styled_line(message, level), file=sys.stderr, color=color_enabled()) + # the active Status's own animation loop redraws unconditionally on its next tick, + # so nothing further is needed here to restore the spinner line + + +class Status: + """Context manager for a stage that may take a while. + + On a real terminal, shows an animated spinner line (stderr only) with an elapsed + timer and, past `slow_after` seconds, a `slow_hint`. Everywhere else (non-TTY, + --no-color, --quiet, TERM=dumb) it degrades to a single static line printed once on + entry -- including the slow_hint text upfront, since there is no timer to add it + later -- and nothing further is printed on exit besides the optional `success` line. + """ + + def __init__( + self, + message: str, + *, + quiet: bool = False, + slow_hint: str | None = None, + slow_after: float = 20.0, + spinner_delay: float = 0.4, + success: str | None = None, + ): + self.message = message + self.quiet = quiet + self.slow_hint = slow_hint + self.slow_after = slow_after + self.spinner_delay = spinner_delay + self.success = success + self._thread: threading.Thread | None = None + self._stop = threading.Event() + self._start_time: float | None = None + self._line_drawn = False + + def update(self, message: str) -> None: + with _LOCK: + self.message = message + + def __enter__(self) -> "Status": + global _ACTIVE + if self.quiet: + return self + if not animation_enabled(): + text = self.message + if self.slow_hint: + text = f"{self.message} ({self.slow_hint})" + click.echo(_styled_line(text, "wait"), file=sys.stderr, color=color_enabled()) + return self + + self._start_time = time.monotonic() + with _LOCK: + click.echo(_styled_line(self.message, "wait"), file=sys.stderr, nl=False, color=color_enabled()) + self._line_drawn = True + _ACTIVE = self + self._thread = threading.Thread(target=self._animate, daemon=True) + self._thread.start() + return self + + def __exit__(self, exc_type, exc, tb) -> Literal[False]: + global _ACTIVE + self._stop.set() + if self._thread is not None: + self._thread.join(timeout=2) + with _LOCK: + self._erase() + if _ACTIVE is self: + _ACTIVE = None + if self.success is not None and exc_type is None and not self.quiet: + click.echo(_styled_line(self.success, "success"), file=sys.stderr, color=color_enabled()) + return False + + def _erase(self) -> None: + """Erase the currently-drawn spinner line, if any. Caller holds _LOCK.""" + if self._line_drawn: + click.echo("\r\x1b[2K", file=sys.stderr, nl=False, color=color_enabled()) + self._line_drawn = False + + def _animate(self) -> None: + frames = itertools.cycle(_spinner_frames()) + # Below spinner_delay we leave the plain static line (drawn in __enter__) alone -- + # this is what keeps a fast create/delete from ever showing spinner motion at all. + while not self._stop.is_set() and time.monotonic() - self._start_time < self.spinner_delay: + self._stop.wait(_SPINNER_INTERVAL) + while not self._stop.is_set(): + with _LOCK: + elapsed = time.monotonic() - self._start_time + text = self.message + if elapsed >= 2: + text = f"{text} ({int(elapsed)}s)" + if self.slow_hint and elapsed >= self.slow_after: + text = f"{text} — {self.slow_hint}" + line = f"\r\x1b[2K{_styled_line(f'{next(frames)} {text}', 'wait')}" + click.echo(line, file=sys.stderr, nl=False, color=color_enabled()) + self._line_drawn = True + self._stop.wait(_SPINNER_INTERVAL) diff --git a/tests/test_cli.py b/tests/test_cli.py index a3ac573..16bfed1 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -31,6 +31,28 @@ def test_run_dry_run_does_not_touch_a_cluster(): assert "kind: Pod" in result.output +def _without_run_id(manifest): + manifest = dict(manifest) + manifest["metadata"] = dict(manifest["metadata"]) + manifest["metadata"]["labels"] = { + k: v for k, v in manifest["metadata"]["labels"].items() if k != "squarepeg.io/run-id" + } + return manifest + + +def test_run_dry_run_output_unaffected_by_force_color(monkeypatch): + """--dry-run's YAML is machine-parseable output and must never be colourised/decorated, + regardless of $FORCE_COLOR -- only squarepeg's own status chatter goes through ui.py.""" + args = ["run", "--no-default-config", "--name", "squarepeg-pinned-name", "--dry-run", "alpine"] + plain = CliRunner().invoke(cli, args) + monkeypatch.setenv("FORCE_COLOR", "1") + forced = CliRunner().invoke(cli, args) + assert "\x1b" not in forced.output + # squarepeg.io/run-id is a fresh random UUID per invocation by design; everything else + # must be byte-identical regardless of $FORCE_COLOR. + assert _without_run_id(yaml.safe_load(plain.output)) == _without_run_id(yaml.safe_load(forced.output)) + + def test_run_missing_image_errors(): result = CliRunner().invoke(cli, ["run"]) assert result.exit_code != 0 @@ -221,15 +243,7 @@ def fake_server_dry_run(session, spec, manifest): # squarepeg.io/run-id is a fresh random UUID per invocation by design; strip it before # comparing, since it's the one label that's expected to legitimately differ here. - def without_run_id(manifest): - manifest = dict(manifest) - manifest["metadata"] = dict(manifest["metadata"]) - manifest["metadata"]["labels"] = { - k: v for k, v in manifest["metadata"]["labels"].items() if k != "squarepeg.io/run-id" - } - return manifest - - assert without_run_id(captured["manifest"]) == without_run_id(yaml.safe_load(dry_run_result.output)) + assert _without_run_id(captured["manifest"]) == _without_run_id(yaml.safe_load(dry_run_result.output)) def test_dry_run_server_constructs_session_with_context_and_quiet(monkeypatch): @@ -281,3 +295,75 @@ def raising_session(namespace=None, context=None, quiet=False): assert result.exit_code != 0 assert result.exception.exit_code == 125 assert "kubeconfig" in str(result.exception) + + +# --- config group: bare 'config' defaults to 'show' --- + + +def test_config_bare_equals_config_show(): + bare = CliRunner().invoke(cli, ["config", "--no-default-config"]) + show = CliRunner().invoke(cli, ["config", "show", "--no-default-config"]) + assert bare.exit_code == 0, bare.output + assert show.exit_code == 0, show.output + assert bare.output == show.output + + +def test_config_bare_no_sources_message(): + result = CliRunner().invoke(cli, ["config", "--no-default-config"]) + assert result.exit_code == 0, result.output + assert "no config files loaded" in result.output + + +def test_config_bare_respects_profile(tmp_path): + config_file = tmp_path / "config.yaml" + config_file.write_text("profiles:\n p:\n namespace: from-profile\n") + result = CliRunner().invoke( + cli, ["config", "--no-default-config", "--config", str(config_file), "--profile", "p"] + ) + assert result.exit_code == 0, result.output + assert "from-profile" in result.output + + +def test_config_option_before_subcommand_matches_bare(tmp_path): + """Group-level options given before 'show' must still apply -- proves the default_map + seeding in the group callback actually wires the options through, rather than an + explicit 'show' silently discarding them.""" + config_file = tmp_path / "config.yaml" + config_file.write_text("namespace: from-config\n") + before = CliRunner().invoke(cli, ["config", "--no-default-config", "--config", str(config_file), "show"]) + bare = CliRunner().invoke(cli, ["config", "--no-default-config", "--config", str(config_file)]) + assert before.exit_code == 0, before.output + assert bare.exit_code == 0, bare.output + assert before.output == bare.output + assert "from-config" in before.output + + +def test_config_explicit_show_option_wins_over_group_level(tmp_path): + """An explicit 'config show --profile q' must win over a group-level '--profile p'.""" + config_file = tmp_path / "config.yaml" + config_file.write_text( + "profiles:\n p:\n namespace: from-p\n q:\n namespace: from-q\n" + ) + result = CliRunner().invoke( + cli, + [ + "config", + "--no-default-config", + "--config", + str(config_file), + "--profile", + "p", + "show", + "--profile", + "q", + ], + ) + assert result.exit_code == 0, result.output + assert "from-q" in result.output + assert "from-p" not in result.output + + +def test_config_unknown_subcommand_errors(): + result = CliRunner().invoke(cli, ["config", "bogus"]) + assert result.exit_code != 0 + assert "No such command 'bogus'" in result.output diff --git a/tests/test_runner.py b/tests/test_runner.py index 3a02618..8d4b23b 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -294,6 +294,33 @@ def test_run_manifest_happy_path_calls_in_order(monkeypatch): session.core.delete_namespaced_pod.assert_called_once_with("squarepeg-alpine-abc123", "default") +def test_run_manifest_never_writes_status_output_to_stdout(monkeypatch, capsys): + """The colour/spinner mechanism must never touch stdout -- only the container's own + output (here faked by stream_logs) may land there.""" + + def fake_stream_logs(session, pod_name, container_name, stop_event, *, quiet=False): + import sys + + sys.stdout.buffer.write(b"container output line\n") + sys.stdout.buffer.flush() + + monkeypatch.setattr(runner, "stream_logs", fake_stream_logs) + session = make_session() + monkeypatch.setattr(runner.watch, "Watch", lambda: FakeWatch([{"object": pod_object(phase="Succeeded")}])) + cs = container_status(name="main", terminated_code=0, terminated_reason="Completed") + session.core.read_namespaced_pod.return_value = pod_object(container_statuses=[cs]) + + manifest = {"metadata": {"name": "squarepeg-alpine-abc123"}} + code = runner.run_manifest(session, basic_spec(mode="pod"), manifest) + + captured = capsys.readouterr() + assert code == 0 + assert captured.out == "container output line\n" + assert "container output line" not in captured.err + assert "creating" in captured.err + assert "deleting" in captured.err + + def test_run_manifest_does_not_truncate_logs_for_already_terminal_pod(monkeypatch): """Regression test: when the pod is already Succeeded/Failed by the time wait_until_running_or_terminal returns (typical for fast-exiting containers), the log diff --git a/tests/test_ui.py b/tests/test_ui.py new file mode 100644 index 0000000..f2360bf --- /dev/null +++ b/tests/test_ui.py @@ -0,0 +1,110 @@ +import threading + +import pytest + +from squarepeg import ui + +LEVELS = ["info", "step", "wait", "success", "warn", "error", "detail"] + + +@pytest.fixture(autouse=True) +def _reset_color_override(): + ui.set_color_override(None) + yield + ui.set_color_override(None) + + +# --- emit: colour / env var precedence --- + + +@pytest.mark.parametrize("level", LEVELS) +def test_emit_no_ansi_under_capsys_by_default(capsys, level): + """capsys is a non-TTY, so click.echo's own colour autodetection should strip ANSI.""" + ui.emit("hello world", level=level) + assert "\x1b" not in capsys.readouterr().err + + +def test_emit_force_color_adds_ansi(monkeypatch, capsys): + monkeypatch.setenv("FORCE_COLOR", "1") + ui.emit("hello", level="step") + assert "\x1b[36m" in capsys.readouterr().err + + +def test_no_color_beats_force_color(monkeypatch, capsys): + monkeypatch.setenv("FORCE_COLOR", "1") + monkeypatch.setenv("NO_COLOR", "1") + ui.emit("hello", level="step") + assert "\x1b" not in capsys.readouterr().err + + +def test_no_color_override_beats_force_color(monkeypatch, capsys): + monkeypatch.setenv("FORCE_COLOR", "1") + ui.set_color_override(False) + ui.emit("hello", level="step") + assert "\x1b" not in capsys.readouterr().err + + +@pytest.mark.parametrize("level", LEVELS) +def test_emit_quiet_prints_nothing(capsys, level): + ui.emit("should not appear", level=level, quiet=True) + assert capsys.readouterr().err == "" + + +@pytest.mark.parametrize("level", LEVELS) +def test_emit_body_is_contiguous_substring_even_with_color_forced(monkeypatch, capsys, level): + """Guards the never-style-a-sub-span rule: existing tests assert on exact substrings of + chatter output via capsys, and must keep passing even if colour is forced on.""" + monkeypatch.setenv("FORCE_COLOR", "1") + ui.emit("swept 3 orphaned resource(s) from previous runs", level=level) + assert "swept 3 orphaned resource(s) from previous runs" in capsys.readouterr().err + + +# --- Status: non-TTY degrades to a single static line, no thread --- + + +def test_status_non_tty_prints_one_static_line_including_slow_hint(capsys): + before = threading.active_count() + with ui.Status("waiting for pod to start", slow_hint="still pulling", slow_after=999): + assert threading.active_count() == before + err = capsys.readouterr().err + assert "waiting for pod to start" in err + assert "still pulling" in err + + +def test_status_non_tty_no_thread_spawned(capsys): + before = threading.active_count() + with ui.Status("doing a thing"): + assert threading.active_count() == before + assert threading.active_count() == before + + +def test_status_quiet_emits_nothing_including_success(capsys): + with ui.Status("doing a thing", quiet=True, success="done"): + pass + assert capsys.readouterr().err == "" + + +def test_status_success_printed_on_clean_exit(capsys): + with ui.Status("doing a thing", success="all done"): + pass + assert "all done" in capsys.readouterr().err + + +def test_status_success_not_printed_on_exception(capsys): + from squarepeg.errors import RunnerError + + with pytest.raises(RunnerError): + with ui.Status("doing a thing", success="all done"): + raise RunnerError("boom") + err = capsys.readouterr().err + assert "all done" not in err + + +def test_status_propagates_exceptions_and_leaves_no_live_thread(): + from squarepeg.errors import RunnerError + + before = threading.active_count() + with pytest.raises(RunnerError): + with ui.Status("doing a thing"): + raise RunnerError("boom") + assert threading.active_count() == before From 072c8b83c6377e0276adcde059e873abbc190099 Mon Sep 17 00:00:00 2001 From: Biowilko Date: Thu, 6 Aug 2026 12:09:20 +0100 Subject: [PATCH 2/4] add ascii logo --- squarepeg/cli.py | 150 ++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 123 insertions(+), 27 deletions(-) diff --git a/squarepeg/cli.py b/squarepeg/cli.py index 5033621..d067ea3 100644 --- a/squarepeg/cli.py +++ b/squarepeg/cli.py @@ -2,8 +2,17 @@ import yaml from squarepeg import __version__, ui -from squarepeg.config import coerce_bool, coerce_int, load_effective_config, select_profile -from squarepeg.dockerargs import check_supported_image, parse_env_entries, reject_unsupported +from squarepeg.config import ( + coerce_bool, + coerce_int, + load_effective_config, + select_profile, +) +from squarepeg.dockerargs import ( + check_supported_image, + parse_env_entries, + reject_unsupported, +) from squarepeg.errors import SquarepegError, UsageError from squarepeg.k8s.dryrun import server_dry_run from squarepeg.k8s.runner import run_manifest @@ -17,7 +26,11 @@ _CONFIG_OPTIONS = [ click.option( - "--config", "config_paths", multiple=True, type=click.Path(), help="repeatable, layered left-to-right" + "--config", + "config_paths", + multiple=True, + type=click.Path(), + help="repeatable, layered left-to-right", ), click.option("--no-default-config", "no_default_config", is_flag=True), click.option("--profile", "profile", default=None), @@ -31,7 +44,9 @@ def _add_config_options(cmd): def _resolve_config(config_paths, no_default_config, profile): - effective, sources = load_effective_config(config_paths, no_default_config=no_default_config) + effective, sources = load_effective_config( + config_paths, no_default_config=no_default_config + ) resolved = select_profile(effective, profile) return resolved, sources @@ -75,24 +90,62 @@ def _add_unsupported_options(cmd): return cmd -@click.group() +_LOGO = r""" + _____ + / ___/____ ___ ______ _________ ____ ___ ____ _ + \__ \/ __ `/ / / / __ `/ ___/ _ \/ __ \/ _ \/ __ `/ + ___/ / /_/ / /_/ / /_/ / / / __/ /_/ / __/ /_/ / +/____/\__, /\__,_/\__,_/_/ \___/ .___/\___/\__, / + /_/ /_/ /____/ +""".strip("\n") + +# a plain docstring can't be an f-string (only string literals populate __doc__), so the +# logo is spliced in via Command's own help= kwarg instead. +_CLI_HELP = f"\b\n{_LOGO}\n\nRun a docker-run-style command as a Kubernetes Pod or Job." + + +@click.group(help=_CLI_HELP) @click.version_option(__version__, prog_name="squarepeg") def cli(): - """Run a docker-run-style command as a Kubernetes Pod or Job.""" + pass @cli.command( context_settings={"allow_interspersed_args": False, "ignore_unknown_options": True}, ) @click.option("-e", "--env", "env_entries", multiple=True, help="KEY=VALUE, repeatable") -@click.option("-v", "--volume", "volume_entries", multiple=True, help="[NAME|/host]:/container[:ro|rw], repeatable") +@click.option( + "-v", + "--volume", + "volume_entries", + multiple=True, + help="[NAME|/host]:/container[:ro|rw], repeatable", +) @click.option("--name", "name", default=None, help="Pod/Job name") @click.option("-w", "--workdir", "workdir", default=None) -@click.option("--entrypoint", "entrypoint", default=None, help="Override the image ENTRYPOINT (-> k8s 'command')") -@click.option("-i", "--interactive", "interactive", is_flag=True, help="accepted; stdin is not forwarded") +@click.option( + "--entrypoint", + "entrypoint", + default=None, + help="Override the image ENTRYPOINT (-> k8s 'command')", +) +@click.option( + "-i", + "--interactive", + "interactive", + is_flag=True, + help="accepted; stdin is not forwarded", +) @click.option("-t", "--tty", "tty", is_flag=True) -@click.option("--rm", "rm_flag", is_flag=True, help="accepted no-op; cleanup is already the default") -@click.option("--pull", "pull", type=click.Choice(["always", "missing", "never"]), default=None) +@click.option( + "--rm", + "rm_flag", + is_flag=True, + help="accepted no-op; cleanup is already the default", +) +@click.option( + "--pull", "pull", type=click.Choice(["always", "missing", "never"]), default=None +) @click.option("--cpus", "cpus", default=None) @click.option("-m", "--memory", "memory", default=None) @click.option("--request-cpu", "request_cpu", default=None) @@ -101,8 +154,12 @@ def cli(): @click.option("--limit-memory", "limit_memory", default=None) @click.option("--mode", "mode", type=click.Choice(["pod", "job"]), default=None) @click.option("-n", "--namespace", "namespace", default=None) -@click.option("--keep", "keep", is_flag=True, help="do not delete the pod/job after it finishes") -@click.option("--dry-run", "dry_run", is_flag=True, help="render the manifest without creating it") +@click.option( + "--keep", "keep", is_flag=True, help="do not delete the pod/job after it finishes" +) +@click.option( + "--dry-run", "dry_run", is_flag=True, help="render the manifest without creating it" +) @click.option( "--dry-run-server", "dry_run_server", @@ -111,7 +168,12 @@ def cli(): ) @click.option("--timeout", "timeout", type=int, default=None) @click.option("--quiet", "quiet", is_flag=True) -@click.option("--no-color", "no_color", is_flag=True, help="disable colour/animation in squarepeg's own stderr output") +@click.option( + "--no-color", + "no_color", + is_flag=True, + help="disable colour/animation in squarepeg's own stderr output", +) @click.option("--context", "context", default=None, help="kubeconfig context to use") @click.option("--container-name", "container_name", default="main") @click.argument("image") @@ -158,10 +220,14 @@ def run( if dry_run and dry_run_server: raise UsageError("--dry-run and --dry-run-server are mutually exclusive") - resolved_config, _sources = _resolve_config(config_paths, no_default_config, profile) + resolved_config, _sources = _resolve_config( + config_paths, no_default_config, profile + ) config_defaults = resolved_config.get("defaults") or {} config_volumes = resolved_config.get("volumes") or {} - allow_host_path_mounts = coerce_bool(resolved_config.get("allow_host_path_mounts", False), "allow_host_path_mounts") + allow_host_path_mounts = coerce_bool( + resolved_config.get("allow_host_path_mounts", False), "allow_host_path_mounts" + ) claims: set[str] = set() @@ -170,12 +236,18 @@ def run( claims.add(f"/spec/containers/[name={container_name}]/env/[name={key}]") cli_volumes = [ - parse_volume_flag(v, config_volumes=config_volumes, allow_host_path_mounts=allow_host_path_mounts) + parse_volume_flag( + v, + config_volumes=config_volumes, + allow_host_path_mounts=allow_host_path_mounts, + ) for v in volume_entries ] for vol in cli_volumes: claims.add(f"/spec/volumes/[name={vol.volume_name}]") - claims.add(f"/spec/containers/[name={container_name}]/volumeMounts/[name={vol.volume_name}]") + claims.add( + f"/spec/containers/[name={container_name}]/volumeMounts/[name={vol.volume_name}]" + ) # config-declared auto-mounts (volumes.NAME with a mount_path) are not claimed -- they're # config-driven, not CLI-driven, so kubernetes.spec passthrough in the same config can still @@ -204,7 +276,9 @@ def run( claims.add(f"/spec/containers/[name={container_name}]/resources/limits/cpu") if request_memory is not None: resources.request_memory = request_memory - claims.add(f"/spec/containers/[name={container_name}]/resources/requests/memory") + claims.add( + f"/spec/containers/[name={container_name}]/resources/requests/memory" + ) if limit_memory is not None: resources.limit_memory = limit_memory claims.add(f"/spec/containers/[name={container_name}]/resources/limits/memory") @@ -213,7 +287,9 @@ def run( if cli_set_cpus and limit_cpu is None: claims.add(f"/spec/containers/[name={container_name}]/resources/limits/cpu") if cli_set_memory and request_memory is None: - claims.add(f"/spec/containers/[name={container_name}]/resources/requests/memory") + claims.add( + f"/spec/containers/[name={container_name}]/resources/requests/memory" + ) if cli_set_memory and limit_memory is None: claims.add(f"/spec/containers/[name={container_name}]/resources/limits/memory") @@ -228,7 +304,11 @@ def run( else: namespace = resolved_config.get("namespace") - pull_policy = {"always": "Always", "missing": "IfNotPresent", "never": "Never"}.get(pull) if pull else None + pull_policy = ( + {"always": "Always", "missing": "IfNotPresent", "never": "Never"}.get(pull) + if pull + else None + ) if pull_policy is not None: claims.add(f"/spec/containers/[name={container_name}]/imagePullPolicy") elif "image_pull_policy" in config_defaults: @@ -240,13 +320,25 @@ def run( workdir = config_defaults["workdir"] mode = mode or resolved_config.get("mode", "pod") - timeout = timeout if timeout is not None else coerce_int(resolved_config.get("timeout", 300), "timeout") + timeout = ( + timeout + if timeout is not None + else coerce_int(resolved_config.get("timeout", 300), "timeout") + ) quiet = quiet or coerce_bool(resolved_config.get("quiet", False), "quiet") - cleanup = False if keep else coerce_bool(resolved_config.get("cleanup", True), "cleanup") - orphan_sweep = coerce_bool(resolved_config.get("orphan_sweep", True), "orphan_sweep") - orphan_sweep_min_age = coerce_int(resolved_config.get("orphan_sweep_min_age", 300), "orphan_sweep_min_age") + cleanup = ( + False if keep else coerce_bool(resolved_config.get("cleanup", True), "cleanup") + ) + orphan_sweep = coerce_bool( + resolved_config.get("orphan_sweep", True), "orphan_sweep" + ) + orphan_sweep_min_age = coerce_int( + resolved_config.get("orphan_sweep_min_age", 300), "orphan_sweep_min_age" + ) if orphan_sweep_min_age < 0: - raise UsageError(f"'orphan_sweep_min_age' must not be negative, got {orphan_sweep_min_age}") + raise UsageError( + f"'orphan_sweep_min_age' must not be negative, got {orphan_sweep_min_age}" + ) entrypoint_tuple = (entrypoint,) if entrypoint is not None else None if entrypoint_tuple is not None: @@ -361,7 +453,11 @@ def main(): except click.exceptions.Exit as exc: raise SystemExit(exc.exit_code) from exc except SquarepegError as exc: - click.echo(click.style(f"squarepeg: {exc}", fg="red", bold=True), err=True, color=ui.color_enabled()) + click.echo( + click.style(f"squarepeg: {exc}", fg="red", bold=True), + err=True, + color=ui.color_enabled(), + ) raise SystemExit(exc.exit_code) from exc From 2b527cfb7ce55a618e9d351b741b9d43863fb50c Mon Sep 17 00:00:00 2001 From: Biowilko Date: Thu, 6 Aug 2026 12:12:09 +0100 Subject: [PATCH 3/4] Print ascii logo above all help text --- squarepeg/cli.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/squarepeg/cli.py b/squarepeg/cli.py index d067ea3..8614696 100644 --- a/squarepeg/cli.py +++ b/squarepeg/cli.py @@ -99,15 +99,18 @@ def _add_unsupported_options(cmd): /_/ /_/ /____/ """.strip("\n") -# a plain docstring can't be an f-string (only string literals populate __doc__), so the -# logo is spliced in via Command's own help= kwarg instead. -_CLI_HELP = f"\b\n{_LOGO}\n\nRun a docker-run-style command as a Kubernetes Pod or Job." +class _LogoGroup(click.Group): + """Prints the ASCII logo above the 'Usage:' line, rather than as part of the help body.""" + def format_usage(self, ctx, formatter): + formatter.write(_LOGO + "\n\n") + super().format_usage(ctx, formatter) -@click.group(help=_CLI_HELP) + +@click.group(cls=_LogoGroup) @click.version_option(__version__, prog_name="squarepeg") def cli(): - pass + """Run a docker-run-style command as a Kubernetes Pod or Job.""" @cli.command( From 05ce65a86a4eec1b0e737d847a374c0a4d3c27b1 Mon Sep 17 00:00:00 2001 From: Biowilko Date: Thu, 6 Aug 2026 12:37:00 +0100 Subject: [PATCH 4/4] bump version --- README.md | 9 +++++++++ pyproject.toml | 2 +- squarepeg/__init__.py | 7 ++++++- 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index fd6bba4..2ddfe1a 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,12 @@ +``` + _____ + / ___/____ ___ ______ _________ ____ ___ ____ _ + \__ \/ __ `/ / / / __ `/ ___/ _ \/ __ \/ _ \/ __ `/ + ___/ / /_/ / /_/ / /_/ / / / __/ /_/ / __/ /_/ / +/____/\__, /\__,_/\__,_/_/ \___/ .___/\___/\__, / + /_/ /_/ /____/ +``` + # squarepeg Run a `docker run`-style command as a Kubernetes Pod or Job, streaming its diff --git a/pyproject.toml b/pyproject.toml index 3e8b9e4..f5f3c9b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "squarepeg" -version = "0.1.0" +version = "0.2.0" description = "Run a docker-run-style command as a Kubernetes Pod or Job, streaming its output like a local process" readme = "README.md" requires-python = ">=3.11" diff --git a/squarepeg/__init__.py b/squarepeg/__init__.py index 3dc1f76..7bf3717 100644 --- a/squarepeg/__init__.py +++ b/squarepeg/__init__.py @@ -1 +1,6 @@ -__version__ = "0.1.0" +from importlib.metadata import PackageNotFoundError, version + +try: + __version__ = version("squarepeg") +except PackageNotFoundError: # running from a source checkout that was never pip-installed + __version__ = "0.0.0+unknown"