Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions src/twyn/base/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
6 changes: 3 additions & 3 deletions src/twyn/dependency_parser/__init__.py
Original file line number Diff line number Diff line change
@@ -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"]
1 change: 1 addition & 0 deletions src/twyn/dependency_parser/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
UV_LOCK = "uv.lock"
Original file line number Diff line number Diff line change
@@ -1,24 +1,34 @@
"""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
else:
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)
45 changes: 45 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
8 changes: 6 additions & 2 deletions tests/dependency_parser/test_dependency_parser.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -42,11 +42,15 @@ 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"}

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"}
44 changes: 32 additions & 12 deletions tests/dependency_parser/test_dependency_selector.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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)],
Expand Down
6 changes: 3 additions & 3 deletions tests/main/test_main.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from unittest.mock import patch
from unittest.mock import Mock, patch

import pytest
from tomlkit import dumps, parse
Expand Down Expand Up @@ -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"}