diff --git a/justfile b/justfile index 585d889..348a8fb 100644 --- a/justfile +++ b/justfile @@ -42,7 +42,7 @@ test-all: venv # Format all code in the project. format: venv - poetry run ruff {{ target_dirs }} --fix + poetry run ruff check {{ target_dirs }} --fix # Lint all code in the project. lint: venv diff --git a/src/twyn/cli.py b/src/twyn/cli.py index 158c8fa..5512798 100644 --- a/src/twyn/cli.py +++ b/src/twyn/cli.py @@ -1,4 +1,5 @@ import sys +from typing import Optional import click @@ -28,6 +29,12 @@ def entry_point() -> None: "for supported files, but this option will override that behavior." ), ) +@click.option( + "--dependency", + type=str, + multiple=True, + help="Dependency to analyze. Cannot be set together with --dependency-file. If provided, it will take precedence over the default dependency file.", +) @click.option( "--selector-method", type=click.Choice(list(SELECTOR_METHOD_MAPPING.keys())), @@ -51,15 +58,14 @@ def entry_point() -> None: ) def run( config: str, - dependency_file: str, + dependency_file: Optional[str], + dependency: tuple[str], selector_method: str, v: bool, vv: bool, ) -> int: if v and vv: - raise ValueError( - "Only one verbosity level is allowed. Choose either -v or -vv." - ) + raise ValueError("Only one verbosity level is allowed. Choose either -v or -vv.") if v: verbosity = AvailableLoggingLevels.info @@ -68,10 +74,14 @@ def run( else: verbosity = AvailableLoggingLevels.none + if dependency and dependency_file: + raise ValueError("Only one of --dependency or --dependency-file can be set at a time.") + return int( check_dependencies( config_file=config, dependency_file=dependency_file, + dependencies_cli=set(dependency) or None, selector_method=selector_method, verbosity=verbosity, ) diff --git a/src/twyn/main.py b/src/twyn/main.py index 5d7fcc0..cc0b2ef 100644 --- a/src/twyn/main.py +++ b/src/twyn/main.py @@ -30,12 +30,14 @@ def check_dependencies( config_file: Optional[str], - dependency_file: str, + dependency_file: Optional[str], + dependencies_cli: Optional[set[str]], selector_method: str, verbosity: AvailableLoggingLevels = AvailableLoggingLevels.none, ) -> bool: """Check if dependencies could be typosquats.""" config = get_configuration(config_file, dependency_file, selector_method, verbosity) + trusted_packages = TrustedPackages( names=TopPyPiReference().get_packages(), algorithm=EditDistance(), @@ -43,7 +45,7 @@ def check_dependencies( threshold_class=SimilarityThreshold, ) normalized_allowlist_packages = normalize_packages(config.allowlist) - dependencies = get_parsed_dependencies(config.dependency_file) + dependencies = dependencies_cli if dependencies_cli else get_parsed_dependencies_from_file(config.dependency_file) normalized_dependencies = normalize_packages(dependencies) errors: list[TyposquatCheckResult] = [] @@ -53,9 +55,7 @@ def check_dependencies( continue logger.info(f"Analyzing {dependency}") - if dependency not in trusted_packages and ( - typosquat_results := trusted_packages.get_typosquat(dependency) - ): + if dependency not in trusted_packages and (typosquat_results := trusted_packages.get_typosquat(dependency)): errors.append(typosquat_results) for possible_typosquats in errors: @@ -69,8 +69,8 @@ def check_dependencies( def get_configuration( config_file: Optional[str], - dependency_file: str, - selector_method: str, + dependency_file: Optional[str], + selector_method: Optional[str], verbosity: AvailableLoggingLevels, ) -> ConfigHandler: """Read configuration and return configuration object. @@ -80,18 +80,14 @@ def get_configuration( # Read config from file config = ConfigHandler(file_path=config_file, enforce_file=False) - # Set logging level according to priority order - logging_level: AvailableLoggingLevels = get_logging_level( + # Set logging level + config.logging_level = get_logging_level( logging_level=verbosity, config_logging_level=config.logging_level, ) - set_logging_level(logging_level) - config.logging_level = logging_level.value - + set_logging_level(config.logging_level) # Set selector method according to priority order - config.selector_method = ( - selector_method or config.selector_method or DEFAULT_SELECTOR_METHOD - ) + config.selector_method = selector_method or config.selector_method or DEFAULT_SELECTOR_METHOD # Set dependency file according to priority order config.dependency_file = dependency_file or config.dependency_file or None @@ -126,7 +122,7 @@ def get_candidate_selector(selector_method_name: Optional[str]) -> AbstractSelec return selector_method -def get_parsed_dependencies(dependency_file: Optional[str] = None) -> set[str]: +def get_parsed_dependencies_from_file(dependency_file: Optional[str] = None) -> set[str]: dependency_parser = DependencySelector(dependency_file).get_dependency_parser() dependencies = dependency_parser.parse() logger.debug("Successfully parsed local dependencies file.") diff --git a/tests/main/test_cli.py b/tests/main/test_cli.py index f80cfa0..4a98a79 100644 --- a/tests/main/test_cli.py +++ b/tests/main/test_cli.py @@ -28,7 +28,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(self, mock_check_dependencies): + def test_click_arguments_dependency_file(self, mock_check_dependencies): runner = CliRunner() runner.invoke( cli.run, @@ -47,11 +47,65 @@ def test_click_arguments(self, mock_check_dependencies): call( config_file="my-config", dependency_file="requirements.txt", + dependencies_cli=None, selector_method="first-letter", verbosity=AvailableLoggingLevels.debug, ) ] + @patch("twyn.cli.check_dependencies") + def test_click_arguments_single_dependency_cli(self, mock_check_dependencies): + runner = CliRunner() + runner.invoke( + cli.run, + [ + "--dependency", + "reqests", + ], + ) + assert mock_check_dependencies.call_args_list == [ + call( + config_file=None, + dependency_file=None, + dependencies_cli={"reqests"}, + selector_method=None, + verbosity=AvailableLoggingLevels.none, + ) + ] + + def test_click_raises_error_dependency_and_dependency_file_set(self): + runner = CliRunner() + result = runner.invoke( + cli.run, + ["--dependency", "requests", "--dependency-file", "requirements.txt"], + ) + with pytest.raises(ValueError, match="Only one of --dependency or --dependency-file can be set at a time."): + raise result.exception + assert result.exit_code == 1 + + @patch("twyn.cli.check_dependencies") + def test_click_arguments_multiple_dependencies_cli(self, mock_check_dependencies): + runner = CliRunner() + runner.invoke( + cli.run, + [ + "--dependency", + "reqests", + "--dependency", + "reqeusts", + ], + ) + + assert mock_check_dependencies.call_args_list == [ + call( + config_file=None, + dependency_file=None, + dependencies_cli={"reqests", "reqeusts"}, + selector_method=None, + verbosity=AvailableLoggingLevels.none, + ) + ] + @patch("twyn.cli.check_dependencies") def test_click_arguments_default(self, mock_check_dependencies): runner = CliRunner() @@ -62,6 +116,7 @@ def test_click_arguments_default(self, mock_check_dependencies): config_file=None, dependency_file=None, selector_method=None, + dependencies_cli=None, verbosity=AvailableLoggingLevels.none, ) ] diff --git a/tests/main/test_main.py b/tests/main/test_main.py index cfc32da..421eead 100644 --- a/tests/main/test_main.py +++ b/tests/main/test_main.py @@ -7,7 +7,7 @@ check_dependencies, get_configuration, get_logging_level, - get_parsed_dependencies, + get_parsed_dependencies_from_file, ) @@ -129,16 +129,17 @@ def test_logging_level(self, passed_logging_level, config, logging_level): ), ) @patch("twyn.main.TopPyPiReference") - @patch("twyn.main.get_parsed_dependencies") + @patch("twyn.main.get_parsed_dependencies_from_file") def test_check_dependencies_detects_typosquats( - self, mock_get_parsed_dependencies, mock_top_pypi_reference, package_name + 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.return_value = {package_name} + 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", ) @@ -154,16 +155,52 @@ def test_check_dependencies_detects_typosquats( ), ) @patch("twyn.main.TopPyPiReference") - @patch("twyn.main.get_parsed_dependencies") + 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"} + + error = check_dependencies( + config_file=None, + dependency_file=None, + dependencies_cli={package_name}, + selector_method="first-letter", + ) + + assert error is True + + @patch("twyn.main.TopPyPiReference") + def test_check_dependencies_with_input_from_cli_accepts_multiple_dependencies(self, mock_top_pypi_reference): + mock_top_pypi_reference.return_value.get_packages.return_value = {"requests", "mypackage"} + + error = check_dependencies( + config_file=None, + dependency_file=None, + dependencies_cli={"my-package", "requests"}, + selector_method="first-letter", + ) + + assert error is True + + @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, mock_top_pypi_reference, package_name + 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.return_value = {package_name} + mock_get_parsed_dependencies_from_file.return_value = {package_name} m_config = Mock( allowlist={package_name}, dependency_file=None, + dependencies_cli=None, selector_method="first-letter", ) @@ -171,35 +208,33 @@ def test_check_dependencies_ignores_package_in_allowlist( error = check_dependencies( config_file=None, dependency_file=None, + dependencies_cli=None, selector_method="first-letter", ) assert error is False - @pytest.mark.parametrize( - "package_name", ("my.package", "my-package", "my_package", "My.Package") - ) + @pytest.mark.parametrize("package_name", ("my.package", "my-package", "my_package", "My.Package")) @patch("twyn.main.TopPyPiReference") - @patch("twyn.main.get_parsed_dependencies") + @patch("twyn.main.get_parsed_dependencies_from_file") def test_check_dependencies_does_not_error_on_same_package( - self, mock_get_parsed_dependencies, mock_top_pypi_reference, package_name + self, mock_get_parsed_dependencies_from_file, mock_top_pypi_reference, package_name ): mock_top_pypi_reference.return_value.get_packages.return_value = {"my-package"} - mock_get_parsed_dependencies.return_value = {package_name} + 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 False - @patch( - "twyn.dependency_parser.dependency_selector.DependencySelector.get_dependency_parser" - ) + @patch("twyn.dependency_parser.dependency_selector.DependencySelector.get_dependency_parser") @patch("twyn.dependency_parser.requirements_txt.RequirementsTxtParser.parse") - def test_get_parsed_dependencies(self, mock_parse, mock_get_dependency_parser): + def test_get_parsed_dependencies_from_file(self, mock_parse, mock_get_dependency_parser): mock_get_dependency_parser.return_value = RequirementsTxtParser() mock_parse.return_value = {"boto3"} - assert get_parsed_dependencies() == {"boto3"} + assert get_parsed_dependencies_from_file() == {"boto3"}