Skip to content

Commit 7654c14

Browse files
authored
refactor: Change exception handling in cli (#277)
1 parent c741322 commit 7654c14

File tree

8 files changed

+109
-16
lines changed

8 files changed

+109
-16
lines changed

src/twyn/base/exceptions.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,33 @@
66
logger = logging.getLogger("twyn.errors")
77

88

9-
class TwynError(click.ClickException):
9+
class TwynError(Exception):
10+
"""
11+
Base exception from where all application errors will inherit.
12+
13+
Provides a default message field, that subclasses will override to provide more information in case it is not provided during the exception handling.
14+
"""
15+
1016
message = ""
1117

1218
def __init__(self, message: str = "") -> None:
1319
super().__init__(message or self.message)
1420

21+
22+
class CliError(click.ClickException):
23+
"""Error that will populate application errors to stdout. It does not inherit from `TwynError`."""
24+
25+
message = "CLI error"
26+
27+
def __init__(self, message: str = "") -> None:
28+
super().__init__(message)
29+
1530
def show(self, file: Optional[IO[Any]] = None) -> None:
1631
logger.debug(self.format_message(), exc_info=True)
1732
logger.error(self.format_message(), exc_info=False)
1833

1934

2035
class PackageNormalizingError(TwynError):
2136
"""Exception for when it is not possible to normalize a package name."""
37+
38+
message = "Failed to normalize pacakges."

src/twyn/cli.py

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
SELECTOR_METHOD_MAPPING,
1313
AvailableLoggingLevels,
1414
)
15+
from twyn.base.exceptions import CliError, TwynError
1516
from twyn.config.config_handler import ConfigHandler
1617
from twyn.file_handler.file_handler import FileHandler
1718
from twyn.main import check_dependencies
@@ -104,14 +105,19 @@ def run(
104105
if dependency_file and not any(dependency_file.endswith(key) for key in DEPENDENCY_FILE_MAPPING):
105106
raise click.UsageError("Dependency file name not supported.", ctx=click.get_current_context())
106107

107-
errors = check_dependencies(
108-
dependencies=set(dependency) or None,
109-
config_file=config,
110-
dependency_file=dependency_file,
111-
selector_method=selector_method,
112-
verbosity=verbosity,
113-
use_cache=not no_cache,
114-
)
108+
try:
109+
errors = check_dependencies(
110+
dependencies=set(dependency) or None,
111+
config_file=config,
112+
dependency_file=dependency_file,
113+
selector_method=selector_method,
114+
verbosity=verbosity,
115+
use_cache=not no_cache,
116+
)
117+
except TwynError as e:
118+
raise CliError(e.message) from e
119+
except Exception as e:
120+
raise CliError("Unhandled exception occured.") from e
115121

116122
if errors:
117123
for possible_typosquats in errors:

src/twyn/config/exceptions.py

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,37 @@
22

33

44
class TOMLError(TwynError):
5+
"""TOML exception class."""
6+
7+
message = "TOML parsing error"
8+
59
def __init__(self, message: str):
610
super().__init__(message)
711

812

9-
class AllowlistError(TwynError):
10-
def __init__(self, package_name: str = ""):
13+
class BaseAllowlistError(TwynError):
14+
"""Base `allowlist` exception."""
15+
16+
message = "Allowlist error"
17+
18+
def __init__(self, package_name: str = "") -> None:
1119
message = self.message.format(package_name) if package_name else self.message
1220
super().__init__(message)
1321

1422

15-
class AllowlistPackageAlreadyExistsError(AllowlistError):
23+
class AllowlistPackageAlreadyExistsError(BaseAllowlistError):
24+
"""Exception class for when a package already exists in the allowlist."""
25+
1626
message = "Package '{}' is already present in the allowlist. Skipping."
1727

1828

19-
class AllowlistPackageDoesNotExistError(AllowlistError):
29+
class AllowlistPackageDoesNotExistError(BaseAllowlistError):
30+
"""Exception class for when it is not possible to locate the desired pacakge in the allowlist."""
31+
2032
message = "Package '{}' is not present in the allowlist. Skipping."
2133

2234

2335
class InvalidSelectorMethodError(TwynError):
2436
"""Exception for when an invalid selector method has been specified."""
37+
38+
message = "Invalid `Selector` was provided."

src/twyn/dependency_parser/exceptions.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,12 @@
22

33

44
class NoMatchingParserError(TwynError):
5+
"""Exception raised when no suitable dependency file parser can be automatically determined."""
6+
57
message = "Could not assign a dependency file parser. Please specify it with --dependency-file"
68

79

810
class MultipleParsersError(TwynError):
11+
"""Exception raised when multiple dependency file parsers are detected."""
12+
913
message = "Can't auto detect dependencies file to parse. More than one format was found."

src/twyn/file_handler/exceptions.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,12 @@
22

33

44
class PathIsNotFileError(TwynError):
5+
"""Exception raised when a specified path exists but is not a regular file."""
6+
57
message = "Specified dependencies path is not a file"
68

79

8-
class PathNotFoundError(TwynError, FileNotFoundError):
10+
class PathNotFoundError(TwynError):
11+
"""Exception raised when a specified file path does not exist in the filesystem."""
12+
913
message = "Specified dependencies file path does not exist"

src/twyn/similarity/exceptions.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,12 @@
22

33

44
class DistanceAlgorithmError(TwynError):
5+
"""Exception raised while running distance algorithm."""
6+
57
message = "Exception raised while running distance algorithm"
68

79

810
class ThresholdError(TwynError, ValueError):
11+
"""Exception raised when minimum threshold is greater than maximum threshold."""
12+
913
message = "Minimum threshold cannot be greater than maximum threshold."

src/twyn/trusted_packages/exceptions.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,30 @@
22

33

44
class InvalidJSONError(TwynError):
5+
"""Exception raised when JSON decoding of downloaded packages list fails."""
6+
57
message = "Could not json decode the downloaded packages list"
68

79

8-
class InvalidPyPiFormatError(TwynError, KeyError):
10+
class InvalidPyPiFormatError(TwynError):
11+
"""Exception raised when PyPI JSON format is invalid."""
12+
913
message = "Invalid JSON format."
1014

1115

1216
class EmptyPackagesListError(TwynError):
17+
"""Exception raised when downloaded packages list is empty."""
18+
1319
message = "Downloaded packages list is empty"
1420

1521

16-
class CharacterNotInMatrixError(TwynError, KeyError): ... # TODO add comments
22+
class CharacterNotInMatrixError(TwynError):
23+
"""Exception raised when a character is not found in the similarity matrix."""
24+
25+
message = "Character not found in similarity matrix"
1726

1827

1928
class InvalidCacheError(TwynError):
2029
"""Error for when the cache content is not valid."""
30+
31+
message = "Invalid cache content"

tests/main/test_cli.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from click.testing import CliRunner
55
from twyn import cli
66
from twyn.base.constants import AvailableLoggingLevels
7+
from twyn.base.exceptions import TwynError
78
from twyn.trusted_packages.cache_handler import CacheEntry, CacheHandler
89
from twyn.trusted_packages.trusted_packages import TyposquatCheckResult
910

@@ -216,3 +217,35 @@ def test_dependency_file_name_has_to_be_recognized(self):
216217
assert isinstance(result.exception, SystemExit)
217218
assert result.exit_code == 2
218219
assert "Dependency file name not supported." in result.output
220+
221+
@patch("twyn.cli.check_dependencies")
222+
def test_base_twyn_error_is_caught_and_wrapped_in_cli_error(self, mock_check_dependencies, caplog):
223+
"""Test that BaseTwynError is caught and wrapped in CliError."""
224+
runner = CliRunner()
225+
226+
# Mock check_dependencies to raise a BaseTwynError
227+
test_error = TwynError("Test base error")
228+
test_error.message = "Test base error message"
229+
mock_check_dependencies.side_effect = test_error
230+
231+
result = runner.invoke(cli.run, ["--dependency", "requests"])
232+
233+
assert result.exit_code == 1
234+
assert isinstance(result.exception, SystemExit)
235+
# Check that the error message was logged
236+
assert "Test base error message" in caplog.text
237+
238+
@patch("twyn.cli.check_dependencies")
239+
def test_unhandled_exception_is_caught_and_wrapped_in_cli_error(self, mock_check_dependencies, caplog):
240+
"""Test that unhandled exceptions are caught and wrapped in CliError."""
241+
runner = CliRunner()
242+
243+
# Mock check_dependencies to raise a generic exception
244+
mock_check_dependencies.side_effect = ValueError("Unexpected error")
245+
246+
result = runner.invoke(cli.run, ["--dependency", "requests"])
247+
248+
assert result.exit_code == 1
249+
assert isinstance(result.exception, SystemExit)
250+
# Check that the generic error message was logged
251+
assert "Unhandled exception occured." in caplog.text

0 commit comments

Comments
 (0)