Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 8 additions & 3 deletions modelaudit/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"),
Expand Down
32 changes: 30 additions & 2 deletions modelaudit/__main__.py
Original file line number Diff line number Diff line change
@@ -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)
Comment thread
mldangelo-oai marked this conversation as resolved.
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
Comment thread
mldangelo-oai marked this conversation as resolved.

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()
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ all = [
]

[project.scripts]
modelaudit = "modelaudit.cli:main"
modelaudit = "modelaudit.__main__:_run"

[project.urls]
Repository = "https://github.com/promptfoo/modelaudit"
Expand Down
1 change: 1 addition & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 43 additions & 0 deletions tests/utils/helpers/test_interrupt_handling.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""Test interrupt handling functionality."""

import builtins
import runpy
import signal
import subprocess
import sys
Expand All @@ -10,6 +12,47 @@
import pytest


def test_module_entrypoint_handles_interrupt_during_cli_import(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
Comment thread
mldangelo-oai marked this conversation as resolved.
"""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_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 (
Expand Down
Loading