diff --git a/argus/cli.py b/argus/cli.py index 63ae973..c94d268 100644 --- a/argus/cli.py +++ b/argus/cli.py @@ -81,16 +81,118 @@ def _clear_line(self) -> None: self._stream.write("\r" + (" " * 80) + "\r") self._stream.flush() + @property + def enabled(self) -> bool: + """True when the spinner thread is actively drawing.""" + return self._enabled + + def update_message(self, message: str) -> None: + """Replace the spinner's status text in place. + + Called from progress callbacks at phase transitions so the + user sees ``[2/4] scanner-opengrep — trivy (12s)`` updating + instead of a static "Running container scan". No-op when the + spinner isn't drawing — callers can pass through unconditionally. + """ + # Pad with trailing spaces to overwrite any longer previous + # message without leaving stale characters on the line. + prev = self._message + self._message = message + if self._enabled and len(prev) > len(message): + self._stream.write("\r" + (" " * len(prev)) + "\r") + self._stream.flush() + + +def _configure_logger(args: argparse.Namespace, output_dir: str | None = None): + """Set up the ``argus`` logger at the level the user's flags imply. + + Mapping: + * ``--verbose`` / ``--debug``: DEBUG (full firehose) + * ``--quiet``: WARNING (suppress INFO lines from the engine) + * default: INFO (per-phase status, no DEBUG noise) + + Returns the configured logger so callers can also use it directly. + """ + import logging + from argus.audit import get_logger + + log = get_logger( + "argus", + output_dir=output_dir, + verbose=_verbose_enabled(args), + ) + if _quiet_enabled(args): + for handler in log.handlers: + if isinstance(handler, logging.StreamHandler) and not isinstance( + handler, logging.FileHandler, + ): + handler.setLevel(logging.WARNING) + return log + + +def _verbose_enabled(args: argparse.Namespace) -> bool: + """``--verbose`` / ``--debug`` — full firehose mode. + + The two are aliases: ``--verbose`` is preserved for backward + compatibility, ``--debug`` is the clearer name new users learn. + """ + return getattr(args, "verbose", False) or getattr(args, "debug", False) + + +def _quiet_enabled(args: argparse.Namespace) -> bool: + """``--quiet`` / ``-q`` — suppress per-phase INFO lines. + + Spinner stays drawing (just doesn't update its message) unless + ``--no-spinner`` is also passed. Composes with ``--no-spinner``: + ``--quiet --no-spinner`` means fully silent (CI exit-code-only). + """ + return getattr(args, "quiet", False) + def _spinner_enabled(args: argparse.Namespace) -> bool: """Enable spinner for interactive terminals unless explicitly disabled.""" if getattr(args, "no_spinner", False): return False - if getattr(args, "verbose", False): + if _verbose_enabled(args): return False return sys.stderr.isatty() +def _make_progress_emitter( + args: argparse.Namespace, + spinner: "_TerminalSpinner | None", +): + """Return a progress callback that routes phase events to the right surface. + + Output target depends on the flag combination: + * ``--quiet`` or ``--verbose``: no-op (logger handles verbose; quiet + suppresses progress on purpose). + * Spinner is drawing: update its message in place. + * No spinner (``--no-spinner`` or non-TTY): print a persistent + INFO line to stderr per phase event so CI logs and step-away + terminals get scrollback. + """ + if _quiet_enabled(args) or _verbose_enabled(args): + return lambda *a, **kw: None + + if spinner is not None and spinner.enabled: + def update_spinner(idx: int, total: int, name: str, phase: str, elapsed_ms: int): + elapsed_s = elapsed_ms // 1000 + spinner.update_message( + f"[{idx}/{total}] {name} — {phase} ({elapsed_s}s)" + ) + return update_spinner + + def print_line(idx: int, total: int, name: str, phase: str, elapsed_ms: int): + elapsed_s = elapsed_ms // 1000 + print( + f"[{idx}/{total}] {name} — {phase} ({elapsed_s}s)", + file=sys.stderr, + flush=True, + ) + return print_line + + def _make_run_dir(base_dir: str) -> str: """Create a timestamped subdirectory for this scan run. @@ -486,9 +588,25 @@ def _build_scan_parser(subparsers: argparse._SubParsersAction) -> None: help="List available scanners and exit", ) scan_parser.add_argument( - "--verbose", "-v", + "--verbose", action="store_true", - help="Enable verbose output", + help="Alias for --debug. Full firehose: subprocess output, " + "vulnerability-DB updates, every engine log line.", + ) + scan_parser.add_argument( + "--debug", + action="store_true", + help="Full firehose: subprocess output, vulnerability-DB updates, " + "every engine log line. Use when troubleshooting; the default " + "phase-aware progress is enough for normal scans.", + ) + scan_parser.add_argument( + "--quiet", "-q", + action="store_true", + help="Suppress per-phase progress lines. The spinner still draws " + "(use --no-spinner to suppress that too). Final summary " + "still prints. Compose with --no-spinner for fully silent " + "CI exit-code-only mode.", ) scan_parser.add_argument( "--no-spinner", @@ -1754,15 +1872,14 @@ def _cmd_container_scan( that path is kept for backward compatibility with any caller that still bypasses ``cmd_scan``. """ - from argus.audit import get_logger from argus.container import ContainerEngine from argus.reporters.container_markdown import ContainerMarkdownReporter - # Configure the ``argus`` logger so engine-level INFO/DEBUG output - # actually reaches the terminal under ``--verbose``. Without this - # the container engine's logger.info() calls go nowhere — same - # setup the source-scan handler does at the top of its body. - get_logger("argus", verbose=getattr(args, "verbose", False)) + # Configure the ``argus`` logger at the user's chosen level — + # default INFO, --quiet WARNING, --verbose/--debug DEBUG. Without + # this the container engine's logger.info() calls would go + # nowhere even under --verbose. + _configure_logger(args) if container_config is None: try: @@ -1812,11 +1929,18 @@ def _cmd_container_scan( # Run try: - engine = ContainerEngine(config) + # Build the spinner first so the engine's progress callback + # can update its message in place. ``_make_progress_emitter`` + # picks the right routing (spinner vs persistent INFO line vs + # no-op) based on the user's --quiet/--debug/--no-spinner mix. with _TerminalSpinner( message="Running container scan", enabled=_spinner_enabled(args), - ): + ) as spinner: + engine = ContainerEngine( + config, + progress_callback=_make_progress_emitter(args, spinner), + ) summary = engine.run() except Exception as exc: print(f"Error: container scan failed: {exc}", file=sys.stderr) diff --git a/argus/container/engine.py b/argus/container/engine.py index 814f8ea..eb3a5ab 100644 --- a/argus/container/engine.py +++ b/argus/container/engine.py @@ -6,7 +6,9 @@ """ import logging +import time from pathlib import Path +from typing import Callable from .builder import build_image from .discovery import ( @@ -45,10 +47,22 @@ class ContainerEngine: cleanup: true # Remove images after scanning (default) """ - def __init__(self, config: dict): + def __init__( + self, + config: dict, + progress_callback: "Callable[[int, int, str, str, int], None] | None" = None, + ): self.config = config self._cleanup = config.get("cleanup", True) self._built_images: list[str] = [] + # Progress callback signature: (idx, total, name, phase, elapsed_ms). + # Default is a no-op so engine code can call ``self._progress(...)`` + # unconditionally without checking. The CLI installs a callback + # that updates the spinner message or prints a persistent INFO + # line based on flag combination. + self._progress: Callable[[int, int, str, str, int], None] = ( + progress_callback or (lambda *a, **kw: None) + ) def run(self) -> ContainerScanSummary: """Execute the full container scanning lifecycle. @@ -77,14 +91,25 @@ def run(self) -> ContainerScanSummary: ) results: list[ContainerScanResult] = [] + total = len(targets) for i, target in enumerate(targets, 1): logger.info( - "[%d/%d] Processing %s", i, len(targets), target.name, + "[%d/%d] Processing %s", i, total, target.name, ) - result = self._process_target(target) + # Emit phase progress at each transition. The callback + # decides whether to update the spinner, print a line, or + # ignore the event based on the user's CLI flags. + target_start = time.monotonic() + initial_phase = "build" if target.dockerfile else "pull" + self._progress(i, total, target.name, initial_phase, 0) + + result = self._process_target(target, idx=i, total=total, target_start=target_start) results.append(result) + elapsed_ms = int((time.monotonic() - target_start) * 1000) + self._progress(i, total, target.name, "done", elapsed_ms) + # Post-scan cleanup — free disk for the next container. # Per-target ``cleanup:`` overrides the engine-level default # so a long-lived base image can stay cached across runs @@ -111,13 +136,29 @@ def run(self) -> ContainerScanSummary: ) return summary - def _process_target(self, target: ContainerTarget) -> ContainerScanResult: + def _process_target( + self, + target: ContainerTarget, + idx: int = 1, + total: int = 1, + target_start: float | None = None, + ) -> ContainerScanResult: """Build (if needed) and scan a single container target. Catches disk space errors and other resource failures individually — a failure on one container doesn't stop the rest from being scanned. + + ``idx`` / ``total`` / ``target_start`` are progress-tracking + params used to emit phase events between build and scan. + Default values keep backward compat for direct test callers. """ + if target_start is None: + target_start = time.monotonic() + + def _elapsed() -> int: + return int((time.monotonic() - target_start) * 1000) + if target.dockerfile: success = build_image(target) if not success: @@ -137,6 +178,10 @@ def _process_target(self, target: ContainerTarget) -> ContainerScanResult: ) self._built_images.append(target.image_ref) + # Build is done (or skipped for remote-pull targets); transition + # to the scan phase so the spinner updates. + self._progress(idx, total, target.name, "scan", _elapsed()) + try: # If the dispatcher set ``_raw_output_root`` in the # config dict, persist this target's raw scanner outputs diff --git a/argus/tests/test_progress_emitter.py b/argus/tests/test_progress_emitter.py new file mode 100644 index 0000000..59bad45 --- /dev/null +++ b/argus/tests/test_progress_emitter.py @@ -0,0 +1,129 @@ +"""Tests for argus.cli._make_progress_emitter and the new flag-routing matrix. + +Locks in the four-mode UX surface — default / --quiet / --no-spinner / +--debug — all composing through orthogonal flags. +""" + +import argparse + +import pytest + +from argus.cli import ( + _TerminalSpinner, + _make_progress_emitter, + _quiet_enabled, + _verbose_enabled, +) + + +def _ns(**flags) -> argparse.Namespace: + """Build an argparse.Namespace with sensible defaults for testing.""" + base = { + "verbose": False, + "debug": False, + "quiet": False, + "no_spinner": False, + } + base.update(flags) + return argparse.Namespace(**base) + + +# --------------------------------------------------------------------- # +# Flag predicates # +# --------------------------------------------------------------------- # + + +class TestVerboseEnabled: + def test_no_flag_is_false(self): + assert _verbose_enabled(_ns()) is False + + def test_verbose_flag(self): + assert _verbose_enabled(_ns(verbose=True)) is True + + def test_debug_flag(self): + assert _verbose_enabled(_ns(debug=True)) is True + + def test_either_flag_alone_is_enough(self): + assert _verbose_enabled(_ns(verbose=True, debug=False)) is True + assert _verbose_enabled(_ns(verbose=False, debug=True)) is True + + +class TestQuietEnabled: + def test_no_flag_is_false(self): + assert _quiet_enabled(_ns()) is False + + def test_quiet_flag(self): + assert _quiet_enabled(_ns(quiet=True)) is True + + +# --------------------------------------------------------------------- # +# Progress emitter routing # +# --------------------------------------------------------------------- # + + +class TestProgressEmitter: + def test_quiet_returns_no_op(self, capsys): + emit = _make_progress_emitter(_ns(quiet=True), spinner=None) + emit(1, 4, "scanner-bandit", "build", 0) + # No output anywhere — quiet mode is silence. + out = capsys.readouterr() + assert out.out == "" + assert out.err == "" + + def test_verbose_returns_no_op(self, capsys): + # --verbose / --debug routes through the logger; the progress + # emitter is silent so we don't double-print phase lines. + emit = _make_progress_emitter(_ns(verbose=True), spinner=None) + emit(1, 4, "scanner-bandit", "build", 0) + out = capsys.readouterr() + assert out.out == "" + assert out.err == "" + + def test_debug_alias_returns_no_op(self, capsys): + emit = _make_progress_emitter(_ns(debug=True), spinner=None) + emit(1, 4, "scanner-bandit", "build", 0) + out = capsys.readouterr() + assert out.out == "" + assert out.err == "" + + def test_no_spinner_prints_persistent_lines(self, capsys): + # --no-spinner + non-quiet + non-verbose → phase events go to + # stderr as scrollback. This is the CI-friendly default. + emit = _make_progress_emitter(_ns(no_spinner=True), spinner=None) + emit(1, 4, "scanner-bandit", "build", 0) + emit(1, 4, "scanner-bandit", "scan", 12000) + emit(1, 4, "scanner-bandit", "done", 45000) + err = capsys.readouterr().err + assert "[1/4] scanner-bandit — build (0s)" in err + assert "[1/4] scanner-bandit — scan (12s)" in err + assert "[1/4] scanner-bandit — done (45s)" in err + + def test_disabled_spinner_falls_back_to_print(self, capsys): + # When the spinner exists but its enabled flag is False (non-TTY, + # for example), we still want phase lines to flow somewhere. + spinner = _TerminalSpinner(message="x", enabled=False) + emit = _make_progress_emitter(_ns(), spinner=spinner) + emit(2, 3, "myapp", "scan", 8000) + err = capsys.readouterr().err + assert "[2/3] myapp — scan (8s)" in err + + def test_enabled_spinner_routes_to_update_message(self, capsys): + # When the spinner IS drawing, phase events update its in-place + # message rather than printing scrollback lines. + spinner = _TerminalSpinner(message="initial", enabled=True) + emit = _make_progress_emitter(_ns(), spinner=spinner) + emit(2, 3, "myapp", "scan", 8000) + # The spinner thread isn't running (we never entered the + # context manager), but update_message still records the new + # text on the instance for inspection. + assert spinner._message == "[2/3] myapp — scan (8s)" + # And nothing leaked to stderr. + assert capsys.readouterr().err == "" + + def test_quiet_overrides_spinner_path(self, capsys): + # --quiet wins over a drawing spinner — the spinner stays, but + # its message doesn't update. + spinner = _TerminalSpinner(message="initial", enabled=True) + emit = _make_progress_emitter(_ns(quiet=True), spinner=spinner) + emit(1, 1, "x", "scan", 0) + assert spinner._message == "initial" diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 7898338..b579c43 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -1,6 +1,6 @@ # Argus CLI Reference (v0.7.2) -> Auto-generated from argparse definitions on 2026-05-05. +> Auto-generated from argparse definitions on 2026-05-06. > Do not edit manually — run `python -m scripts.ci.gen_cli_docs` to regenerate. Argus Security Scanner — comprehensive security scanning for your codebase @@ -17,6 +17,19 @@ argus [--version] [--help] [options] |------|-------------|---------| | `--version` | show program's version number and exit | | +## Output and verbosity + +`argus scan` exposes four flags that compose orthogonally — `--quiet` controls log verbosity, `--no-spinner` controls UI rendering, and `--debug` (alias `--verbose`) is the explicit troubleshooting opt-in. The four most useful modes: + +| Invocation | When to use | What you see | +|---|---|---| +| `argus scan` | Default — interactive terminal | Phase-aware spinner that updates per image and per scan phase | +| `argus scan --quiet` | Daily runs you don't want narrating | Spinner stays drawing, but per-phase chatter is suppressed; only WARNING/ERROR lines and the final summary print | +| `argus scan --no-spinner` | CI logs, step-away monitoring | Persistent `[idx/total] name — phase (Ns)` lines on stderr instead of a self-overwriting spinner | +| `argus scan --debug` (or `--verbose`) | Troubleshooting | Full firehose: subprocess output, vulnerability-DB updates, every engine log line | + +Compose flags for additional modes — `--quiet --no-spinner` is the fully-silent CI exit-code-only combination; `--debug --no-spinner` is identical to `--debug` since debug auto-disables the spinner. + ## Commands ### `argus init` @@ -58,8 +71,8 @@ argus scan [-h] [--path PATH] [--config CONFIG] [--output-dir OUTPUT_DIR] [--severity-threshold {critical,high,medium,low,none}] [--format {terminal,markdown,sarif,json}] [--list] - [--verbose] [--no-spinner] [--no-timestamp] - [--output-vars FILE] [--exclude PATTERNS] + [--verbose] [--debug] [--quiet] [--no-spinner] + [--no-timestamp] [--output-vars FILE] [--exclude PATTERNS] [--no-default-excludes] [--dry-run] [--sbom PATH] [--interface {terminal,browser}] [--fail-fast] [--fail-on-scanner-error] [--timeout SECONDS] @@ -85,7 +98,9 @@ argus scan [-h] [--path PATH] [--config CONFIG] | `--severity-threshold`, `-s` | Fail threshold severity level (default: from config) (critical, high, medium, low, none) | | | `--format`, `-f` | Output format (can be repeated; default: terminal) (terminal, markdown, sarif, json) | | | `--list` | List available scanners and exit | `false` | -| `--verbose`, `-v` | Enable verbose output | `false` | +| `--verbose` | Alias for --debug. Full firehose: subprocess output, vulnerability-DB updates, every engine log line. | `false` | +| `--debug` | Full firehose: subprocess output, vulnerability-DB updates, every engine log line. Use when troubleshooting; the default phase-aware progress is enough for normal scans. | `false` | +| `--quiet`, `-q` | Suppress per-phase progress lines. The spinner still draws (use --no-spinner to suppress that too). Final summary still prints. Compose with --no-spinner for fully silent CI exit-code-only mode. | `false` | | `--no-spinner` | Disable animated spinner output | `false` | | `--no-timestamp` | Write output directly to --output-dir without a timestamped subdirectory. Useful in CI where a predictable output path is needed. | `false` | | `--output-vars` | Write scan result counts as key=value pairs to FILE. Useful in CI: cat FILE >> $GITHUB_OUTPUT. Keys: critical_count, high_count, medium_count, low_count, total_count, passed. | | diff --git a/scripts/ci/gen_cli_docs.py b/scripts/ci/gen_cli_docs.py index 507e019..24deec0 100644 --- a/scripts/ci/gen_cli_docs.py +++ b/scripts/ci/gen_cli_docs.py @@ -57,6 +57,29 @@ def generate_cli_docs(output: str | None = None, fmt: str = "markdown") -> str: lines.extend(_format_options_table(top_options)) lines.append("") + # Output and verbosity — explains how the four output-control + # flags compose. Each flag's individual help text is also rendered + # below in the per-command tables; this section gives readers the + # mental model up front. + lines.extend([ + "## Output and verbosity", + "", + "`argus scan` exposes four flags that compose orthogonally —" + " `--quiet` controls log verbosity, `--no-spinner` controls UI" + " rendering, and `--debug` (alias `--verbose`) is the explicit" + " troubleshooting opt-in. The four most useful modes:", + "", + "| Invocation | When to use | What you see |", + "|---|---|---|", + "| `argus scan` | Default — interactive terminal | Phase-aware spinner that updates per image and per scan phase |", + "| `argus scan --quiet` | Daily runs you don't want narrating | Spinner stays drawing, but per-phase chatter is suppressed; only WARNING/ERROR lines and the final summary print |", + "| `argus scan --no-spinner` | CI logs, step-away monitoring | Persistent `[idx/total] name — phase (Ns)` lines on stderr instead of a self-overwriting spinner |", + "| `argus scan --debug` (or `--verbose`) | Troubleshooting | Full firehose: subprocess output, vulnerability-DB updates, every engine log line |", + "", + "Compose flags for additional modes — `--quiet --no-spinner` is the fully-silent CI exit-code-only combination; `--debug --no-spinner` is identical to `--debug` since debug auto-disables the spinner.", + "", + ]) + # Commands lines.append("## Commands") lines.append("")