Skip to content

Commit 839c7cf

Browse files
fix(cli): handle startup interrupts gracefully (#1723)
* fix(cli): handle startup interrupts gracefully * fix(cli): cover startup interrupt entrypoints
1 parent 84187ba commit 839c7cf

6 files changed

Lines changed: 89 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [Unreleased]
9+
10+
### Bug Fixes
11+
12+
- **cli:** handle interrupts during CLI startup without a traceback
13+
814
## [0.2.49](https://github.com/promptfoo/modelaudit/compare/v0.2.48...v0.2.49) (2026-06-25)
915

1016
### Bug Fixes

modelaudit/__init__.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,14 @@
88
import sys
99
from typing import TYPE_CHECKING
1010

11-
from .scanner_results import Issue, IssueSeverity, ScanResult
12-
from .version import __version__
13-
1411
if TYPE_CHECKING:
1512
from modelaudit.core import scan_file as scan_file
1613
from modelaudit.core import scan_model_directory_or_file as scan_model_directory_or_file
14+
from modelaudit.scanner_results import Issue as Issue
15+
from modelaudit.scanner_results import IssueSeverity as IssueSeverity
16+
from modelaudit.scanner_results import ScanResult as ScanResult
1717
from modelaudit.scanners.base import BaseScanner as BaseScanner
18+
from modelaudit.version import __version__ as __version__
1819

1920
if sys.version_info < (3, 10): # noqa: UP036 — intentional safety net for bypassed requires-python
2021
import warnings
@@ -28,6 +29,10 @@
2829

2930
# Public API — lazy-loaded to avoid circular imports at package init time.
3031
_LAZY_IMPORTS: dict[str, tuple[str, str]] = {
32+
"Issue": ("modelaudit.scanner_results", "Issue"),
33+
"IssueSeverity": ("modelaudit.scanner_results", "IssueSeverity"),
34+
"ScanResult": ("modelaudit.scanner_results", "ScanResult"),
35+
"__version__": ("modelaudit.version", "__version__"),
3136
"scan_file": ("modelaudit.core", "scan_file"),
3237
"scan_model_directory_or_file": ("modelaudit.core", "scan_model_directory_or_file"),
3338
"BaseScanner": ("modelaudit.scanners.base", "BaseScanner"),

modelaudit/__main__.py

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,34 @@
11
"""Entry point for running modelaudit as a module with python -m modelaudit."""
22

3-
from .cli import main
3+
import signal
4+
import sys
5+
from types import FrameType
6+
7+
8+
class _StartupInterrupted(BaseException):
9+
"""Keep Click from converting a startup SIGINT into a generic abort."""
10+
11+
12+
def _raise_startup_interrupt(signum: int, frame: FrameType | None) -> None:
13+
del signum, frame
14+
raise _StartupInterrupted
15+
16+
17+
def _run() -> None:
18+
"""Run the CLI while keeping startup interrupts user-friendly."""
19+
original_sigint_handler = signal.signal(signal.SIGINT, _raise_startup_interrupt)
20+
try:
21+
# Keep this import inside the guard: CLI imports can take long enough for
22+
# a user to interrupt before the scan-specific handler is installed.
23+
from .cli import main
24+
25+
main()
26+
except (_StartupInterrupted, KeyboardInterrupt):
27+
print("Scan interrupted by user", file=sys.stderr)
28+
raise SystemExit(2) from None
29+
finally:
30+
signal.signal(signal.SIGINT, original_sigint_handler)
31+
432

533
if __name__ == "__main__":
6-
main()
34+
_run()

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ all = [
148148
]
149149

150150
[project.scripts]
151-
modelaudit = "modelaudit.cli:main"
151+
modelaudit = "modelaudit.__main__:_run"
152152

153153
[project.urls]
154154
Repository = "https://github.com/promptfoo/modelaudit"

tests/conftest.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ def pytest_runtest_setup(item):
151151
"test_base_scanner.py",
152152
"test_core.py",
153153
"test_cli.py",
154+
"test_interrupt_handling.py", # CLI startup and scan interruption regressions
154155
"test_sarif_formatter.py", # SARIF output and credential-redaction regressions
155156
"test_sarif_redaction.py", # SARIF exported source credential-redaction regressions
156157
"test_directory_file_filtering.py", # Directory prefilter regression tests

tests/utils/helpers/test_interrupt_handling.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
"""Test interrupt handling functionality."""
22

3+
import builtins
4+
import runpy
35
import signal
46
import subprocess
57
import sys
@@ -10,6 +12,47 @@
1012
import pytest
1113

1214

15+
def test_module_entrypoint_handles_interrupt_during_cli_import(
16+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
17+
) -> None:
18+
"""Startup Ctrl+C should not leak a raw import traceback."""
19+
original_import = builtins.__import__
20+
21+
def interrupt_cli_import(
22+
name: str,
23+
globals: dict[str, object] | None = None,
24+
locals: dict[str, object] | None = None,
25+
fromlist: tuple[str, ...] = (),
26+
level: int = 0,
27+
) -> object:
28+
if name == "cli" and level == 1:
29+
raise KeyboardInterrupt
30+
return original_import(name, globals, locals, fromlist, level)
31+
32+
monkeypatch.setattr(builtins, "__import__", interrupt_cli_import)
33+
34+
with pytest.raises(SystemExit, match="2"):
35+
runpy.run_module("modelaudit", run_name="__main__")
36+
37+
assert capsys.readouterr().err == "Scan interrupted by user\n"
38+
39+
40+
def test_package_init_defers_scanner_result_imports() -> None:
41+
"""Package discovery must stay lightweight until the startup guard runs."""
42+
result = subprocess.run(
43+
[
44+
sys.executable,
45+
"-c",
46+
"import modelaudit, sys; assert 'modelaudit.scanner_results' not in sys.modules",
47+
],
48+
capture_output=True,
49+
text=True,
50+
check=False,
51+
)
52+
53+
assert result.returncode == 0, result.stderr
54+
55+
1356
def test_interrupt_handler_basic():
1457
"""Test basic interrupt handler functionality."""
1558
from modelaudit.utils.helpers.interrupt_handler import (

0 commit comments

Comments
 (0)