From 6bb980d93b903cc726efd112e59851b360a76c3b Mon Sep 17 00:00:00 2001 From: Daniel Sanz <13658011+sdn4z@users.noreply.github.com> Date: Thu, 28 Aug 2025 09:03:49 +0200 Subject: [PATCH] refactor: Change exception handling in cli --- src/twyn/base/exceptions.py | 19 +++++++++++++- src/twyn/cli.py | 22 ++++++++++------ src/twyn/config/exceptions.py | 22 +++++++++++++--- src/twyn/dependency_parser/exceptions.py | 4 +++ src/twyn/file_handler/exceptions.py | 6 ++++- src/twyn/similarity/exceptions.py | 4 +++ src/twyn/trusted_packages/exceptions.py | 15 +++++++++-- tests/main/test_cli.py | 33 ++++++++++++++++++++++++ 8 files changed, 109 insertions(+), 16 deletions(-) diff --git a/src/twyn/base/exceptions.py b/src/twyn/base/exceptions.py index e7e479b..82d1fff 100644 --- a/src/twyn/base/exceptions.py +++ b/src/twyn/base/exceptions.py @@ -6,12 +6,27 @@ logger = logging.getLogger("twyn.errors") -class TwynError(click.ClickException): +class TwynError(Exception): + """ + Base exception from where all application errors will inherit. + + Provides a default message field, that subclasses will override to provide more information in case it is not provided during the exception handling. + """ + message = "" def __init__(self, message: str = "") -> None: super().__init__(message or self.message) + +class CliError(click.ClickException): + """Error that will populate application errors to stdout. It does not inherit from `TwynError`.""" + + message = "CLI error" + + def __init__(self, message: str = "") -> None: + super().__init__(message) + def show(self, file: Optional[IO[Any]] = None) -> None: logger.debug(self.format_message(), exc_info=True) logger.error(self.format_message(), exc_info=False) @@ -19,3 +34,5 @@ def show(self, file: Optional[IO[Any]] = None) -> None: class PackageNormalizingError(TwynError): """Exception for when it is not possible to normalize a package name.""" + + message = "Failed to normalize pacakges." diff --git a/src/twyn/cli.py b/src/twyn/cli.py index d582f70..03510b4 100644 --- a/src/twyn/cli.py +++ b/src/twyn/cli.py @@ -12,6 +12,7 @@ SELECTOR_METHOD_MAPPING, AvailableLoggingLevels, ) +from twyn.base.exceptions import CliError, TwynError from twyn.config.config_handler import ConfigHandler from twyn.file_handler.file_handler import FileHandler from twyn.main import check_dependencies @@ -104,14 +105,19 @@ def run( if dependency_file and not any(dependency_file.endswith(key) for key in DEPENDENCY_FILE_MAPPING): raise click.UsageError("Dependency file name not supported.", ctx=click.get_current_context()) - errors = check_dependencies( - dependencies=set(dependency) or None, - config_file=config, - dependency_file=dependency_file, - selector_method=selector_method, - verbosity=verbosity, - use_cache=not no_cache, - ) + try: + errors = check_dependencies( + dependencies=set(dependency) or None, + config_file=config, + dependency_file=dependency_file, + selector_method=selector_method, + verbosity=verbosity, + use_cache=not no_cache, + ) + except TwynError as e: + raise CliError(e.message) from e + except Exception as e: + raise CliError("Unhandled exception occured.") from e if errors: for possible_typosquats in errors: diff --git a/src/twyn/config/exceptions.py b/src/twyn/config/exceptions.py index ae0c35a..ce02347 100644 --- a/src/twyn/config/exceptions.py +++ b/src/twyn/config/exceptions.py @@ -2,23 +2,37 @@ class TOMLError(TwynError): + """TOML exception class.""" + + message = "TOML parsing error" + def __init__(self, message: str): super().__init__(message) -class AllowlistError(TwynError): - def __init__(self, package_name: str = ""): +class BaseAllowlistError(TwynError): + """Base `allowlist` exception.""" + + message = "Allowlist error" + + def __init__(self, package_name: str = "") -> None: message = self.message.format(package_name) if package_name else self.message super().__init__(message) -class AllowlistPackageAlreadyExistsError(AllowlistError): +class AllowlistPackageAlreadyExistsError(BaseAllowlistError): + """Exception class for when a package already exists in the allowlist.""" + message = "Package '{}' is already present in the allowlist. Skipping." -class AllowlistPackageDoesNotExistError(AllowlistError): +class AllowlistPackageDoesNotExistError(BaseAllowlistError): + """Exception class for when it is not possible to locate the desired pacakge in the allowlist.""" + message = "Package '{}' is not present in the allowlist. Skipping." class InvalidSelectorMethodError(TwynError): """Exception for when an invalid selector method has been specified.""" + + message = "Invalid `Selector` was provided." diff --git a/src/twyn/dependency_parser/exceptions.py b/src/twyn/dependency_parser/exceptions.py index 6581575..8c5747e 100644 --- a/src/twyn/dependency_parser/exceptions.py +++ b/src/twyn/dependency_parser/exceptions.py @@ -2,8 +2,12 @@ class NoMatchingParserError(TwynError): + """Exception raised when no suitable dependency file parser can be automatically determined.""" + message = "Could not assign a dependency file parser. Please specify it with --dependency-file" class MultipleParsersError(TwynError): + """Exception raised when multiple dependency file parsers are detected.""" + message = "Can't auto detect dependencies file to parse. More than one format was found." diff --git a/src/twyn/file_handler/exceptions.py b/src/twyn/file_handler/exceptions.py index 0c7d326..808740c 100644 --- a/src/twyn/file_handler/exceptions.py +++ b/src/twyn/file_handler/exceptions.py @@ -2,8 +2,12 @@ class PathIsNotFileError(TwynError): + """Exception raised when a specified path exists but is not a regular file.""" + message = "Specified dependencies path is not a file" -class PathNotFoundError(TwynError, FileNotFoundError): +class PathNotFoundError(TwynError): + """Exception raised when a specified file path does not exist in the filesystem.""" + message = "Specified dependencies file path does not exist" diff --git a/src/twyn/similarity/exceptions.py b/src/twyn/similarity/exceptions.py index eb17474..dc3aae8 100644 --- a/src/twyn/similarity/exceptions.py +++ b/src/twyn/similarity/exceptions.py @@ -2,8 +2,12 @@ class DistanceAlgorithmError(TwynError): + """Exception raised while running distance algorithm.""" + message = "Exception raised while running distance algorithm" class ThresholdError(TwynError, ValueError): + """Exception raised when minimum threshold is greater than maximum threshold.""" + message = "Minimum threshold cannot be greater than maximum threshold." diff --git a/src/twyn/trusted_packages/exceptions.py b/src/twyn/trusted_packages/exceptions.py index 28d440a..dc725ae 100644 --- a/src/twyn/trusted_packages/exceptions.py +++ b/src/twyn/trusted_packages/exceptions.py @@ -2,19 +2,30 @@ class InvalidJSONError(TwynError): + """Exception raised when JSON decoding of downloaded packages list fails.""" + message = "Could not json decode the downloaded packages list" -class InvalidPyPiFormatError(TwynError, KeyError): +class InvalidPyPiFormatError(TwynError): + """Exception raised when PyPI JSON format is invalid.""" + message = "Invalid JSON format." class EmptyPackagesListError(TwynError): + """Exception raised when downloaded packages list is empty.""" + message = "Downloaded packages list is empty" -class CharacterNotInMatrixError(TwynError, KeyError): ... # TODO add comments +class CharacterNotInMatrixError(TwynError): + """Exception raised when a character is not found in the similarity matrix.""" + + message = "Character not found in similarity matrix" class InvalidCacheError(TwynError): """Error for when the cache content is not valid.""" + + message = "Invalid cache content" diff --git a/tests/main/test_cli.py b/tests/main/test_cli.py index e7b9dc0..cd5cc7d 100644 --- a/tests/main/test_cli.py +++ b/tests/main/test_cli.py @@ -4,6 +4,7 @@ from click.testing import CliRunner from twyn import cli from twyn.base.constants import AvailableLoggingLevels +from twyn.base.exceptions import TwynError from twyn.trusted_packages.cache_handler import CacheEntry, CacheHandler from twyn.trusted_packages.trusted_packages import TyposquatCheckResult @@ -216,3 +217,35 @@ def test_dependency_file_name_has_to_be_recognized(self): assert isinstance(result.exception, SystemExit) assert result.exit_code == 2 assert "Dependency file name not supported." in result.output + + @patch("twyn.cli.check_dependencies") + def test_base_twyn_error_is_caught_and_wrapped_in_cli_error(self, mock_check_dependencies, caplog): + """Test that BaseTwynError is caught and wrapped in CliError.""" + runner = CliRunner() + + # Mock check_dependencies to raise a BaseTwynError + test_error = TwynError("Test base error") + test_error.message = "Test base error message" + mock_check_dependencies.side_effect = test_error + + result = runner.invoke(cli.run, ["--dependency", "requests"]) + + assert result.exit_code == 1 + assert isinstance(result.exception, SystemExit) + # Check that the error message was logged + assert "Test base error message" in caplog.text + + @patch("twyn.cli.check_dependencies") + def test_unhandled_exception_is_caught_and_wrapped_in_cli_error(self, mock_check_dependencies, caplog): + """Test that unhandled exceptions are caught and wrapped in CliError.""" + runner = CliRunner() + + # Mock check_dependencies to raise a generic exception + mock_check_dependencies.side_effect = ValueError("Unexpected error") + + result = runner.invoke(cli.run, ["--dependency", "requests"]) + + assert result.exit_code == 1 + assert isinstance(result.exception, SystemExit) + # Check that the generic error message was logged + assert "Unhandled exception occured." in caplog.text