diff --git a/.gitignore b/.gitignore index d5095cb5..c268d0b0 100644 --- a/.gitignore +++ b/.gitignore @@ -111,3 +111,6 @@ GitHub.sublime-settings .history /.ruff_cache/ /.env.local + +# Test lock files +yarn.lock \ No newline at end of file diff --git a/README.md b/README.md index 3e4b4738..60970bc7 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ - [Using `Twyn` as a cli tool](#using-twyn-as-a-cli-tool) - [Installation](#installation) - [Docker](#docker) + - [CLI Options Reference](#cli-options-reference) - [Run](#run) - [JSON Format](#json-format) - [Using `Twyn` as a library](#using-twyn-as-a-library) @@ -61,15 +62,29 @@ docker pull elementsinteractive/twyn:latest docker run elementsinteractive/twyn --help ``` +##### CLI Options Reference + +| Option / Argument | Type / Values | Description | +|--------------------------|----------------------------------------------------|-----------------------------------------------------------------------------------------------| +| `--config` | `str` (path) | Path to configuration file (`twyn.toml` or `pyproject.toml` by default). | +| `--dependency-file` | `str` (path) | Dependency file to analyze. Supported: `requirements.txt`, `poetry.lock`, `uv.lock`, etc. | +| `--dependency` | `str` (multiple allowed) | Dependency to analyze directly. Can be specified multiple times. | +| `--selector-method` | `all`, `first-letter`, `nearby-letter` | Method for selecting possible typosquats. | +| `--package-ecosystem` | `pypi`, `npm` | Package ecosystem for analysis. | +| `-v` | flag | Enable info-level logging. | +| `-vv` | flag | Enable debug-level logging. | +| `--no-cache` | flag | Disable use of trusted packages cache. Always fetch from the source. | +| `--no-track` | flag | Do not show the progress bar while processing packages. | +| `--json` | flag | Display results in JSON format. Implies `--no-track`. | +| `-r`, `--recursive` | flag | Scan directories recursively for dependency files. | #### Run -To run twyn simply type: +**Usage Example:** ```sh twyn run ``` - -For a list of all the available options as well as their expected arguments run: +or get help with ```sh twyn run --help @@ -90,41 +105,9 @@ If `Twyn` was run by manually giving it dependencies (with `--dependency`), the In any other case (when dependencies are parsed from a file), the source will be the path to the dependencies file. One entry will be created for every source. -### Using Twyn as a library - - -#### Installation -`Twyn` also supports being used as 3rd party library for you project. To install it, run: - - -```sh -pip install twyn -``` - -Example usage in your code: - -```python -from twyn import check_dependencies - -typos = check_dependencies() - -for typo in typos.errors: - print(f"Dependency:{typo.dependency}") - print(f"Did you mean any of [{','.join(typo.similars)}]") - -``` - -#### Logging level -By default, logging is disabled when running as a 3rd party library. To override this behaviour, you can: - -```python -logging.basicConfig(level=logging.INFO) -logging.getLogger("twyn").setLevel(logging.INFO) -``` ### Using Twyn as a library - #### Installation `Twyn` also supports being used as 3rd party library for you project. To install it, run: @@ -154,6 +137,7 @@ logging.basicConfig(level=logging.INFO) logging.getLogger("twyn").setLevel(logging.INFO) ``` + ## Configuration ### Allowlist @@ -268,5 +252,3 @@ To clear the cache, run: ```python twyn run cache clear ``` - - diff --git a/src/twyn/base/constants.py b/src/twyn/base/constants.py index b2dd50cf..16cd1615 100644 --- a/src/twyn/base/constants.py +++ b/src/twyn/base/constants.py @@ -35,6 +35,7 @@ DEFAULT_PROJECT_TOML_FILE = "pyproject.toml" DEFAULT_TWYN_TOML_FILE = "twyn.toml" DEFAULT_USE_CACHE = True +DEFAULT_RECURSIVE = False PackageEcosystems: TypeAlias = Literal["pypi", "npm"] diff --git a/src/twyn/cli.py b/src/twyn/cli.py index 5f08fb9e..5ae569c8 100644 --- a/src/twyn/cli.py +++ b/src/twyn/cli.py @@ -103,6 +103,13 @@ def entry_point() -> None: default=False, help="Display the results in json format. It implies --no-track.", ) +@click.option( + "-r", + "--recursive", + is_flag=True, + default=False, + help="Recursively look for files when trying to locate them automatically. Ignored if --dependency-file is given.", +) def run( # noqa: C901 config: str, dependency_file: Optional[str], @@ -114,6 +121,7 @@ def run( # noqa: C901 no_track: bool, json: bool, package_ecosystem: Optional[str], + recursive: bool, ) -> int: if vv: logger.setLevel(logging.DEBUG) @@ -138,6 +146,7 @@ def run( # noqa: C901 show_progress_bar=False if json else not no_track, load_config_from_file=True, package_ecosystem=package_ecosystem, + recursive=recursive, ) except TwynError as e: raise CliError(str(e)) from e diff --git a/src/twyn/config/config_handler.py b/src/twyn/config/config_handler.py index 81777702..cf2b97db 100644 --- a/src/twyn/config/config_handler.py +++ b/src/twyn/config/config_handler.py @@ -8,6 +8,7 @@ from twyn.base.constants import ( DEFAULT_PROJECT_TOML_FILE, + DEFAULT_RECURSIVE, DEFAULT_SELECTOR_METHOD, DEFAULT_TWYN_TOML_FILE, DEFAULT_USE_CACHE, @@ -37,6 +38,7 @@ class TwynConfiguration: source: Optional[str] use_cache: bool package_ecosystem: Optional[PackageEcosystems] + recursive: Optional[bool] @dataclass @@ -49,6 +51,7 @@ class ReadTwynConfiguration: source: Optional[str] = None use_cache: Optional[bool] = None package_ecosystem: Optional[PackageEcosystems] = None + recursive: Optional[bool] = None class ConfigHandler: @@ -63,6 +66,7 @@ def resolve_config( dependency_file: Optional[str] = None, use_cache: Optional[bool] = None, package_ecosystem: Optional[PackageEcosystems] = None, + recursive: Optional[bool] = None, ) -> TwynConfiguration: """Resolve the configuration for Twyn. @@ -95,6 +99,13 @@ def resolve_config( else: final_use_cache = DEFAULT_USE_CACHE + if recursive is not None: + final_recursive = use_cache + elif read_config.recursive is not None: + final_recursive = read_config.recursive + else: + final_recursive = DEFAULT_RECURSIVE + return TwynConfiguration( dependency_file=dependency_file or read_config.dependency_file, selector_method=final_selector_method, @@ -102,6 +113,7 @@ def resolve_config( source=read_config.source, use_cache=final_use_cache, package_ecosystem=package_ecosystem or read_config.package_ecosystem, + recursive=final_recursive, ) def add_package_to_allowlist(self, package_name: str) -> None: diff --git a/src/twyn/dependency_parser/dependency_selector.py b/src/twyn/dependency_parser/dependency_selector.py index 40ab57a6..595b151f 100644 --- a/src/twyn/dependency_parser/dependency_selector.py +++ b/src/twyn/dependency_parser/dependency_selector.py @@ -1,4 +1,5 @@ import logging +from pathlib import Path from typing import Optional from twyn.base.constants import DEPENDENCY_FILE_MAPPING @@ -11,21 +12,28 @@ class DependencySelector: - def __init__(self, dependency_file: Optional[str] = None) -> None: + def __init__(self, dependency_file: Optional[str] = None, root_path: str = ".") -> None: self.dependency_file = dependency_file or "" + self.root_path = root_path def auto_detect_dependency_file_parser(self) -> list[AbstractParser]: parsers: list[AbstractParser] = [] - for dependency_parser in DEPENDENCY_FILE_MAPPING.values(): - file_parser = dependency_parser() - if file_parser.file_exists(): - parsers.append(file_parser) - logger.debug("Assigned %s parser for local dependencies file.", file_parser) + root = Path(self.root_path) + for path in root.rglob("*"): + if ".git" in path.parts: + continue + if path.is_file(): + for known_file, dependency_parser in DEPENDENCY_FILE_MAPPING.items(): + if path.name == known_file: + file_parser = dependency_parser(str(path)) + if file_parser.file_exists(): + parsers.append(file_parser) + logger.debug("Assigned %s parser for local dependencies file at %s.", file_parser, path) if not parsers: raise NoMatchingParserError - logger.debug("Dependencies file found") + logger.debug("Dependencies file(s) found: %s", [str(p.file_path) for p in parsers]) return parsers def get_dependency_file_parsers_from_file_name(self) -> list[AbstractParser]: diff --git a/src/twyn/main.py b/src/twyn/main.py index ed8d51e8..aa9f8a8d 100644 --- a/src/twyn/main.py +++ b/src/twyn/main.py @@ -43,6 +43,7 @@ def check_dependencies( show_progress_bar: bool = False, load_config_from_file: bool = False, package_ecosystem: Optional[PackageEcosystems] = None, + recursive: Optional[bool] = None, ) -> TyposquatCheckResults: """ Check if the provided dependencies are potential typosquats of trusted packages. @@ -70,6 +71,7 @@ def check_dependencies( dependency_file=dependency_file, use_cache=use_cache, package_ecosystem=package_ecosystem, + recursive=recursive, ) maybe_cache_handler = CacheHandler() if config.use_cache else None selector_method_obj = _get_selector_method(config.selector_method) @@ -85,9 +87,17 @@ def check_dependencies( dependencies=dependencies, ) + # The following checks do not result in an error to avoid inconsistencies. + # If the user has set in the config file a setting that would conflict with a cli provided option + # it would always result in an execution error rather than in overriding the behaviour. if config.package_ecosystem: logger.warning("`package_ecosystem` is not supported when reading dependencies from files. It will be ignored.") + if config.dependency_file and config.recursive: + logger.warning( + "`--recursive` has been set together with `--dependency-file`. `--dependency-file` will take precedence." + ) + return _analyze_packages_from_source( selector_method=selector_method_obj, source=config.source, @@ -155,6 +165,7 @@ def _analyze_packages_from_source( typos_by_file = TyposquatCheckResults() for dependency_manager, parsers in dependency_managers.items(): top_package_reference = dependency_manager.trusted_packages_source(source, maybe_cache_handler) + packages_from_source = top_package_reference.get_packages() trusted_packages = TrustedPackages( names=packages_from_source, @@ -259,6 +270,7 @@ def _get_config( dependency_file: Optional[str], use_cache: Optional[bool], package_ecosystem: Optional[PackageEcosystems], + recursive: Optional[bool], ) -> TwynConfiguration: """Given the arguments passed to the main function and the configuration loaded from the config file (if any), return a config object.""" if load_config_from_file: @@ -270,4 +282,5 @@ def _get_config( dependency_file=dependency_file, use_cache=use_cache, package_ecosystem=package_ecosystem, + recursive=recursive, ) diff --git a/tests/config/test_config_handler.py b/tests/config/test_config_handler.py index 96c4ce45..f24b2daa 100644 --- a/tests/config/test_config_handler.py +++ b/tests/config/test_config_handler.py @@ -42,6 +42,7 @@ def test_no_enforce_file_on_non_existent_file(self, mock_is_file: Mock) -> None: source=None, use_cache=True, package_ecosystem=None, + recursive=False, ) def test_config_raises_for_unknown_file(self) -> None: @@ -103,6 +104,7 @@ def test_write_toml(self, pyproject_toml_file: Path) -> None: "selector_method": "all", "allowlist": {}, "use_cache": False, + "recursive": False, }, } } diff --git a/tests/conftest.py b/tests/conftest.py index 82a34035..bac4144d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,7 +1,8 @@ import datetime -from collections.abc import Iterator +from collections.abc import Generator, Iterator from contextlib import contextmanager from pathlib import Path +from typing import Any from unittest import mock import pytest @@ -21,10 +22,8 @@ def patch_pypi_packages_download(packages: list[str]) -> Iterator[mock.Mock]: Replaces call with the output you would get from downloading the top PyPi packages list. """ json_response = {"packages": packages, "date": datetime.datetime.now().isoformat()} - with mock.patch("twyn.trusted_packages.TopPyPiReference._download") as mock_download: mock_download.return_value = json_response - yield mock_download @@ -447,3 +446,10 @@ def yarn_lock_file_v2(tmp_path: Path) -> Iterator[Path]: """ with create_tmp_file(yarn_file, data) as tmp_file: yield tmp_file + + +@pytest.fixture(autouse=True) +def fail_on_requests_get(request) -> Generator[None, Any, None]: + with mock.patch("requests.get") as m_get: + m_get.side_effect = RuntimeError("`requests.get()` was called!") + yield diff --git a/tests/dependency_parser/test_dependency_selector.py b/tests/dependency_parser/test_dependency_selector.py index e952d027..23f72d37 100644 --- a/tests/dependency_parser/test_dependency_selector.py +++ b/tests/dependency_parser/test_dependency_selector.py @@ -1,4 +1,4 @@ -from unittest.mock import Mock, patch +from pathlib import Path import pytest from twyn.dependency_parser import PoetryLockParser, RequirementsTxtParser, UvLockParser @@ -7,6 +7,8 @@ NoMatchingParserError, ) from twyn.dependency_parser.parsers.abstract_parser import AbstractParser +from twyn.dependency_parser.parsers.package_lock_json import PackageLockJsonParser +from twyn.dependency_parser.parsers.yarn_lock_parser import YarnLockParser class TestDependencySelector: @@ -22,6 +24,8 @@ class TestDependencySelector: ("/some/path/poetry.lock", PoetryLockParser), ("/some/path/uv.lock", UvLockParser), ("/some/path/requirements.txt", RequirementsTxtParser), + ("/some/path/yarn.lock", YarnLockParser), + ("/some/path/package-lock.json", PackageLockJsonParser), ], ) def test_get_dependency_parser(self, file_name: str, parser_class: type[AbstractParser]): @@ -31,53 +35,49 @@ def test_get_dependency_parser(self, file_name: str, parser_class: type[Abstract assert isinstance(parser[0], parser_class) assert str(parser[0].file_handler.file_path).endswith(file_name) - @patch("twyn.dependency_parser.parsers.lock_parser.PoetryLockParser.file_exists") - @patch("twyn.dependency_parser.parsers.lock_parser.UvLockParser.file_exists") - @patch("twyn.dependency_parser.parsers.requirements_txt_parser.RequirementsTxtParser.file_exists") - def test_get_dependency_parser_auto_detect_requirements_file( - self, req_file_exists: Mock, uv_file_exists: Mock, poetry_file_exists: Mock - ): - poetry_file_exists.return_value = False - req_file_exists.return_value = True - uv_file_exists.return_value = False - - parser = DependencySelector("").get_dependency_parsers() + def test_get_dependency_parser_auto_detect_requirements_file(self, requirements_txt_file: Path, tmp_path: Path): + parser = DependencySelector("", root_path=str(tmp_path)).get_dependency_parsers() assert isinstance(parser[0], RequirementsTxtParser) - @patch("twyn.dependency_parser.parsers.lock_parser.PoetryLockParser.file_exists") - @patch("twyn.dependency_parser.parsers.lock_parser.UvLockParser.file_exists") - @patch("twyn.dependency_parser.parsers.requirements_txt_parser.RequirementsTxtParser.file_exists") def test_get_dependency_parser_auto_detect_poetry_lock_file( - self, req_file_exists: Mock, uv_file_exists: Mock, poetry_file_exists: Mock + self, poetry_lock_file_ge_1_5: Path, tmp_path: Path ) -> None: - poetry_file_exists.return_value = True - req_file_exists.return_value = False - uv_file_exists.return_value = False - - selector = DependencySelector("") + selector = DependencySelector("", root_path=str(tmp_path)) parser = selector.get_dependency_parsers() assert isinstance(parser[0], PoetryLockParser) - @patch("twyn.dependency_parser.parsers.lock_parser.PoetryLockParser.file_exists") - @patch("twyn.dependency_parser.parsers.lock_parser.UvLockParser.file_exists") - @patch("twyn.dependency_parser.parsers.requirements_txt_parser.RequirementsTxtParser.file_exists") - def test_get_dependency_parser_auto_detect_uv_lock_file( - self, req_file_exists: Mock, uv_file_exists: Mock, poetry_file_exists: Mock - ) -> None: - poetry_file_exists.return_value = False - req_file_exists.return_value = False - uv_file_exists.return_value = True - - parser = DependencySelector("").get_dependency_parsers() + def test_get_dependency_parser_auto_detect_uv_lock_file(self, uv_lock_file: Path, tmp_path: Path) -> None: + parser = DependencySelector("", root_path=str(tmp_path)).get_dependency_parsers() assert isinstance(parser[0], UvLockParser) - @patch("twyn.dependency_parser.parsers.abstract_parser.AbstractParser.file_exists") - def test_auto_detect_dependency_file_parser_exceptions(self, file_exists: Mock) -> None: - file_exists.return_value = False + def test_auto_detect_dependency_file_parser_exceptions(self, tmp_path: Path) -> None: with pytest.raises(NoMatchingParserError): - DependencySelector().get_dependency_parsers() + DependencySelector(root_path=str(tmp_path)).get_dependency_parsers() @pytest.mark.parametrize("file_name", ["unknown.txt", ""]) def test_get_dependency_file_parser_unknown_file_type(self, file_name: str) -> None: with pytest.raises(NoMatchingParserError): DependencySelector(file_name).get_dependency_file_parsers_from_file_name() + + def test_auto_detect_dependency_file_parser_scans_subdirectories(self, tmp_path: Path) -> None: + # Create nested directories and dependency files + subdir = tmp_path / "subdir" + subdir.mkdir() + + req_file = subdir / "requirements.txt" + req_file.write_text("flask\n") + poetry_file = tmp_path / "poetry.lock" + poetry_file.write_text("[package]\nname = 'pytest'\n") + + # Should not scan .git + git_dir = tmp_path / ".git" + git_dir.mkdir() + git_file = git_dir / "requirements.txt" + git_file.write_text("should not be found\n") + + # Should find both files + selector = DependencySelector(root_path=str(tmp_path)) + + parsers = selector.auto_detect_dependency_file_parser() + found_files = {str(p.file_path) for p in parsers} + assert found_files == {str(poetry_file), str(req_file)} diff --git a/tests/main/test_cli.py b/tests/main/test_cli.py index 72f54556..72c28a7b 100644 --- a/tests/main/test_cli.py +++ b/tests/main/test_cli.py @@ -95,6 +95,7 @@ def test_click_arguments_dependency_file(self, mock_check_dependencies: Mock) -> show_progress_bar=True, load_config_from_file=True, package_ecosystem=None, + recursive=False, ) ] @@ -119,6 +120,7 @@ def test_click_arguments_dependency_file_in_different_path(self, mock_check_depe show_progress_bar=True, load_config_from_file=True, package_ecosystem=None, + recursive=False, ) ] @@ -143,6 +145,7 @@ def test_package_ecosystem_option(self, mock_check_dependencies: Mock) -> None: show_progress_bar=True, load_config_from_file=True, package_ecosystem="pypi", + recursive=False, ) ] @@ -166,6 +169,7 @@ def test_click_arguments_single_dependency_cli(self, mock_check_dependencies: Mo show_progress_bar=True, load_config_from_file=True, package_ecosystem=None, + recursive=False, ) ] @@ -201,9 +205,40 @@ def test_click_arguments_multiple_dependencies(self, mock_check_dependencies: Mo show_progress_bar=True, load_config_from_file=True, package_ecosystem=None, + recursive=False, ) ] + @patch("twyn.cli.check_dependencies") + def test_recursive(self, mock_check_dependencies: Mock) -> None: + runner = CliRunner() + runner.invoke( + cli.run, + [ + "--recursive", + ], + ) + + runner.invoke( + cli.run, + [ + "-r", + ], + ) + call_args = call( + selector_method=None, + dependencies=None, + config_file=None, + dependency_file=None, + use_cache=None, + show_progress_bar=True, + load_config_from_file=True, + package_ecosystem=None, + recursive=True, + ) + assert mock_check_dependencies.call_args_list[0] == call_args + assert mock_check_dependencies.call_args_list[1] == call_args + @patch("twyn.cli.check_dependencies") def test_click_arguments_default(self, mock_check_dependencies: Mock) -> None: runner = CliRunner() @@ -219,6 +254,7 @@ def test_click_arguments_default(self, mock_check_dependencies: Mock) -> None: show_progress_bar=True, load_config_from_file=True, package_ecosystem=None, + recursive=False, ) ] diff --git a/tests/main/test_main.py b/tests/main/test_main.py index 7c4a0be7..fcb25e4e 100644 --- a/tests/main/test_main.py +++ b/tests/main/test_main.py @@ -12,6 +12,7 @@ get_dependency_manager_from_file, get_dependency_manager_from_name, ) +from twyn.dependency_parser.dependency_selector import DependencySelector from twyn.file_handler.file_handler import FileHandler from twyn.main import ( check_dependencies, @@ -38,6 +39,7 @@ class TestCheckDependencies: "dependency_file": "requirements.txt", "use_cache": True, "pypi_reference": "https://myurl.com", + "recurisve": True, }, { "selector_method": "nearby-letter", @@ -45,6 +47,7 @@ class TestCheckDependencies: "allowlist": ["boto4", "boto2"], # There is no allowlist option in the cli "use_cache": False, "pypi_reference": "https://mysecondurl.com", + "recursive": False, }, TwynConfiguration( dependency_file="requirements.txt", @@ -53,6 +56,7 @@ class TestCheckDependencies: source=TopPyPiReference.DEFAULT_SOURCE, use_cache=True, package_ecosystem="pypi", + recursive=True, ), ), # CLI args take precedence over config from file ( @@ -63,6 +67,7 @@ class TestCheckDependencies: "allowlist": ["boto4", "boto2"], "use_cache": False, "pypi_reference": "https://mysecondurl.com", + "recursive": True, }, TwynConfiguration( dependency_file="poetry.lock", @@ -71,6 +76,7 @@ class TestCheckDependencies: source=TopPyPiReference.DEFAULT_SOURCE, use_cache=False, package_ecosystem="pypi", + recursive=True, ), ), # Config from file takes precendence over fallback values ( @@ -83,6 +89,7 @@ class TestCheckDependencies: source=TopPyPiReference.DEFAULT_SOURCE, use_cache=True, package_ecosystem="pypi", + recursive=False, ), ), # Fallback values ], @@ -180,6 +187,7 @@ def test_check_dependencies_detects_typosquats_and_autodetects_file( source=None, use_cache=False, package_ecosystem=None, + recursive=False, ) mock_fpath.return_value = uv_lock_file_with_typo error = check_dependencies() @@ -214,6 +222,7 @@ def test_check_dependencies_detects_typosquats_and_autodetects_file_and_language source=None, use_cache=False, package_ecosystem="pypi", + recursive=False, ) mock_fpath.return_value = uv_lock_file_with_typo error = check_dependencies() @@ -248,6 +257,26 @@ def test_check_dependencies_with_input_from_cli_detects_typosquats( ] ) + @patch("twyn.trusted_packages.TopPyPiReference._get_packages_from_cache_if_enabled") + def test_check_dependencies_recursive_and_dependency_file_set( + self, mock_get_packages_from_cache: Mock, uv_lock_file_with_typo: Path + ) -> None: + """Test that recursive and dependency file can be set at the same time, and then dependency file takes precedence while recurisve is ignored.""" + mock_get_packages_from_cache.return_value = {"requests"} + error = check_dependencies( + dependency_file=str(uv_lock_file_with_typo), + package_ecosystem="pypi", + ) + + assert error == TyposquatCheckResults( + results=[ + TyposquatCheckResultFromSource( + errors=[TyposquatCheckResultEntry(dependency="reqests", similars=["requests"])], + source=str(uv_lock_file_with_typo), + ) + ] + ) + @patch("twyn.trusted_packages.TopPyPiReference._get_packages_from_cache_if_enabled") def test_check_dependencies_with_input_loads_file_from_different_location( self, mock_get_packages_from_cache: Mock, tmp_path: Path @@ -290,11 +319,14 @@ def test_check_dependencies_with_input_from_cli_accepts_multiple_dependencies( @patch("twyn.main._analyze_dependencies_from_input") @patch("twyn.main.ConfigHandler") - def test_check_dependencies_loads_config_from_file(self, mock_config: Mock, mock_analyze: Mock) -> None: + @patch("twyn.trusted_packages.TopPyPiReference.get_packages") + def test_check_dependencies_loads_config_from_file( + self, m_packages: Mock, mock_config: Mock, mock_analyze: Mock + ) -> None: mock_analyze.return_value = TyposquatCheckResults() mock_config.return_value = ConfigHandler() - - check_dependencies(load_config_from_file=True) + m_packages.return_value = {"requests"} + check_dependencies(load_config_from_file=True, use_cache=False, dependencies={"requests"}) assert mock_config.call_count == 1 assert len(mock_config.call_args) == 2 @@ -383,6 +415,7 @@ def test_check_dependencies_ignores_package_in_allowlist( use_cache=True, source=None, package_ecosystem=None, + recursive=False, ) # Check that the package is no longer an error @@ -417,6 +450,7 @@ def test_track_is_disabled_by_default_when_used_as_package( source=None, use_cache=False, package_ecosystem=None, + recursive=False, ) mock_get_packages.return_value = {"requests"} with patch("rich.progress.track") as m_track: @@ -433,6 +467,7 @@ def test_track_is_shown_when_enabled(self, mock_config: Mock, mock_get_packages: source=None, use_cache=False, package_ecosystem=None, + recursive=False, ) mock_get_packages.return_value = {"requests"} with patch("rich.progress.track") as m_track: @@ -449,9 +484,13 @@ def test_check_dependencies_invalid_language_error(self) -> None: with pytest.raises(InvalidArgumentsError): check_dependencies(dependencies={"foo"}, package_ecosystem="asdf") - def test_check_dependency_file_invalid_language_error(self, uv_lock_file_with_typo: Path) -> None: + @patch("twyn.trusted_packages.TopPyPiReference.get_packages") + def test_check_dependency_file_invalid_language_error(self, m_packages: Mock, uv_lock_file_with_typo: Path) -> None: """Test that when a non valid package_ecosystem is given together with a depdendecy_file, package_ecosystem is ignored.""" - result = check_dependencies(dependency_file=str(uv_lock_file_with_typo), package_ecosystem="npm") + m_packages.return_value = {"requests"} + result = check_dependencies( + dependency_file=str(uv_lock_file_with_typo), package_ecosystem="npm", use_cache=False + ) assert bool(result) def test_check_dependencies_invalid_selector_method_error(self) -> None: @@ -496,3 +535,23 @@ def test_check_dependencies_yarn(self, yarn_lock_file_v1: Path) -> None: source=str(yarn_lock_file_v1), ) ] + + @patch("twyn.main.DependencySelector") + @patch("twyn.trusted_packages.TopPyPiReference.get_packages") + def test_auto_detect_dependency_file_parser_scans_subdirectories( + self, m_packages: Mock, m_dep_selector: Mock, tmp_path: Path + ) -> None: + # Create nested directories and dependency files + subdir = tmp_path / "subdir" + subdir.mkdir() + + req_file = subdir / "requirements.txt" + req_file.write_text("reqests\n") + + m_dep_selector.return_value = DependencySelector(root_path=str(tmp_path)) + m_packages.return_value = {"requests"} + + error = check_dependencies(recursive=True, use_cache=False) + + assert len(error.results) == 1 + assert error.get_results_from_source(str(req_file)) is not None