From 23331802b740a5ea2186aca40ab1050e98d1fa13 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 30 Jun 2026 16:04:53 -0600 Subject: [PATCH 1/2] fix(cli): handle startup interrupts gracefully --- CHANGELOG.md | 6 ++++ modelaudit/__main__.py | 32 +++++++++++++++++-- .../utils/helpers/test_interrupt_handling.py | 27 ++++++++++++++++ 3 files changed, 63 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c46eb9954..e05d01a97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Bug Fixes + +- **cli:** handle interrupts during CLI startup without a traceback + ## [0.2.49](https://github.com/promptfoo/modelaudit/compare/v0.2.48...v0.2.49) (2026-06-25) ### Bug Fixes diff --git a/modelaudit/__main__.py b/modelaudit/__main__.py index a0da2a639..ed8d08e44 100644 --- a/modelaudit/__main__.py +++ b/modelaudit/__main__.py @@ -1,6 +1,34 @@ """Entry point for running modelaudit as a module with python -m modelaudit.""" -from .cli import main +import signal +import sys +from types import FrameType + + +class _StartupInterrupted(BaseException): + """Keep Click from converting a startup SIGINT into a generic abort.""" + + +def _raise_startup_interrupt(signum: int, frame: FrameType | None) -> None: + del signum, frame + raise _StartupInterrupted + + +def _run() -> None: + """Run the CLI while keeping startup interrupts user-friendly.""" + original_sigint_handler = signal.signal(signal.SIGINT, _raise_startup_interrupt) + try: + # Keep this import inside the guard: CLI imports can take long enough for + # a user to interrupt before the scan-specific handler is installed. + from .cli import main + + main() + except (_StartupInterrupted, KeyboardInterrupt): + print("Scan interrupted by user", file=sys.stderr) + raise SystemExit(2) from None + finally: + signal.signal(signal.SIGINT, original_sigint_handler) + if __name__ == "__main__": - main() + _run() diff --git a/tests/utils/helpers/test_interrupt_handling.py b/tests/utils/helpers/test_interrupt_handling.py index 1d64b696c..54fd8fb8a 100644 --- a/tests/utils/helpers/test_interrupt_handling.py +++ b/tests/utils/helpers/test_interrupt_handling.py @@ -1,5 +1,7 @@ """Test interrupt handling functionality.""" +import builtins +import runpy import signal import subprocess import sys @@ -10,6 +12,31 @@ import pytest +def test_module_entrypoint_handles_interrupt_during_cli_import( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + """Startup Ctrl+C should not leak a raw import traceback.""" + original_import = builtins.__import__ + + def interrupt_cli_import( + name: str, + globals: dict[str, object] | None = None, + locals: dict[str, object] | None = None, + fromlist: tuple[str, ...] = (), + level: int = 0, + ) -> object: + if name == "cli" and level == 1: + raise KeyboardInterrupt + return original_import(name, globals, locals, fromlist, level) + + monkeypatch.setattr(builtins, "__import__", interrupt_cli_import) + + with pytest.raises(SystemExit, match="2"): + runpy.run_module("modelaudit", run_name="__main__") + + assert capsys.readouterr().err == "Scan interrupted by user\n" + + def test_interrupt_handler_basic(): """Test basic interrupt handler functionality.""" from modelaudit.utils.helpers.interrupt_handler import ( From ccde1e42a7f11b1b800df1144f0b1b9b11828c56 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Wed, 1 Jul 2026 08:57:59 -0600 Subject: [PATCH 2/2] fix(cli): cover startup interrupt entrypoints --- modelaudit/__init__.py | 11 ++++++++--- pyproject.toml | 2 +- tests/conftest.py | 1 + tests/utils/helpers/test_interrupt_handling.py | 16 ++++++++++++++++ 4 files changed, 26 insertions(+), 4 deletions(-) diff --git a/modelaudit/__init__.py b/modelaudit/__init__.py index f272d670d..46b22d7a6 100644 --- a/modelaudit/__init__.py +++ b/modelaudit/__init__.py @@ -8,13 +8,14 @@ import sys from typing import TYPE_CHECKING -from .scanner_results import Issue, IssueSeverity, ScanResult -from .version import __version__ - if TYPE_CHECKING: from modelaudit.core import scan_file as scan_file from modelaudit.core import scan_model_directory_or_file as scan_model_directory_or_file + from modelaudit.scanner_results import Issue as Issue + from modelaudit.scanner_results import IssueSeverity as IssueSeverity + from modelaudit.scanner_results import ScanResult as ScanResult from modelaudit.scanners.base import BaseScanner as BaseScanner + from modelaudit.version import __version__ as __version__ if sys.version_info < (3, 10): # noqa: UP036 — intentional safety net for bypassed requires-python import warnings @@ -28,6 +29,10 @@ # Public API — lazy-loaded to avoid circular imports at package init time. _LAZY_IMPORTS: dict[str, tuple[str, str]] = { + "Issue": ("modelaudit.scanner_results", "Issue"), + "IssueSeverity": ("modelaudit.scanner_results", "IssueSeverity"), + "ScanResult": ("modelaudit.scanner_results", "ScanResult"), + "__version__": ("modelaudit.version", "__version__"), "scan_file": ("modelaudit.core", "scan_file"), "scan_model_directory_or_file": ("modelaudit.core", "scan_model_directory_or_file"), "BaseScanner": ("modelaudit.scanners.base", "BaseScanner"), diff --git a/pyproject.toml b/pyproject.toml index 8287bc5cf..c387d0e9d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -148,7 +148,7 @@ all = [ ] [project.scripts] -modelaudit = "modelaudit.cli:main" +modelaudit = "modelaudit.__main__:_run" [project.urls] Repository = "https://github.com/promptfoo/modelaudit" diff --git a/tests/conftest.py b/tests/conftest.py index 819480e3b..af710eb84 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -151,6 +151,7 @@ def pytest_runtest_setup(item): "test_base_scanner.py", "test_core.py", "test_cli.py", + "test_interrupt_handling.py", # CLI startup and scan interruption regressions "test_sarif_formatter.py", # SARIF output and credential-redaction regressions "test_sarif_redaction.py", # SARIF exported source credential-redaction regressions "test_directory_file_filtering.py", # Directory prefilter regression tests diff --git a/tests/utils/helpers/test_interrupt_handling.py b/tests/utils/helpers/test_interrupt_handling.py index 54fd8fb8a..b41b79a3a 100644 --- a/tests/utils/helpers/test_interrupt_handling.py +++ b/tests/utils/helpers/test_interrupt_handling.py @@ -37,6 +37,22 @@ def interrupt_cli_import( assert capsys.readouterr().err == "Scan interrupted by user\n" +def test_package_init_defers_scanner_result_imports() -> None: + """Package discovery must stay lightweight until the startup guard runs.""" + result = subprocess.run( + [ + sys.executable, + "-c", + "import modelaudit, sys; assert 'modelaudit.scanner_results' not in sys.modules", + ], + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 0, result.stderr + + def test_interrupt_handler_basic(): """Test basic interrupt handler functionality.""" from modelaudit.utils.helpers.interrupt_handler import (