diff --git a/src/twyn/cli.py b/src/twyn/cli.py index 5512798..991ed71 100644 --- a/src/twyn/cli.py +++ b/src/twyn/cli.py @@ -23,7 +23,7 @@ def entry_point() -> None: @click.option("--config", type=click.STRING) @click.option( "--dependency-file", - type=click.Choice(list(DEPENDENCY_FILE_MAPPING.keys())), + type=str, help=( "Dependency file to analyze. By default, twyn will search in the current directory " "for supported files, but this option will override that behavior." @@ -77,6 +77,9 @@ def run( if dependency and dependency_file: raise ValueError("Only one of --dependency or --dependency-file can be set at a time.") + if dependency_file and not any(dependency_file.endswith(key) for key in DEPENDENCY_FILE_MAPPING): + raise ValueError("Dependency file name not supported.") + return int( check_dependencies( config_file=config, diff --git a/src/twyn/dependency_parser/dependency_selector.py b/src/twyn/dependency_parser/dependency_selector.py index 375b62b..fe65bdf 100644 --- a/src/twyn/dependency_parser/dependency_selector.py +++ b/src/twyn/dependency_parser/dependency_selector.py @@ -50,11 +50,12 @@ def get_dependency_parser(self) -> AbstractParser: if self.dependency_file: logger.debug("Dependency file provided. Assigning a parser.") dependency_file_parser = self.get_dependency_file_parser_from_file_name() + file_parser = dependency_file_parser(self.dependency_file) else: logger.debug("No dependency file provided. Attempting to locate one.") dependency_file_parser = self.auto_detect_dependency_file_parser() + file_parser = dependency_file_parser() - file_parser = dependency_file_parser() logger.debug(f"Assigned {file_parser} parser for local dependencies file.") return file_parser diff --git a/tests/conftest.py b/tests/conftest.py index c05e640..3a5c7c8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,28 +1,36 @@ import os -from typing import Generator +from contextlib import contextmanager +from pathlib import Path +from typing import Iterator import pytest +@contextmanager +def create_tmp_file(path: Path, data: str) -> Iterator[str]: + path.write_text(data) + yield str(path) + os.remove(path) + + @pytest.fixture -def requirements_txt_file(tmp_path) -> Generator[str, None, None]: +def requirements_txt_file(tmp_path: Path) -> Iterator[str]: requirements_txt_file = tmp_path / "requirements.txt" - requirements_txt_file.write_text( - """ + + data = """ South==1.0.1 --hash=sha256:abcdefghijklmno pycrypto>=2.6 """ - ) - yield str(requirements_txt_file) - os.remove(requirements_txt_file) + + with create_tmp_file(requirements_txt_file, data) as tmp_file: + yield tmp_file @pytest.fixture -def poetry_lock_file_lt_1_5(tmp_path) -> Generator[str, None, None]: +def poetry_lock_file_lt_1_5(tmp_path: Path) -> Iterator[str]: """Poetry lock version < 1.5.""" poetry_lock_file = tmp_path / "poetry.lock" - poetry_lock_file.write_text( - """ + data = """ [[package]] name = "charset-normalizer" version = "3.0.1" @@ -61,17 +69,15 @@ def poetry_lock_file_lt_1_5(tmp_path) -> Generator[str, None, None]: flake8 = [] mccabe = [] """ - ) - yield str(poetry_lock_file) - os.remove(poetry_lock_file) + with create_tmp_file(poetry_lock_file, data) as tmp_file: + yield tmp_file @pytest.fixture -def poetry_lock_file_ge_1_5(tmp_path) -> Generator[str, None, None]: +def poetry_lock_file_ge_1_5(tmp_path: Path) -> Iterator[str]: """Poetry lock version >= 1.5.""" poetry_lock_file = tmp_path / "poetry.lock" - poetry_lock_file.write_text( - """ + data = """ [[package]] name = "charset-normalizer" version = "3.0.1" @@ -107,16 +113,14 @@ def poetry_lock_file_ge_1_5(tmp_path) -> Generator[str, None, None]: flake8 = [] mccabe = [] """ - ) - yield str(poetry_lock_file) - os.remove(poetry_lock_file) + with create_tmp_file(poetry_lock_file, data) as tmp_file: + yield tmp_file @pytest.fixture -def pyproject_toml_file(tmp_path) -> Generator[str, None, None]: +def pyproject_toml_file(tmp_path: Path) -> Iterator[str]: pyproject_toml = tmp_path / "pyproject.toml" - pyproject_toml.write_text( - """ + data = """ [tool.poetry.dependencies] python = "^3.11" requests = "^2.28.2" @@ -136,6 +140,5 @@ def pyproject_toml_file(tmp_path) -> Generator[str, None, None]: allowlist=["boto4", "boto2"] """ - ) - yield str(pyproject_toml) - os.remove(pyproject_toml) + with create_tmp_file(pyproject_toml, data) as tmp_file: + yield tmp_file diff --git a/tests/dependency_parser/test_dependency_selector.py b/tests/dependency_parser/test_dependency_selector.py index 68d38ba..4bcfdb2 100644 --- a/tests/dependency_parser/test_dependency_selector.py +++ b/tests/dependency_parser/test_dependency_selector.py @@ -10,39 +10,39 @@ class TestDependencySelector: - @patch("twyn.dependency_parser.poetry_lock.PoetryLockParser.file_exists") - @patch("twyn.dependency_parser.requirements_txt.RequirementsTxtParser.file_exists") - @patch("twyn.dependency_parser.dependency_selector.DependencySelector._raise_for_selected_parsers") @pytest.mark.parametrize( - "file_name, requirements_exists, poetry_exists, parser_obj", + "file_name, parser_obj", [ - (None, True, False, RequirementsTxtParser), # auto detect requirements.txt - (None, False, True, PoetryLockParser), # auto detect poetry.lock ( "requirements.txt", - None, - None, RequirementsTxtParser, ), # because file is specified, we won't autocheck - ("poetry.lock", None, None, PoetryLockParser), - ("/some/path/poetry.lock", None, None, PoetryLockParser), - ("/some/path/requirements.txt", None, None, RequirementsTxtParser), + ("poetry.lock", PoetryLockParser), + ("/some/path/poetry.lock", PoetryLockParser), + ("/some/path/requirements.txt", RequirementsTxtParser), ], ) - def test_get_dependency_parser( - self, - _raise_for_selected_parsers, - req_exists, - poet_exists, - file_name, - requirements_exists, - poetry_exists, - parser_obj, - ): - req_exists.return_value = requirements_exists - poet_exists.return_value = poetry_exists + def test_get_dependency_parser(self, file_name, parser_obj): parser = DependencySelector(file_name).get_dependency_parser() assert isinstance(parser, parser_obj) + assert str(parser.file_handler.file_path).endswith(file_name) + + @patch("twyn.dependency_parser.poetry_lock.PoetryLockParser.file_exists") + @patch("twyn.dependency_parser.requirements_txt.RequirementsTxtParser.file_exists") + def test_get_dependency_parser_auto_detect_requirements_file( + self, req_file_exists, poetry_file_exists, requirements_txt_file + ): + # Twyn finds the local poetry.lock file. + # It proves that it can show a requirements.txt file still after mocking the poetry file parser. + poetry_file_exists.return_value = False + req_file_exists.return_value = True + + parser = DependencySelector("").get_dependency_parser() + assert isinstance(parser, RequirementsTxtParser) + + def test_get_dependency_parser_auto_detect_poetry_file(self, poetry_lock_file_lt_1_5): + parser = DependencySelector("").get_dependency_parser() + assert isinstance(parser, PoetryLockParser) @pytest.mark.parametrize( "exists, exception", diff --git a/tests/main/test_cli.py b/tests/main/test_cli.py index 4a98a79..78db363 100644 --- a/tests/main/test_cli.py +++ b/tests/main/test_cli.py @@ -53,6 +53,27 @@ 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): + runner = CliRunner() + runner.invoke( + cli.run, + [ + "--dependency-file", + "/path/requirements.txt", + ], + ) + + assert mock_check_dependencies.call_args_list == [ + call( + config_file=None, + dependency_file="/path/requirements.txt", + dependencies_cli=None, + selector_method=None, + verbosity=AvailableLoggingLevels.none, + ) + ] + @patch("twyn.cli.check_dependencies") def test_click_arguments_single_dependency_cli(self, mock_check_dependencies): runner = CliRunner() @@ -128,3 +149,11 @@ def test_only_one_verbosity_level_is_allowed(self): match="Only one verbosity level is allowed. Choose either -v or -vv.", ): runner.invoke(cli.run, ["-v", "-vv"], catch_exceptions=False) + + def test_dependency_file_name_has_to_be_recognized(self): + runner = CliRunner() + with pytest.raises( + ValueError, + match="Dependency file name not supported.", + ): + runner.invoke(cli.run, ["--dependency-file", "requirements-dev.txt"], catch_exceptions=False) diff --git a/tests/main/test_main.py b/tests/main/test_main.py index 421eead..4c80d0e 100644 --- a/tests/main/test_main.py +++ b/tests/main/test_main.py @@ -10,6 +10,8 @@ get_parsed_dependencies_from_file, ) +from tests.conftest import create_tmp_file + @pytest.mark.usefixtures("disable_track") class TestCheckDependencies: @@ -167,6 +169,23 @@ def test_check_dependencies_with_input_from_cli_detects_typosquats(self, mock_to assert error is True + @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"} + tmpdir.mkdir("fake-dir") + tmp_file = tmp_path / "fake-dir" / "requirements.txt" + with create_tmp_file(tmp_file, "mypackag"): + error = check_dependencies( + config_file=None, + dependency_file=str(tmp_file), + dependencies_cli=None, + 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"}