diff --git a/src/twyn/base/constants.py b/src/twyn/base/constants.py index 5c8f6f4..b9a1a8e 100644 --- a/src/twyn/base/constants.py +++ b/src/twyn/base/constants.py @@ -17,12 +17,12 @@ } DEPENDENCY_FILE_MAPPING: dict[str, type[AbstractParser]] = { - "requirements.txt": dependency_parser.requirements_txt.RequirementsTxtParser, - "poetry.lock": dependency_parser.poetry_lock.PoetryLockParser, + "requirements.txt": dependency_parser.requirements_txt_parser.RequirementsTxtParser, + "poetry.lock": dependency_parser.lock_parser.PoetryLockParser, + "uv.lock": dependency_parser.lock_parser.UvLockParser, } DEFAULT_SELECTOR_METHOD = "all" -DEFAULT_DEPENDENCY_FILE = "requirements.txt" DEFAULT_PROJECT_TOML_FILE = "pyproject.toml" DEFAULT_TOP_PYPI_PACKAGES = "https://hugovk.github.io/top-pypi-packages/top-pypi-packages.min.json" diff --git a/src/twyn/dependency_parser/__init__.py b/src/twyn/dependency_parser/__init__.py index fd544d1..a7c5b31 100644 --- a/src/twyn/dependency_parser/__init__.py +++ b/src/twyn/dependency_parser/__init__.py @@ -1,6 +1,6 @@ """Dependency parsers.""" -from twyn.dependency_parser.poetry_lock import PoetryLockParser -from twyn.dependency_parser.requirements_txt import RequirementsTxtParser +from twyn.dependency_parser.lock_parser import PoetryLockParser, UvLockParser +from twyn.dependency_parser.requirements_txt_parser import RequirementsTxtParser -__all__ = ["RequirementsTxtParser", "PoetryLockParser"] +__all__ = ["RequirementsTxtParser", "PoetryLockParser", "UvLockParser"] diff --git a/src/twyn/dependency_parser/constants.py b/src/twyn/dependency_parser/constants.py new file mode 100644 index 0000000..45a62d1 --- /dev/null +++ b/src/twyn/dependency_parser/constants.py @@ -0,0 +1 @@ +UV_LOCK = "uv.lock" diff --git a/src/twyn/dependency_parser/poetry_lock.py b/src/twyn/dependency_parser/lock_parser.py similarity index 50% rename from src/twyn/dependency_parser/poetry_lock.py rename to src/twyn/dependency_parser/lock_parser.py index 79c2d61..b473e48 100644 --- a/src/twyn/dependency_parser/poetry_lock.py +++ b/src/twyn/dependency_parser/lock_parser.py @@ -1,10 +1,9 @@ -"""Parser for poetry.lock dependencies.""" - import sys from dparse import filetypes from twyn.dependency_parser.abstract_parser import AbstractParser +from twyn.dependency_parser.constants import UV_LOCK if sys.version_info >= (3, 11): import tomllib @@ -12,13 +11,24 @@ import tomli as tomllib -class PoetryLockParser(AbstractParser): - """Parser for poetry.lock.""" - - def __init__(self, file_path: str = filetypes.poetry_lock) -> None: - super().__init__(file_path) +class LockParser(AbstractParser): + """Parser for poetry.lock and uv.lock files.""" def parse(self) -> set[str]: - """Parse poetry.lock dependencies into set of dependency names.""" + """Parse dependencies names and map them to a set.""" data = tomllib.loads(self._read()) return {dependency["name"] for dependency in data["package"]} + + +class PoetryLockParser(LockParser): + """Parser for poetry.lock files.""" + + def __init__(self, file_path: str = filetypes.poetry_lock) -> None: + super().__init__(file_path) + + +class UvLockParser(LockParser): + """Parser for uv.lock files.""" + + def __init__(self, file_path: str = UV_LOCK) -> None: + super().__init__(file_path) diff --git a/src/twyn/dependency_parser/requirements_txt.py b/src/twyn/dependency_parser/requirements_txt_parser.py similarity index 100% rename from src/twyn/dependency_parser/requirements_txt.py rename to src/twyn/dependency_parser/requirements_txt_parser.py diff --git a/tests/conftest.py b/tests/conftest.py index ceef56a..e20fd34 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -124,6 +124,51 @@ def poetry_lock_file_ge_1_5(tmp_path: Path) -> Iterator[str]: yield tmp_file +@pytest.fixture +def uv_lock_file(tmp_path: Path) -> Iterator[str]: + """Uv lock file.""" + uv_lock_file = tmp_path / "uv.lock" + data = """ + version = 1 + revision = 2 + requires-python = ">=3.13, <4" + + [[package]] + name = "annotated-types" + version = "0.7.0" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, + ] + + [[package]] + name = "anyio" + version = "4.9.0" + source = { registry = "https://pypi.org/simple" } + dependencies = [ + { name = "idna" }, + { name = "sniffio" }, + ] + sdist = { url = "https://files.pythonhosted.org/packages/95/7d/4c1bd541d4dffa1b52bd83fb8527089e097a106fc90b467a7313b105f840/anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028", size = 190949, upload-time = "2025-03-17T00:02:54.77Z" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916, upload-time = "2025-03-17T00:02:52.713Z" }, + ] + + [[package]] + name = "argcomplete" + version = "3.6.2" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/16/0f/861e168fc813c56a78b35f3c30d91c6757d1fd185af1110f1aec784b35d0/argcomplete-3.6.2.tar.gz", hash = "sha256:d0519b1bc867f5f4f4713c41ad0aba73a4a5f007449716b16f385f2166dc6adf", size = 73403, upload-time = "2025-04-03T04:57:03.52Z" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/31/da/e42d7a9d8dd33fa775f467e4028a47936da2f01e4b0e561f9ba0d74cb0ca/argcomplete-3.6.2-py3-none-any.whl", hash = "sha256:65b3133a29ad53fb42c48cf5114752c7ab66c1c38544fdf6460f450c09b42591", size = 43708, upload-time = "2025-04-03T04:57:01.591Z" }, + ] + + """ + with create_tmp_file(uv_lock_file, data) as tmp_file: + yield tmp_file + + @pytest.fixture def pyproject_toml_file(tmp_path: Path) -> Iterator[str]: pyproject_toml = tmp_path / "pyproject.toml" diff --git a/tests/dependency_parser/test_dependency_parser.py b/tests/dependency_parser/test_dependency_parser.py index 08ea530..c497eeb 100644 --- a/tests/dependency_parser/test_dependency_parser.py +++ b/tests/dependency_parser/test_dependency_parser.py @@ -1,7 +1,7 @@ from unittest.mock import patch import pytest -from twyn.dependency_parser import PoetryLockParser, RequirementsTxtParser +from twyn.dependency_parser import PoetryLockParser, RequirementsTxtParser, UvLockParser from twyn.dependency_parser.abstract_parser import AbstractParser from twyn.file_handler.exceptions import PathIsNotFileError, PathNotFoundError @@ -42,7 +42,7 @@ def test_parse_requirements_txt_file(self, requirements_txt_file): assert parser.parse() == {"South", "pycrypto"} -class TestPoetryLockParser: +class TestLockParser: def test_parse_poetry_lock_file_lt_1_5(self, poetry_lock_file_lt_1_5): parser = PoetryLockParser(file_path=poetry_lock_file_lt_1_5) assert parser.parse() == {"charset-normalizer", "flake8", "mccabe"} @@ -50,3 +50,7 @@ def test_parse_poetry_lock_file_lt_1_5(self, poetry_lock_file_lt_1_5): def test_parse_poetry_lock_file_ge_1_5(self, poetry_lock_file_ge_1_5): parser = PoetryLockParser(file_path=poetry_lock_file_ge_1_5) assert parser.parse() == {"charset-normalizer", "flake8", "mccabe"} + + def test_uv_lock_parser(self, uv_lock_file) -> None: + parser = UvLockParser(file_path=uv_lock_file) + assert parser.parse() == {"annotated-types", "anyio", "argcomplete"} diff --git a/tests/dependency_parser/test_dependency_selector.py b/tests/dependency_parser/test_dependency_selector.py index 6325a6e..9bd39a2 100644 --- a/tests/dependency_parser/test_dependency_selector.py +++ b/tests/dependency_parser/test_dependency_selector.py @@ -1,7 +1,8 @@ -from unittest.mock import patch +from unittest.mock import Mock, patch import pytest -from twyn.dependency_parser import PoetryLockParser, RequirementsTxtParser +from twyn.dependency_parser import PoetryLockParser, RequirementsTxtParser, UvLockParser +from twyn.dependency_parser.abstract_parser import AbstractParser from twyn.dependency_parser.dependency_selector import DependencySelector from twyn.dependency_parser.exceptions import ( MultipleParsersError, @@ -11,44 +12,63 @@ class TestDependencySelector: @pytest.mark.parametrize( - "file_name, parser_obj", + "file_name, parser_class", [ ( "requirements.txt", RequirementsTxtParser, ), # because file is specified, we won't autocheck ("poetry.lock", PoetryLockParser), + ("uv.lock", UvLockParser), ("/some/path/poetry.lock", PoetryLockParser), + ("/some/path/uv.lock", UvLockParser), ("/some/path/requirements.txt", RequirementsTxtParser), ], ) - def test_get_dependency_parser(self, file_name, parser_obj): + def test_get_dependency_parser(self, file_name: str, parser_class: type[AbstractParser]): parser = DependencySelector(file_name).get_dependency_parser() - assert isinstance(parser, parser_obj) + assert isinstance(parser, parser_class) 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") + @patch("twyn.dependency_parser.lock_parser.LockParser.file_exists") + @patch("twyn.dependency_parser.lock_parser.UvLockParser.file_exists") + @patch("twyn.dependency_parser.requirements_txt_parser.RequirementsTxtParser.file_exists") def test_get_dependency_parser_auto_detect_requirements_file( - self, req_file_exists, poetry_file_exists, requirements_txt_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_parser() assert isinstance(parser, RequirementsTxtParser) - @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_poetry_file( - self, req_file_exists, poetry_file_exists, requirements_txt_file + @patch("twyn.dependency_parser.lock_parser.PoetryLockParser.file_exists") + @patch("twyn.dependency_parser.lock_parser.UvLockParser.file_exists") + @patch("twyn.dependency_parser.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 ): poetry_file_exists.return_value = True req_file_exists.return_value = False + uv_file_exists.return_value = False parser = DependencySelector("").get_dependency_parser() assert isinstance(parser, PoetryLockParser) + @patch("twyn.dependency_parser.lock_parser.PoetryLockParser.file_exists") + @patch("twyn.dependency_parser.lock_parser.UvLockParser.file_exists") + @patch("twyn.dependency_parser.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 + ): + poetry_file_exists.return_value = False + req_file_exists.return_value = False + uv_file_exists.return_value = True + + parser = DependencySelector("").get_dependency_parser() + assert isinstance(parser, UvLockParser) + @pytest.mark.parametrize( "exists, exception", [(True, MultipleParsersError), (False, NoMatchingParserError)], diff --git a/tests/main/test_main.py b/tests/main/test_main.py index c081d3c..82f2cfa 100644 --- a/tests/main/test_main.py +++ b/tests/main/test_main.py @@ -1,4 +1,4 @@ -from unittest.mock import patch +from unittest.mock import Mock, patch import pytest from tomlkit import dumps, parse @@ -267,8 +267,8 @@ def test_check_dependencies_does_not_error_on_same_package( assert error is False @patch("twyn.dependency_parser.dependency_selector.DependencySelector.get_dependency_parser") - @patch("twyn.dependency_parser.requirements_txt.RequirementsTxtParser.parse") - def test_get_parsed_dependencies_from_file(self, mock_parse, mock_get_dependency_parser): + @patch("twyn.dependency_parser.requirements_txt_parser.RequirementsTxtParser.parse") + def test_get_parsed_dependencies_from_file(self, mock_parse: Mock, mock_get_dependency_parser: Mock): mock_get_dependency_parser.return_value = RequirementsTxtParser() mock_parse.return_value = {"boto3"} assert get_parsed_dependencies_from_file() == {"boto3"}