Skip to content

Commit 7c14f42

Browse files
committed
feat(cli): add scan spinner and verbose logger handling
1 parent 3da0a07 commit 7c14f42

5 files changed

Lines changed: 137 additions & 6 deletions

File tree

.ai/workflows.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,8 @@ quick_reference:
297297
test_all: "pytest"
298298
test_fast: "pytest --no-cov -q"
299299
test_with_coverage: "pytest --cov"
300+
scan_verbose: "argus scan --verbose --severity-threshold none"
301+
scan_no_spinner: "argus scan --no-spinner --severity-threshold none"
300302
lint: "npm run lint"
301303
release: "npm run release"
302304
format: "npm run format"

argus/audit/logger.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,31 @@ def get_logger(
115115
"""
116116
logger = logging.getLogger(name)
117117
if logger.handlers:
118-
return logger # Already configured
118+
# Existing loggers should still honor a later verbose request,
119+
# especially when shared across command flows.
120+
for handler in logger.handlers:
121+
if isinstance(handler, logging.StreamHandler):
122+
desired_level = logging.DEBUG if verbose else logging.INFO
123+
if handler.level != desired_level:
124+
handler.setLevel(desired_level)
125+
126+
# Add file logging if this call requests it and none exists yet.
127+
has_file_handler = any(
128+
isinstance(handler, logging.FileHandler)
129+
for handler in logger.handlers
130+
)
131+
if output_dir and not has_file_handler:
132+
log_dir = Path(output_dir)
133+
log_dir.mkdir(parents=True, exist_ok=True)
134+
log_path = log_dir / "argus.log"
135+
file_handler = logging.FileHandler(
136+
log_path, mode="a", encoding="utf-8"
137+
)
138+
file_handler.setLevel(logging.DEBUG)
139+
file_handler.setFormatter(JsonLogFormatter())
140+
logger.addHandler(file_handler)
141+
142+
return logger
119143

120144
logger.setLevel(logging.DEBUG)
121145

argus/cli.py

Lines changed: 96 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@
22

33
import argparse
44
import sys
5+
import threading
6+
import time
57
from pathlib import Path
8+
from typing import TextIO
69

710
# Exit codes
811
EXIT_SUCCESS = 0
@@ -11,6 +14,79 @@
1114

1215
SEVERITY_CHOICES = ["critical", "high", "medium", "low", "none"]
1316
FORMAT_CHOICES = ["terminal", "markdown", "sarif", "json"]
17+
_SPINNER_STYLES = [
18+
["⠁", "⠂", "⠄", "⡀", "⢀", "⠠", "⠐", "⠈"],
19+
["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
20+
["⠿", "⣟", "⣯", "⣷", "⣾", "⣽", "⣻", "⢿"],
21+
]
22+
23+
24+
class _TerminalSpinner:
25+
"""Minimal terminal spinner for long-running CLI operations."""
26+
27+
def __init__(
28+
self,
29+
message: str,
30+
enabled: bool,
31+
stream: TextIO | None = None,
32+
interval: float = 0.08,
33+
):
34+
self._message = message
35+
self._enabled = enabled
36+
self._stream = stream or sys.stderr
37+
self._interval = interval
38+
self._stop_event = threading.Event()
39+
self._thread: threading.Thread | None = None
40+
self._start_ts = 0.0
41+
42+
def __enter__(self):
43+
self._start_ts = time.monotonic()
44+
if self._enabled:
45+
self._thread = threading.Thread(target=self._spin, daemon=True)
46+
self._thread.start()
47+
return self
48+
49+
def __exit__(self, exc_type, _exc, _tb):
50+
elapsed = time.monotonic() - self._start_ts
51+
if self._enabled:
52+
self._stop_event.set()
53+
if self._thread:
54+
self._thread.join(timeout=0.2)
55+
self._clear_line()
56+
57+
status = "done" if exc_type is None else "failed"
58+
print(f"{self._message} [{status} in {elapsed:.1f}s]", file=self._stream)
59+
return False
60+
61+
def _spin(self) -> None:
62+
style_index = 0
63+
frame_index = 0
64+
65+
while not self._stop_event.is_set():
66+
frames = _SPINNER_STYLES[style_index]
67+
frame = frames[frame_index]
68+
self._stream.write(f"\r{self._message} {frame}")
69+
self._stream.flush()
70+
71+
frame_index += 1
72+
if frame_index >= len(frames):
73+
frame_index = 0
74+
style_index = (style_index + 1) % len(_SPINNER_STYLES)
75+
76+
self._stop_event.wait(self._interval)
77+
78+
def _clear_line(self) -> None:
79+
self._stream.write("\r" + (" " * 80) + "\r")
80+
self._stream.flush()
81+
82+
83+
def _spinner_enabled(args: argparse.Namespace) -> bool:
84+
"""Enable spinner for interactive terminals unless explicitly disabled."""
85+
if getattr(args, "no_spinner", False):
86+
return False
87+
if getattr(args, "verbose", False):
88+
return False
89+
return sys.stderr.isatty()
1490

1591

1692
def _make_run_dir(base_dir: str) -> str:
@@ -178,6 +254,11 @@ def _build_scan_parser(subparsers: argparse._SubParsersAction) -> None:
178254
action="store_true",
179255
help="Enable verbose output",
180256
)
257+
scan_parser.add_argument(
258+
"--no-spinner",
259+
action="store_true",
260+
help="Disable animated spinner output",
261+
)
181262

182263
# Container-specific flags (used with: argus scan container)
183264
container_group = scan_parser.add_argument_group(
@@ -480,7 +561,11 @@ def _cmd_source_scan(args: argparse.Namespace) -> int:
480561
try:
481562
scanner_names = [args.scanner] if args.scanner else None
482563
log.info("Running scanners: %s", scanner_names or "all enabled")
483-
summary = engine.run(scanner_names=scanner_names, path=args.path)
564+
with _TerminalSpinner(
565+
message="Running scanners",
566+
enabled=_spinner_enabled(args),
567+
):
568+
summary = engine.run(scanner_names=scanner_names, path=args.path)
484569
log.info(
485570
"Scan complete: %d scanner(s), %d finding(s)",
486571
len(summary.results),
@@ -547,7 +632,11 @@ def _cmd_container_scan(args: argparse.Namespace) -> int:
547632
# Run
548633
try:
549634
engine = ContainerEngine(config)
550-
summary = engine.run()
635+
with _TerminalSpinner(
636+
message="Running container scan",
637+
enabled=_spinner_enabled(args),
638+
):
639+
summary = engine.run()
551640
except Exception as exc:
552641
print(f"Error: container scan failed: {exc}", file=sys.stderr)
553642
return EXIT_ERROR
@@ -627,7 +716,11 @@ def _cmd_dast_scan(args: argparse.Namespace) -> int:
627716

628717
try:
629718
engine = DastEngine(config)
630-
summary = engine.run()
719+
with _TerminalSpinner(
720+
message="Running DAST scan",
721+
enabled=_spinner_enabled(args),
722+
):
723+
summary = engine.run()
631724
except Exception as exc:
632725
print(f"Error: DAST scan failed: {exc}", file=sys.stderr)
633726
return EXIT_ERROR
@@ -1079,8 +1172,6 @@ def _show_logo_easter_egg() -> int:
10791172
Hidden trigger: `argus __logo`
10801173
Not part of argparse, so it stays out of generated docs/help text.
10811174
"""
1082-
import time
1083-
10841175
try:
10851176
from argus.init import _load_banner
10861177
lines = _load_banner().splitlines()

argus/tests/audit/test_logger.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,3 +221,11 @@ def test_non_verbose_sets_info_on_console(self):
221221
h for h in logger.handlers if isinstance(h, logging.StreamHandler)
222222
)
223223
assert console_handler.level == logging.INFO
224+
225+
def test_existing_logger_honors_later_verbose(self):
226+
logger = get_logger("argus.test.reconfigure", verbose=False)
227+
logger = get_logger("argus.test.reconfigure", verbose=True)
228+
console_handler = next(
229+
h for h in logger.handlers if isinstance(h, logging.StreamHandler)
230+
)
231+
assert console_handler.level == logging.DEBUG

argus/tests/test_cli.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ def test_scan_default_args(self):
2121
assert args.formats is None
2222
assert args.list is False
2323
assert args.verbose is False
24+
assert args.no_spinner is False
2425

2526
def test_scan_with_scanner_name(self):
2627
parser = build_parser()
@@ -85,6 +86,11 @@ def test_scan_verbose_flag(self):
8586
args = parser.parse_args(["scan", "--verbose"])
8687
assert args.verbose is True
8788

89+
def test_scan_no_spinner_flag(self):
90+
parser = build_parser()
91+
args = parser.parse_args(["scan", "--no-spinner"])
92+
assert args.no_spinner is True
93+
8894

8995
class TestReportSubcommand:
9096
"""Test parsing of the 'report' subcommand."""

0 commit comments

Comments
 (0)