diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c5af2d4..3107ff2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -23,7 +23,7 @@ jobs: - name: Install uv uses: astral-sh/setup-uv@d9e0f98d3fc6adb07d1e3d37f3043649ddad06a1 # v6.5.0 - - name: Install the project + - name: Install the dependencies run: uv sync --locked --group dev --python ${{ matrix.python-version }} - name: Run tests diff --git a/src/twyn/base/constants.py b/src/twyn/base/constants.py index a924022..8cd4f58 100644 --- a/src/twyn/base/constants.py +++ b/src/twyn/base/constants.py @@ -1,7 +1,7 @@ from __future__ import annotations -import enum -from typing import TYPE_CHECKING +from enum import Enum +from typing import TYPE_CHECKING, Literal from twyn import dependency_parser from twyn.trusted_packages import selectors @@ -16,6 +16,9 @@ "all": selectors.AllSimilar, } +SELECTOR_METHOD_KEYS = set(SELECTOR_METHOD_MAPPING.keys()) +SelectorMethod = Literal["first-letter", "nearby-letter", "all"] + DEPENDENCY_FILE_MAPPING: dict[str, type[AbstractParser]] = { "requirements.txt": dependency_parser.requirements_txt_parser.RequirementsTxtParser, "poetry.lock": dependency_parser.lock_parser.PoetryLockParser, @@ -27,7 +30,7 @@ DEFAULT_TOP_PYPI_PACKAGES = "https://hugovk.github.io/top-pypi-packages/top-pypi-packages.min.json" -class AvailableLoggingLevels(enum.Enum): +class AvailableLoggingLevels(Enum): none = "NONE" debug = "DEBUG" info = "INFO" diff --git a/src/twyn/cli.py b/src/twyn/cli.py index f9a8d0a..bd077f0 100644 --- a/src/twyn/cli.py +++ b/src/twyn/cli.py @@ -86,18 +86,27 @@ 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()) - sys.exit( - int( - check_dependencies( - config_file=config, - dependency_file=dependency_file, - dependencies_cli=set(dependency) or None, - selector_method=selector_method, - verbosity=verbosity, - ) - ) + errors = check_dependencies( + dependencies=set(dependency) or None, + config_file=config, + dependency_file=dependency_file, + selector_method=selector_method, + verbosity=verbosity, ) + if errors: + for possible_typosquats in 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)}]?", + color=True, + ) + sys.exit(1) + else: + click.echo(click.style("No typosquats detected", fg="green"), color=True) + sys.exit(0) + @entry_point.group() def allowlist() -> None: diff --git a/src/twyn/config/config_handler.py b/src/twyn/config/config_handler.py index 8e5e2c2..46d58bb 100644 --- a/src/twyn/config/config_handler.py +++ b/src/twyn/config/config_handler.py @@ -9,11 +9,13 @@ DEFAULT_PROJECT_TOML_FILE, DEFAULT_SELECTOR_METHOD, DEFAULT_TOP_PYPI_PACKAGES, + SELECTOR_METHOD_KEYS, AvailableLoggingLevels, ) from twyn.config.exceptions import ( AllowlistPackageAlreadyExistsError, AllowlistPackageDoesNotExistError, + InvalidSelectorMethodError, TOMLError, ) from twyn.file_handler.exceptions import PathNotFoundError @@ -67,9 +69,19 @@ def resolve_config( toml = self._read_toml() read_config = self._get_read_config(toml) + # Determine final selector method from CLI, config file, or default + final_selector_method = selector_method or read_config.selector_method or DEFAULT_SELECTOR_METHOD + + # Validate selector_method + if final_selector_method not in SELECTOR_METHOD_KEYS: + valid_methods = ", ".join(sorted(SELECTOR_METHOD_KEYS)) + raise InvalidSelectorMethodError( + f"Invalid selector_method '{final_selector_method}'. Must be one of: {valid_methods}" + ) + return TwynConfiguration( dependency_file=dependency_file or read_config.dependency_file, - selector_method=selector_method or read_config.selector_method or DEFAULT_SELECTOR_METHOD, + selector_method=final_selector_method, logging_level=_get_logging_level(verbosity, read_config.logging_level), allowlist=read_config.allowlist, pypi_reference=read_config.pypi_reference or DEFAULT_TOP_PYPI_PACKAGES, diff --git a/src/twyn/config/exceptions.py b/src/twyn/config/exceptions.py index 5715a7f..ae0c35a 100644 --- a/src/twyn/config/exceptions.py +++ b/src/twyn/config/exceptions.py @@ -18,3 +18,7 @@ class AllowlistPackageAlreadyExistsError(AllowlistError): class AllowlistPackageDoesNotExistError(AllowlistError): message = "Package '{}' is not present in the allowlist. Skipping." + + +class InvalidSelectorMethodError(TwynError): + """Exception for when an invalid selector method has been specified.""" diff --git a/src/twyn/main.py b/src/twyn/main.py index d816185..599ee1b 100644 --- a/src/twyn/main.py +++ b/src/twyn/main.py @@ -2,7 +2,6 @@ import re from typing import Optional -import click from rich.logging import RichHandler from rich.progress import track @@ -10,6 +9,7 @@ DEFAULT_PROJECT_TOML_FILE, SELECTOR_METHOD_MAPPING, AvailableLoggingLevels, + SelectorMethod, ) from twyn.config.config_handler import ConfigHandler from twyn.dependency_parser.dependency_selector import DependencySelector @@ -31,12 +31,12 @@ def check_dependencies( - config_file: Optional[str], - dependency_file: Optional[str], - dependencies_cli: Optional[set[str]], - selector_method: str, + selector_method: SelectorMethod, + config_file: Optional[str] = None, + dependency_file: Optional[str] = None, + dependencies: Optional[set[str]] = None, verbosity: AvailableLoggingLevels = AvailableLoggingLevels.none, -) -> bool: +) -> list[TyposquatCheckResult]: """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( @@ -51,7 +51,7 @@ def check_dependencies( threshold_class=SimilarityThreshold, ) normalized_allowlist_packages = _normalize_packages(config.allowlist) - dependencies = dependencies_cli if dependencies_cli else get_parsed_dependencies_from_file(config.dependency_file) + dependencies = dependencies if dependencies else get_parsed_dependencies_from_file(config.dependency_file) normalized_dependencies = _normalize_packages(dependencies) errors: list[TyposquatCheckResult] = [] @@ -64,17 +64,7 @@ def check_dependencies( if dependency not in trusted_packages and (typosquat_results := trusted_packages.get_typosquat(dependency)): errors.append(typosquat_results) - for possible_typosquats in 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)}]?", - color=True, - ) - - if not errors: - click.echo(click.style("No typosquats detected", fg="green"), color=True) - - return bool(errors) + return errors def _set_logging_level(logging_level: AvailableLoggingLevels) -> None: diff --git a/tests/conftest.py b/tests/conftest.py index 94cc22b..ab68100 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,5 @@ import os -from collections.abc import Iterator +from collections.abc import Iterable, Iterator from contextlib import contextmanager from pathlib import Path from unittest import mock @@ -20,6 +20,20 @@ def create_tmp_file(path: Path, data: str) -> Iterator[str]: os.remove(path) +@contextmanager +def patch_pypi_requests_get(packages: Iterable[str]) -> Iterator[mock.Mock]: + """Patcher of `requests.get` for Top PyPi list. + + Replaces call with the output you would get from downloading the top PyPi packages list. + """ + json_response = {"rows": [{"project": name} for name in packages]} + + with mock.patch("requests.get") as mock_get: + mock_get.return_value.json.return_value = json_response + + yield mock_get + + @pytest.fixture def requirements_txt_file(tmp_path: Path) -> Iterator[str]: requirements_txt_file = tmp_path / "requirements.txt" @@ -187,7 +201,7 @@ def pyproject_toml_file(tmp_path: Path) -> Iterator[str]: [tool.twyn] dependency_file="my_file.txt" - selector_method="my_selector" + selector_method="all" logging_level="debug" allowlist=["boto4", "boto2"] diff --git a/tests/core/test_config_handler.py b/tests/core/test_config_handler.py index 390f6b7..aaadff8 100644 --- a/tests/core/test_config_handler.py +++ b/tests/core/test_config_handler.py @@ -1,5 +1,6 @@ import dataclasses from copy import deepcopy +from pathlib import Path from unittest.mock import patch import pytest @@ -9,6 +10,7 @@ from twyn.config.exceptions import ( AllowlistPackageAlreadyExistsError, AllowlistPackageDoesNotExistError, + InvalidSelectorMethodError, TOMLError, ) from twyn.file_handler.exceptions import PathNotFoundError @@ -46,7 +48,7 @@ def test_config_raises_for_unknown_file(self): def test_read_config_values(self, pyproject_toml_file): config = ConfigHandler(file_handler=FileHandler(pyproject_toml_file)).resolve_config() assert config.dependency_file == "my_file.txt" - assert config.selector_method == "my_selector" + assert config.selector_method == "all" assert config.logging_level == AvailableLoggingLevels.debug assert config.allowlist == {"boto4", "boto2"} @@ -57,7 +59,7 @@ def test_get_twyn_data_from_file(self, pyproject_toml_file): twyn_data = ConfigHandler(FileHandler(pyproject_toml_file))._get_read_config(toml) assert twyn_data == ReadTwynConfiguration( dependency_file="my_file.txt", - selector_method="my_selector", + selector_method="all", logging_level="debug", allowlist={"boto4", "boto2"}, pypi_reference=None, @@ -95,7 +97,7 @@ def test_write_toml(self, pyproject_toml_file): }, "twyn": { "dependency_file": "my_file.txt", - "selector_method": "my_selector", + "selector_method": "all", "logging_level": "debug", "allowlist": {}, "pypi_reference": DEFAULT_TOP_PYPI_PACKAGES, @@ -166,3 +168,46 @@ def test_allowlist_remove_non_existent_package_error(self, mock_toml, mock_write config.remove_package_from_allowlist("mypackage2") assert not mock_write_toml.called + + @pytest.mark.parametrize("valid_selector", ["first-letter", "nearby-letter", "all"]) + def test_valid_selector_methods_accepted(self, valid_selector: str, tmp_path: Path): + """Test that all valid selector methods are accepted.""" + pyproject_toml = tmp_path / "pyproject.toml" + pyproject_toml.write_text("") + config = ConfigHandler(FileHandler(str(pyproject_toml)), enforce_file=False) + + # Should not raise any exception + resolved_config = config.resolve_config(selector_method=valid_selector) + assert resolved_config.selector_method == valid_selector + + def test_invalid_selector_method_rejected(self, tmp_path: Path): + """Test that invalid selector methods are rejected with appropriate error.""" + pyproject_toml = tmp_path / "pyproject.toml" + pyproject_toml.write_text("") + config = ConfigHandler(FileHandler(str(pyproject_toml)), enforce_file=False) + + with pytest.raises(InvalidSelectorMethodError) as exc_info: + config.resolve_config(selector_method="random-selector") + + error_message = str(exc_info.value) + assert "Invalid selector_method 'random-selector'" in error_message + assert "Must be one of: all, first-letter, nearby-letter" in error_message + + def test_invalid_selector_method_from_config_file(self, tmp_path: Path): + """Test that invalid selector method from config file is rejected.""" + # Create a config file with invalid selector method + pyproject_toml = tmp_path / "pyproject.toml" + data = """ + [tool.twyn] + selector_method="invalid-selector" + """ + pyproject_toml.write_text(data) + + config = ConfigHandler(FileHandler(str(pyproject_toml))) + + with pytest.raises(InvalidSelectorMethodError) as exc_info: + config.resolve_config() + + error_message = str(exc_info.value) + assert "Invalid selector_method 'invalid-selector'" in error_message + assert "Must be one of: all, first-letter, nearby-letter" in error_message diff --git a/tests/main/test_cli.py b/tests/main/test_cli.py index 52caa11..3efb806 100644 --- a/tests/main/test_cli.py +++ b/tests/main/test_cli.py @@ -3,6 +3,7 @@ from click.testing import CliRunner from twyn import cli from twyn.base.constants import AvailableLoggingLevels +from twyn.trusted_packages.trusted_packages import TyposquatCheckResult class TestCli: @@ -46,7 +47,7 @@ def test_click_arguments_dependency_file(self, mock_check_dependencies): call( config_file="my-config", dependency_file="requirements.txt", - dependencies_cli=None, + dependencies=None, selector_method="first-letter", verbosity=AvailableLoggingLevels.debug, ) @@ -67,7 +68,7 @@ def test_click_arguments_dependency_file_in_different_path(self, mock_check_depe call( config_file=None, dependency_file="/path/requirements.txt", - dependencies_cli=None, + dependencies=None, selector_method=None, verbosity=AvailableLoggingLevels.none, ) @@ -87,7 +88,7 @@ def test_click_arguments_single_dependency_cli(self, mock_check_dependencies): call( config_file=None, dependency_file=None, - dependencies_cli={"reqests"}, + dependencies={"reqests"}, selector_method=None, verbosity=AvailableLoggingLevels.none, ) @@ -103,7 +104,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_cli(self, mock_check_dependencies): + def test_click_arguments_multiple_dependencies(self, mock_check_dependencies): runner = CliRunner() runner.invoke( cli.run, @@ -119,7 +120,7 @@ def test_click_arguments_multiple_dependencies_cli(self, mock_check_dependencies call( config_file=None, dependency_file=None, - dependencies_cli={"reqests", "reqeusts"}, + dependencies={"reqests", "reqeusts"}, selector_method=None, verbosity=AvailableLoggingLevels.none, ) @@ -135,7 +136,7 @@ def test_click_arguments_default(self, mock_check_dependencies): config_file=None, dependency_file=None, selector_method=None, - dependencies_cli=None, + dependencies=None, verbosity=AvailableLoggingLevels.none, ) ] @@ -143,7 +144,9 @@ def test_click_arguments_default(self, mock_check_dependencies): @patch("twyn.cli.check_dependencies") def test_return_code_1(self, mock_check_dependencies): runner = CliRunner() - mock_check_dependencies.return_value = True + mock_check_dependencies.return_value = [ + TyposquatCheckResult(candidate_dependency="my-package", similar_dependencies=["mypackage"]) + ] result = runner.invoke(cli.run) assert result.exit_code == 1 @@ -151,7 +154,7 @@ def test_return_code_1(self, mock_check_dependencies): @patch("twyn.cli.check_dependencies") def test_return_code_0(self, mock_check_dependencies): runner = CliRunner() - mock_check_dependencies.return_value = False + mock_check_dependencies.return_value = [] result = runner.invoke(cli.run) assert result.exit_code == 0 diff --git a/tests/main/test_main.py b/tests/main/test_main.py index d18238e..3fef701 100644 --- a/tests/main/test_main.py +++ b/tests/main/test_main.py @@ -10,8 +10,9 @@ check_dependencies, get_parsed_dependencies_from_file, ) +from twyn.trusted_packages.trusted_packages import TyposquatCheckResult -from tests.conftest import create_tmp_file +from tests.conftest import create_tmp_file, patch_pypi_requests_get @pytest.mark.usefixtures("disable_track") @@ -99,7 +100,7 @@ def test_options_priorities_assignation( """ Checks that the configuration values are picked accordingly to the priority they have. - From more relevant to less: + Relevance order: 1. command line 2. config file 3. default values @@ -139,70 +140,42 @@ def test_logging_level(self, passed_logging_level, config, logging_level): ) assert log_level == logging_level - @pytest.mark.parametrize( - "package_name", - [ - "my.package", - "my-package", - "my_package", - "My.Package", - ], - ) - @patch("twyn.main.TopPyPiReference") @patch("twyn.main.get_parsed_dependencies_from_file") - def test_check_dependencies_detects_typosquats( - self, mock_get_parsed_dependencies_from_file, mock_top_pypi_reference, package_name - ): - mock_top_pypi_reference.return_value.get_packages.return_value = {"mypackage"} - mock_get_parsed_dependencies_from_file.return_value = {package_name} - - error = check_dependencies( - config_file=None, - dependency_file=None, - dependencies_cli=None, - selector_method="first-letter", - ) - - assert error is True + def test_check_dependencies_detects_typosquats(self, mock_get_parsed_dependencies_from_file): + mock_get_parsed_dependencies_from_file.return_value = {"my-package"} + with patch_pypi_requests_get({"mypackage"}): + error = check_dependencies( + config_file=None, + dependency_file=None, + dependencies=None, + selector_method="first-letter", + ) - @pytest.mark.parametrize( - "package_name", - [ - "my.package", - "my-package", - "my_package", - "My.Package", - ], - ) - @patch("twyn.main.TopPyPiReference") - def test_check_dependencies_with_input_from_cli_detects_typosquats(self, mock_top_pypi_reference, package_name): - mock_top_pypi_reference.return_value.get_packages.return_value = {"mypackage"} + assert error == [TyposquatCheckResult(candidate_dependency="my-package", similar_dependencies=["mypackage"])] - error = check_dependencies( - config_file=None, - dependency_file=None, - dependencies_cli={package_name}, - selector_method="first-letter", - ) + def test_check_dependencies_with_input_from_cli_detects_typosquats(self): + with patch_pypi_requests_get({"mypackage"}): + error = check_dependencies( + config_file=None, + dependency_file=None, + dependencies={"my-package"}, + selector_method="first-letter", + ) - assert error is True + assert error == [TyposquatCheckResult(candidate_dependency="my-package", similar_dependencies=["mypackage"])] - @patch("twyn.main.TopPyPiReference") - def test_check_dependencies_with_input_loads_file_from_different_location( - self, mock_top_pypi_reference, tmp_path, tmpdir - ): - mock_top_pypi_reference.return_value.get_packages.return_value = {"mypackage"} + def test_check_dependencies_with_input_loads_file_from_different_location(self, tmp_path, tmpdir): tmpdir.mkdir("fake-dir") tmp_file = tmp_path / "fake-dir" / "requirements.txt" - with create_tmp_file(tmp_file, "mypackag"): + with create_tmp_file(tmp_file, "mypackag"), patch_pypi_requests_get({"mypackage"}): error = check_dependencies( config_file=None, dependency_file=str(tmp_file), - dependencies_cli=None, + dependencies=None, selector_method="first-letter", ) - assert error is True + assert error == [TyposquatCheckResult(candidate_dependency="mypackag", similar_dependencies=["mypackage"])] @patch("twyn.main.TopPyPiReference") def test_check_dependencies_with_input_from_cli_accepts_multiple_dependencies(self, mock_top_pypi_reference): @@ -211,64 +184,88 @@ def test_check_dependencies_with_input_from_cli_accepts_multiple_dependencies(se error = check_dependencies( config_file=None, dependency_file=None, - dependencies_cli={"my-package", "requests"}, + dependencies={"my-package", "reqests"}, selector_method="first-letter", ) - assert error is True + 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 - @pytest.mark.parametrize( - "package_name", - [ - "my.package", - "my-package", - "my_package", - "My.Package", - ], - ) @patch("twyn.main.TopPyPiReference") @patch("twyn.main.get_parsed_dependencies_from_file") def test_check_dependencies_ignores_package_in_allowlist( - self, mock_get_parsed_dependencies_from_file, mock_top_pypi_reference, package_name + self, mock_get_parsed_dependencies_from_file, mock_top_pypi_reference ): mock_top_pypi_reference.return_value.get_packages.return_value = {"mypackage"} - mock_get_parsed_dependencies_from_file.return_value = {package_name} + mock_get_parsed_dependencies_from_file.return_value = {"my-package"} + + # Verify that before the whitelist configuration the package is classified as an error. + error = check_dependencies( + config_file=None, + dependency_file=None, + dependencies=None, + selector_method="first-letter", + ) + + assert error == [TyposquatCheckResult(candidate_dependency="my-package", similar_dependencies=["mypackage"])] m_config = TwynConfiguration( - allowlist={package_name}, + allowlist={"my-package"}, dependency_file=None, selector_method="first-letter", logging_level=AvailableLoggingLevels.info, pypi_reference=DEFAULT_TOP_PYPI_PACKAGES, ) + # Check that the package is no longer an error with patch("twyn.main.ConfigHandler.resolve_config", return_value=m_config): error = check_dependencies( config_file=None, dependency_file=None, - dependencies_cli=None, + dependencies=None, selector_method="first-letter", ) - assert error is False + assert error == [] + + @pytest.mark.parametrize( + "package_name", + [ + "my.package", + "my-package", + "my_package", + "My.Package", + ], + ) + def test_normalize_package(self, package_name): + with patch_pypi_requests_get({"requests", "mypackage"}): + error = check_dependencies( + config_file=None, + dependency_file=None, + dependencies={package_name}, + selector_method="first-letter", + ) + + assert error == [ + TyposquatCheckResult(candidate_dependency="my-package", similar_dependencies=["mypackage"]), + ] @pytest.mark.parametrize("package_name", ["my.package", "my-package", "my_package", "My.Package"]) - @patch("twyn.main.TopPyPiReference") @patch("twyn.main.get_parsed_dependencies_from_file") def test_check_dependencies_does_not_error_on_same_package( - self, mock_get_parsed_dependencies_from_file, mock_top_pypi_reference, package_name + self, mock_get_parsed_dependencies_from_file, package_name ): - mock_top_pypi_reference.return_value.get_packages.return_value = {"my-package"} mock_get_parsed_dependencies_from_file.return_value = {package_name} + with patch_pypi_requests_get({"my-package"}): + error = check_dependencies( + config_file=None, + dependency_file=None, + dependencies=None, + selector_method="first-letter", + ) - error = check_dependencies( - config_file=None, - dependency_file=None, - dependencies_cli=None, - selector_method="first-letter", - ) - - assert error is False + assert error == [] @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_references.py b/tests/trusted_packages/test_references.py index 62d37a7..d346488 100644 --- a/tests/trusted_packages/test_references.py +++ b/tests/trusted_packages/test_references.py @@ -1,9 +1,7 @@ -from contextlib import contextmanager -from unittest.mock import Mock, call, patch +from unittest.mock import call, patch import pytest import requests -from pyparsing import Iterable, Iterator from twyn.trusted_packages import TopPyPiReference from twyn.trusted_packages.exceptions import ( EmptyPackagesListError, @@ -12,19 +10,7 @@ ) from twyn.trusted_packages.references import AbstractPackageReference - -@contextmanager -def patch_pypi_requests_get(packages: Iterable[str]) -> Iterator[Mock]: - """Patcher of `requests.get` for Top PyPi list. - - Replaces call with the output you would get from downloading the top PyPi packages list. - """ - json_response = {"rows": [{"project": name} for name in packages]} - - with patch("requests.get") as mock_get: - mock_get.return_value.json.return_value = json_response - - yield mock_get +from tests.conftest import patch_pypi_requests_get class TestAbstractPackageReference: