diff --git a/src/twyn/cli.py b/src/twyn/cli.py index e5ca089e..c993a897 100644 --- a/src/twyn/cli.py +++ b/src/twyn/cli.py @@ -3,6 +3,7 @@ from typing import Optional import click +from rich.console import Console from rich.logging import RichHandler from twyn.__version__ import __version__ @@ -22,7 +23,7 @@ logging.basicConfig( format="%(message)s", datefmt="[%X]", - handlers=[RichHandler(rich_tracebacks=True, show_path=False)], + handlers=[RichHandler(rich_tracebacks=True, show_path=False, console=Console(stderr=True))], ) logger = logging.getLogger("twyn") @@ -82,6 +83,12 @@ def entry_point() -> None: default=False, help="Do not show the progress bar while processing packages.", ) +@click.option( + "--json", + is_flag=True, + default=False, + help="Display the results in json format. It implies --no-track.", +) def run( config: str, dependency_file: Optional[str], @@ -91,16 +98,12 @@ def run( vv: bool, no_cache: bool, no_track: bool, + json: bool, ) -> int: - if v and vv: - raise click.UsageError( - "Only one verbosity level is allowed. Choose either -v or -vv.", ctx=click.get_current_context() - ) - - if v: - verbosity = AvailableLoggingLevels.info - elif vv: + if vv: verbosity = AvailableLoggingLevels.debug + elif v: + verbosity = AvailableLoggingLevels.info else: verbosity = AvailableLoggingLevels.none @@ -113,26 +116,28 @@ def run( raise click.UsageError("Dependency file name not supported.", ctx=click.get_current_context()) try: - errors = check_dependencies( + possible_typos = 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, - use_track=not no_track, + use_track=False if json else not no_track, ) 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: + if json: + click.echo(possible_typos.model_dump_json()) + sys.exit(int(bool(possible_typos.errors))) + elif possible_typos.errors: + for possible_typosquats in possible_typos.errors: click.echo( - click.style("Possible typosquat detected: ", fg="red") - + f"`{possible_typosquats.candidate_dependency}`, " - f"did you mean any of [{', '.join(possible_typosquats.similar_dependencies)}]?", + click.style("Possible typosquat detected: ", fg="red") + f"`{possible_typosquats.dependency}`, " + f"did you mean any of [{', '.join(possible_typosquats.similars)}]?", color=True, ) sys.exit(1) diff --git a/src/twyn/main.py b/src/twyn/main.py index ced0d4e4..9d417ab1 100644 --- a/src/twyn/main.py +++ b/src/twyn/main.py @@ -19,7 +19,7 @@ from twyn.trusted_packages.selectors import AbstractSelector from twyn.trusted_packages.trusted_packages import ( TrustedPackages, - TyposquatCheckResult, + TyposquatCheckResultList, ) logger = logging.getLogger("twyn") @@ -33,7 +33,7 @@ def check_dependencies( verbosity: AvailableLoggingLevels = AvailableLoggingLevels.none, use_cache: bool = True, use_track: bool = False, -) -> list[TyposquatCheckResult]: +) -> TyposquatCheckResultList: """Check if dependencies could be typosquats.""" config_file_handler = FileHandler(config_file or DEFAULT_PROJECT_TOML_FILE) config = ConfigHandler(config_file_handler, enforce_file=False).resolve_config( @@ -52,7 +52,7 @@ def check_dependencies( dependencies = dependencies if dependencies else get_parsed_dependencies_from_file(config.dependency_file) normalized_dependencies = normalize_packages(dependencies) - errors: list[TyposquatCheckResult] = [] + typos_list = TyposquatCheckResultList() dependencies_list = ( track(normalized_dependencies, description="Processing...") if use_track else normalized_dependencies ) @@ -63,9 +63,9 @@ def check_dependencies( logger.info("Analyzing %s", dependency) if dependency not in trusted_packages and (typosquat_results := trusted_packages.get_typosquat(dependency)): - errors.append(typosquat_results) + typos_list.errors.append(typosquat_results) - return errors + return typos_list def _set_logging_level(logging_level: AvailableLoggingLevels) -> None: diff --git a/src/twyn/trusted_packages/trusted_packages.py b/src/twyn/trusted_packages/trusted_packages.py index 7da8bfeb..68b78026 100644 --- a/src/twyn/trusted_packages/trusted_packages.py +++ b/src/twyn/trusted_packages/trusted_packages.py @@ -1,7 +1,8 @@ from collections import defaultdict -from dataclasses import dataclass, field from typing import Any +from pydantic import BaseModel + from twyn.similarity.algorithm import ( AbstractSimilarityAlgorithm, SimilarityThreshold, @@ -11,19 +12,26 @@ _PackageNames = defaultdict[str, set[str]] -@dataclass -class TyposquatCheckResult: +class TyposquatCheckResult(BaseModel): """Represents the result of analyzing a dependency for a possible typosquat.""" - candidate_dependency: str - similar_dependencies: list[str] = field(default_factory=list) + dependency: str + similars: list[str] = [] def __bool__(self) -> bool: - return bool(self.similar_dependencies) + return bool(self.similars) def add(self, similar_name: str) -> None: """Add a similar dependency to this typosquat check result.""" - self.similar_dependencies.append(similar_name) + self.similars.append(similar_name) + + +class TyposquatCheckResultList(BaseModel): + errors: list[TyposquatCheckResult] = [] + + def get_typosquats(self) -> set[str]: + """Return a set containing all the detected packages with a typo.""" + return {typo.dependency for typo in self.errors} class TrustedPackages: @@ -65,7 +73,7 @@ def get_typosquat( are used to determine if the package name can be considered similar. """ threshold = self.threshold_class.from_name(package_name) - typosquat_result = TyposquatCheckResult(package_name) + typosquat_result = TyposquatCheckResult(dependency=package_name) for trusted_package_name in self.selector.select_similar_names(names=self.names, name=package_name): distance = self.algorithm.get_distance(package_name, trusted_package_name) if threshold.is_inside_threshold(distance): diff --git a/tests/conftest.py b/tests/conftest.py index fcb14cf6..330f2d19 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,12 +6,6 @@ import pytest -@pytest.fixture(autouse=True) -def disable_click_echo(): - with mock.patch("click.echo"): - yield - - @contextmanager def create_tmp_file(path: Path, data: str) -> Iterator[str]: path.write_text(data) diff --git a/tests/main/test_cli.py b/tests/main/test_cli.py index 1a6841c1..a6f4a815 100644 --- a/tests/main/test_cli.py +++ b/tests/main/test_cli.py @@ -1,12 +1,13 @@ from pathlib import Path -from unittest.mock import call, patch +from unittest.mock import Mock, call, patch +import pytest 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 +from twyn.trusted_packages.trusted_packages import TyposquatCheckResult, TyposquatCheckResultList class TestCli: @@ -36,7 +37,7 @@ def test_cache_clear_removes_all_cache_files(self, tmp_path: Path) -> None: assert len(cache_files) == 0 @patch("twyn.cli.check_dependencies") - def test_no_cache_option_disables_cache(self, mock_check_dependencies): + def test_no_cache_option_disables_cache(self, mock_check_dependencies: Mock) -> None: runner = CliRunner() runner.invoke( cli.run, @@ -46,7 +47,7 @@ def test_no_cache_option_disables_cache(self, mock_check_dependencies): assert mock_check_dependencies.call_args[1]["dependencies"] == {"requests"} @patch("twyn.config.config_handler.ConfigHandler.add_package_to_allowlist") - def test_allowlist_add_package_to_allowlist(self, mock_allowlist_add): + def test_allowlist_add_package_to_allowlist(self, mock_allowlist_add: Mock) -> None: runner = CliRunner() runner.invoke( cli.add, @@ -56,7 +57,7 @@ def test_allowlist_add_package_to_allowlist(self, mock_allowlist_add): assert mock_allowlist_add.call_args == call("requests") @patch("twyn.config.config_handler.ConfigHandler.remove_package_from_allowlist") - def test_allowlist_remove(self, mock_allowlist_add): + def test_allowlist_remove(self, mock_allowlist_add: Mock) -> None: runner = CliRunner() runner.invoke( cli.remove, @@ -66,7 +67,7 @@ def test_allowlist_remove(self, mock_allowlist_add): assert mock_allowlist_add.call_args == call("requests") @patch("twyn.cli.check_dependencies") - def test_click_arguments_dependency_file(self, mock_check_dependencies): + def test_click_arguments_dependency_file(self, mock_check_dependencies: Mock) -> None: runner = CliRunner() runner.invoke( cli.run, @@ -94,7 +95,7 @@ def test_click_arguments_dependency_file(self, mock_check_dependencies): ] @patch("twyn.cli.check_dependencies") - def test_click_arguments_dependency_file_in_different_path(self, mock_check_dependencies): + def test_click_arguments_dependency_file_in_different_path(self, mock_check_dependencies: Mock) -> None: runner = CliRunner() runner.invoke( cli.run, @@ -117,7 +118,7 @@ def test_click_arguments_dependency_file_in_different_path(self, mock_check_depe ] @patch("twyn.cli.check_dependencies") - def test_click_arguments_single_dependency_cli(self, mock_check_dependencies): + def test_click_arguments_single_dependency_cli(self, mock_check_dependencies: Mock) -> None: runner = CliRunner() runner.invoke( cli.run, @@ -148,7 +149,7 @@ def test_click_raises_error_dependency_and_dependency_file_set(self): assert "Only one of --dependency or --dependency-file can be set at a time." in result.output @patch("twyn.cli.check_dependencies") - def test_click_arguments_multiple_dependencies(self, mock_check_dependencies): + def test_click_arguments_multiple_dependencies(self, mock_check_dependencies: Mock) -> None: runner = CliRunner() runner.invoke( cli.run, @@ -173,7 +174,7 @@ def test_click_arguments_multiple_dependencies(self, mock_check_dependencies): ] @patch("twyn.cli.check_dependencies") - def test_click_arguments_default(self, mock_check_dependencies): + def test_click_arguments_default(self, mock_check_dependencies: Mock) -> None: runner = CliRunner() runner.invoke(cli.run) @@ -190,32 +191,57 @@ def test_click_arguments_default(self, mock_check_dependencies): ] @patch("twyn.cli.check_dependencies") - def test_return_code_1(self, mock_check_dependencies): + def test_return_code_1(self, mock_check_dependencies: Mock) -> None: runner = CliRunner() - mock_check_dependencies.return_value = [ - TyposquatCheckResult(candidate_dependency="my-package", similar_dependencies=["mypackage"]) - ] + mock_check_dependencies.return_value = TyposquatCheckResultList( + errors=[TyposquatCheckResult(dependency="my-package", similars=["mypackage"])] + ) result = runner.invoke(cli.run) assert result.exit_code == 1 + assert "did you mean any of [mypackage]" in result.output @patch("twyn.cli.check_dependencies") - def test_return_code_0(self, mock_check_dependencies): + def test_json_typo_detected(self, mock_check_dependencies: Mock) -> None: + mock_check_dependencies.return_value = TyposquatCheckResultList( + errors=[TyposquatCheckResult(dependency="my-package", similars=["mypackage"])] + ) runner = CliRunner() - mock_check_dependencies.return_value = [] + result = runner.invoke( + cli.run, + [ + "--json", + ], + ) + + assert result.exit_code == 1 + assert result.output == '{"errors":[{"dependency":"my-package","similars":["mypackage"]}]}\n' + + @patch("twyn.cli.check_dependencies") + def test_json_no_typo(self, mock_check_dependencies: Mock) -> None: + mock_check_dependencies.return_value = TyposquatCheckResultList(errors=[]) + runner = CliRunner() + result = runner.invoke( + cli.run, + [ + "--json", + ], + ) - result = runner.invoke(cli.run) assert result.exit_code == 0 + assert result.output == '{"errors":[]}\n' - def test_only_one_verbosity_level_is_allowed(self): + @patch("twyn.cli.check_dependencies") + def test_return_code_0(self, mock_check_dependencies: Mock) -> None: runner = CliRunner() - result = runner.invoke(cli.run, ["-v", "-vv"], catch_exceptions=False) + mock_check_dependencies.return_value = TyposquatCheckResultList() - assert isinstance(result.exception, SystemExit) - assert result.exit_code == 2 - assert "Only one verbosity level is allowed. Choose either -v or -vv." in result.output + result = runner.invoke(cli.run) + + assert result.exit_code == 0 + assert "No typosquats detected" in result.output - def test_dependency_file_name_has_to_be_recognized(self): + def test_dependency_file_name_has_to_be_recognized(self) -> None: runner = CliRunner() result = runner.invoke(cli.run, ["--dependency-file", "requirements-dev.txt"], catch_exceptions=False) @@ -241,7 +267,9 @@ def test_base_twyn_error_is_caught_and_wrapped_in_cli_error(self, mock_check_dep 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): + def test_unhandled_exception_is_caught_and_wrapped_in_cli_error( + self, mock_check_dependencies: Mock, caplog: pytest.LogCaptureFixture + ) -> None: """Test that unhandled exceptions are caught and wrapped in CliError.""" runner = CliRunner() diff --git a/tests/main/test_main.py b/tests/main/test_main.py index b7bc19d6..c85c8581 100644 --- a/tests/main/test_main.py +++ b/tests/main/test_main.py @@ -10,7 +10,7 @@ check_dependencies, get_parsed_dependencies_from_file, ) -from twyn.trusted_packages.trusted_packages import TyposquatCheckResult +from twyn.trusted_packages.trusted_packages import TyposquatCheckResult, TyposquatCheckResultList from tests.conftest import create_tmp_file @@ -154,7 +154,9 @@ def test_check_dependencies_detects_typosquats( selector_method="first-letter", ) - assert error == [TyposquatCheckResult(candidate_dependency="my-package", similar_dependencies=["mypackage"])] + assert error == TyposquatCheckResultList( + errors=[TyposquatCheckResult(dependency="my-package", similars=["mypackage"])] + ) @patch("twyn.trusted_packages.references.TopPyPiReference._get_packages_from_cache") def test_check_dependencies_with_input_from_cli_detects_typosquats(self, mock_get_packages_from_cache): @@ -166,12 +168,14 @@ def test_check_dependencies_with_input_from_cli_detects_typosquats(self, mock_ge selector_method="first-letter", ) - assert error == [TyposquatCheckResult(candidate_dependency="my-package", similar_dependencies=["mypackage"])] + assert error == TyposquatCheckResultList( + errors=[TyposquatCheckResult(dependency="my-package", similars=["mypackage"])] + ) @patch("twyn.trusted_packages.references.TopPyPiReference._get_packages_from_cache") def test_check_dependencies_with_input_loads_file_from_different_location( self, mock_get_packages_from_cache, tmp_path, tmpdir - ): + ) -> None: mock_get_packages_from_cache.return_value = {"mypackage"} tmpdir.mkdir("fake-dir") tmp_file = tmp_path / "fake-dir" / "requirements.txt" @@ -183,7 +187,9 @@ def test_check_dependencies_with_input_loads_file_from_different_location( selector_method="first-letter", ) - assert error == [TyposquatCheckResult(candidate_dependency="mypackag", similar_dependencies=["mypackage"])] + assert error == TyposquatCheckResultList( + errors=[TyposquatCheckResult(dependency="mypackag", similars=["mypackage"])] + ) @patch("twyn.trusted_packages.references.TopPyPiReference._get_packages_from_cache") def test_check_dependencies_with_input_from_cli_accepts_multiple_dependencies(self, mock_get_packages_from_cache): @@ -196,9 +202,9 @@ def test_check_dependencies_with_input_from_cli_accepts_multiple_dependencies(se selector_method="first-letter", ) - assert len(error) == 2 - assert TyposquatCheckResult(candidate_dependency="reqests", similar_dependencies=["requests"]) in error - assert TyposquatCheckResult(candidate_dependency="my-package", similar_dependencies=["mypackage"]) in error + assert len(error.errors) == 2 + assert TyposquatCheckResult(dependency="reqests", similars=["requests"]) in error.errors + assert TyposquatCheckResult(dependency="my-package", similars=["mypackage"]) in error.errors @patch("twyn.main.TopPyPiReference") @patch("twyn.main.get_parsed_dependencies_from_file") @@ -216,7 +222,9 @@ def test_check_dependencies_ignores_package_in_allowlist( selector_method="first-letter", ) - assert error == [TyposquatCheckResult(candidate_dependency="my-package", similar_dependencies=["mypackage"])] + assert error == TyposquatCheckResultList( + errors=[TyposquatCheckResult(dependency="my-package", similars=["mypackage"])] + ) m_config = TwynConfiguration( allowlist={"my-package"}, @@ -235,7 +243,7 @@ def test_check_dependencies_ignores_package_in_allowlist( selector_method="first-letter", ) - assert error == [] + assert error == TyposquatCheckResultList(errors=[]) @pytest.mark.parametrize( "package_name", @@ -256,9 +264,11 @@ def test_normalize_package(self, mock_get_packages_from_cache, package_name): selector_method="first-letter", ) - assert error == [ - TyposquatCheckResult(candidate_dependency="my-package", similar_dependencies=["mypackage"]), - ] + assert error == TyposquatCheckResultList( + errors=[ + TyposquatCheckResult(dependency="my-package", similars=["mypackage"]), + ] + ) @pytest.mark.parametrize("package_name", ["my.package", "my-package", "my_package", "My.Package"]) @patch("twyn.main.get_parsed_dependencies_from_file") @@ -275,7 +285,7 @@ def test_check_dependencies_does_not_error_on_same_package( selector_method="first-letter", ) - assert error == [] + assert error == TyposquatCheckResultList(errors=[]) @patch("twyn.dependency_parser.dependency_selector.DependencySelector.get_dependency_parser") @patch("twyn.dependency_parser.requirements_txt_parser.RequirementsTxtParser.parse") diff --git a/tests/trusted_packages/test_trusted_packages.py b/tests/trusted_packages/test_trusted_packages.py index 01128455..d53f189d 100644 --- a/tests/trusted_packages/test_trusted_packages.py +++ b/tests/trusted_packages/test_trusted_packages.py @@ -118,5 +118,5 @@ def test_get_typosquat(self, package_name, trusted_packages, selector, matches): ) assert trusted_packages.get_typosquat(package_name=package_name) == TyposquatCheckResult( - candidate_dependency=package_name, similar_dependencies=matches + dependency=package_name, similars=matches )