Skip to content

Commit 1ebd3b1

Browse files
authored
feat: support uv.lock files (#244)
1 parent 6d46062 commit 1ebd3b1

9 files changed

Lines changed: 111 additions & 31 deletions

File tree

src/twyn/base/constants.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,12 @@
1717
}
1818

1919
DEPENDENCY_FILE_MAPPING: dict[str, type[AbstractParser]] = {
20-
"requirements.txt": dependency_parser.requirements_txt.RequirementsTxtParser,
21-
"poetry.lock": dependency_parser.poetry_lock.PoetryLockParser,
20+
"requirements.txt": dependency_parser.requirements_txt_parser.RequirementsTxtParser,
21+
"poetry.lock": dependency_parser.lock_parser.PoetryLockParser,
22+
"uv.lock": dependency_parser.lock_parser.UvLockParser,
2223
}
2324

2425
DEFAULT_SELECTOR_METHOD = "all"
25-
DEFAULT_DEPENDENCY_FILE = "requirements.txt"
2626
DEFAULT_PROJECT_TOML_FILE = "pyproject.toml"
2727
DEFAULT_TOP_PYPI_PACKAGES = "https://hugovk.github.io/top-pypi-packages/top-pypi-packages.min.json"
2828

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Dependency parsers."""
22

3-
from twyn.dependency_parser.poetry_lock import PoetryLockParser
4-
from twyn.dependency_parser.requirements_txt import RequirementsTxtParser
3+
from twyn.dependency_parser.lock_parser import PoetryLockParser, UvLockParser
4+
from twyn.dependency_parser.requirements_txt_parser import RequirementsTxtParser
55

6-
__all__ = ["RequirementsTxtParser", "PoetryLockParser"]
6+
__all__ = ["RequirementsTxtParser", "PoetryLockParser", "UvLockParser"]
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
UV_LOCK = "uv.lock"
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,34 @@
1-
"""Parser for poetry.lock dependencies."""
2-
31
import sys
42

53
from dparse import filetypes
64

75
from twyn.dependency_parser.abstract_parser import AbstractParser
6+
from twyn.dependency_parser.constants import UV_LOCK
87

98
if sys.version_info >= (3, 11):
109
import tomllib
1110
else:
1211
import tomli as tomllib
1312

1413

15-
class PoetryLockParser(AbstractParser):
16-
"""Parser for poetry.lock."""
17-
18-
def __init__(self, file_path: str = filetypes.poetry_lock) -> None:
19-
super().__init__(file_path)
14+
class LockParser(AbstractParser):
15+
"""Parser for poetry.lock and uv.lock files."""
2016

2117
def parse(self) -> set[str]:
22-
"""Parse poetry.lock dependencies into set of dependency names."""
18+
"""Parse dependencies names and map them to a set."""
2319
data = tomllib.loads(self._read())
2420
return {dependency["name"] for dependency in data["package"]}
21+
22+
23+
class PoetryLockParser(LockParser):
24+
"""Parser for poetry.lock files."""
25+
26+
def __init__(self, file_path: str = filetypes.poetry_lock) -> None:
27+
super().__init__(file_path)
28+
29+
30+
class UvLockParser(LockParser):
31+
"""Parser for uv.lock files."""
32+
33+
def __init__(self, file_path: str = UV_LOCK) -> None:
34+
super().__init__(file_path)
File renamed without changes.

tests/conftest.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,51 @@ def poetry_lock_file_ge_1_5(tmp_path: Path) -> Iterator[str]:
124124
yield tmp_file
125125

126126

127+
@pytest.fixture
128+
def uv_lock_file(tmp_path: Path) -> Iterator[str]:
129+
"""Uv lock file."""
130+
uv_lock_file = tmp_path / "uv.lock"
131+
data = """
132+
version = 1
133+
revision = 2
134+
requires-python = ">=3.13, <4"
135+
136+
[[package]]
137+
name = "annotated-types"
138+
version = "0.7.0"
139+
source = { registry = "https://pypi.org/simple" }
140+
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" }
141+
wheels = [
142+
{ 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" },
143+
]
144+
145+
[[package]]
146+
name = "anyio"
147+
version = "4.9.0"
148+
source = { registry = "https://pypi.org/simple" }
149+
dependencies = [
150+
{ name = "idna" },
151+
{ name = "sniffio" },
152+
]
153+
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" }
154+
wheels = [
155+
{ 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" },
156+
]
157+
158+
[[package]]
159+
name = "argcomplete"
160+
version = "3.6.2"
161+
source = { registry = "https://pypi.org/simple" }
162+
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" }
163+
wheels = [
164+
{ 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" },
165+
]
166+
167+
"""
168+
with create_tmp_file(uv_lock_file, data) as tmp_file:
169+
yield tmp_file
170+
171+
127172
@pytest.fixture
128173
def pyproject_toml_file(tmp_path: Path) -> Iterator[str]:
129174
pyproject_toml = tmp_path / "pyproject.toml"

tests/dependency_parser/test_dependency_parser.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from unittest.mock import patch
22

33
import pytest
4-
from twyn.dependency_parser import PoetryLockParser, RequirementsTxtParser
4+
from twyn.dependency_parser import PoetryLockParser, RequirementsTxtParser, UvLockParser
55
from twyn.dependency_parser.abstract_parser import AbstractParser
66
from twyn.file_handler.exceptions import PathIsNotFileError, PathNotFoundError
77

@@ -42,11 +42,15 @@ def test_parse_requirements_txt_file(self, requirements_txt_file):
4242
assert parser.parse() == {"South", "pycrypto"}
4343

4444

45-
class TestPoetryLockParser:
45+
class TestLockParser:
4646
def test_parse_poetry_lock_file_lt_1_5(self, poetry_lock_file_lt_1_5):
4747
parser = PoetryLockParser(file_path=poetry_lock_file_lt_1_5)
4848
assert parser.parse() == {"charset-normalizer", "flake8", "mccabe"}
4949

5050
def test_parse_poetry_lock_file_ge_1_5(self, poetry_lock_file_ge_1_5):
5151
parser = PoetryLockParser(file_path=poetry_lock_file_ge_1_5)
5252
assert parser.parse() == {"charset-normalizer", "flake8", "mccabe"}
53+
54+
def test_uv_lock_parser(self, uv_lock_file) -> None:
55+
parser = UvLockParser(file_path=uv_lock_file)
56+
assert parser.parse() == {"annotated-types", "anyio", "argcomplete"}

tests/dependency_parser/test_dependency_selector.py

Lines changed: 32 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
from unittest.mock import patch
1+
from unittest.mock import Mock, patch
22

33
import pytest
4-
from twyn.dependency_parser import PoetryLockParser, RequirementsTxtParser
4+
from twyn.dependency_parser import PoetryLockParser, RequirementsTxtParser, UvLockParser
5+
from twyn.dependency_parser.abstract_parser import AbstractParser
56
from twyn.dependency_parser.dependency_selector import DependencySelector
67
from twyn.dependency_parser.exceptions import (
78
MultipleParsersError,
@@ -11,44 +12,63 @@
1112

1213
class TestDependencySelector:
1314
@pytest.mark.parametrize(
14-
"file_name, parser_obj",
15+
"file_name, parser_class",
1516
[
1617
(
1718
"requirements.txt",
1819
RequirementsTxtParser,
1920
), # because file is specified, we won't autocheck
2021
("poetry.lock", PoetryLockParser),
22+
("uv.lock", UvLockParser),
2123
("/some/path/poetry.lock", PoetryLockParser),
24+
("/some/path/uv.lock", UvLockParser),
2225
("/some/path/requirements.txt", RequirementsTxtParser),
2326
],
2427
)
25-
def test_get_dependency_parser(self, file_name, parser_obj):
28+
def test_get_dependency_parser(self, file_name: str, parser_class: type[AbstractParser]):
2629
parser = DependencySelector(file_name).get_dependency_parser()
27-
assert isinstance(parser, parser_obj)
30+
assert isinstance(parser, parser_class)
2831
assert str(parser.file_handler.file_path).endswith(file_name)
2932

30-
@patch("twyn.dependency_parser.poetry_lock.PoetryLockParser.file_exists")
31-
@patch("twyn.dependency_parser.requirements_txt.RequirementsTxtParser.file_exists")
33+
@patch("twyn.dependency_parser.lock_parser.LockParser.file_exists")
34+
@patch("twyn.dependency_parser.lock_parser.UvLockParser.file_exists")
35+
@patch("twyn.dependency_parser.requirements_txt_parser.RequirementsTxtParser.file_exists")
3236
def test_get_dependency_parser_auto_detect_requirements_file(
33-
self, req_file_exists, poetry_file_exists, requirements_txt_file
37+
self, req_file_exists: Mock, uv_file_exists: Mock, poetry_file_exists: Mock
3438
):
3539
poetry_file_exists.return_value = False
3640
req_file_exists.return_value = True
41+
uv_file_exists.return_value = False
3742

3843
parser = DependencySelector("").get_dependency_parser()
3944
assert isinstance(parser, RequirementsTxtParser)
4045

41-
@patch("twyn.dependency_parser.poetry_lock.PoetryLockParser.file_exists")
42-
@patch("twyn.dependency_parser.requirements_txt.RequirementsTxtParser.file_exists")
43-
def test_get_dependency_parser_auto_detect_poetry_file(
44-
self, req_file_exists, poetry_file_exists, requirements_txt_file
46+
@patch("twyn.dependency_parser.lock_parser.PoetryLockParser.file_exists")
47+
@patch("twyn.dependency_parser.lock_parser.UvLockParser.file_exists")
48+
@patch("twyn.dependency_parser.requirements_txt_parser.RequirementsTxtParser.file_exists")
49+
def test_get_dependency_parser_auto_detect_poetry_lock_file(
50+
self, req_file_exists: Mock, uv_file_exists: Mock, poetry_file_exists: Mock
4551
):
4652
poetry_file_exists.return_value = True
4753
req_file_exists.return_value = False
54+
uv_file_exists.return_value = False
4855

4956
parser = DependencySelector("").get_dependency_parser()
5057
assert isinstance(parser, PoetryLockParser)
5158

59+
@patch("twyn.dependency_parser.lock_parser.PoetryLockParser.file_exists")
60+
@patch("twyn.dependency_parser.lock_parser.UvLockParser.file_exists")
61+
@patch("twyn.dependency_parser.requirements_txt_parser.RequirementsTxtParser.file_exists")
62+
def test_get_dependency_parser_auto_detect_uv_lock_file(
63+
self, req_file_exists: Mock, uv_file_exists: Mock, poetry_file_exists: Mock
64+
):
65+
poetry_file_exists.return_value = False
66+
req_file_exists.return_value = False
67+
uv_file_exists.return_value = True
68+
69+
parser = DependencySelector("").get_dependency_parser()
70+
assert isinstance(parser, UvLockParser)
71+
5272
@pytest.mark.parametrize(
5373
"exists, exception",
5474
[(True, MultipleParsersError), (False, NoMatchingParserError)],

tests/main/test_main.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from unittest.mock import patch
1+
from unittest.mock import Mock, patch
22

33
import pytest
44
from tomlkit import dumps, parse
@@ -267,8 +267,8 @@ def test_check_dependencies_does_not_error_on_same_package(
267267
assert error is False
268268

269269
@patch("twyn.dependency_parser.dependency_selector.DependencySelector.get_dependency_parser")
270-
@patch("twyn.dependency_parser.requirements_txt.RequirementsTxtParser.parse")
271-
def test_get_parsed_dependencies_from_file(self, mock_parse, mock_get_dependency_parser):
270+
@patch("twyn.dependency_parser.requirements_txt_parser.RequirementsTxtParser.parse")
271+
def test_get_parsed_dependencies_from_file(self, mock_parse: Mock, mock_get_dependency_parser: Mock):
272272
mock_get_dependency_parser.return_value = RequirementsTxtParser()
273273
mock_parse.return_value = {"boto3"}
274274
assert get_parsed_dependencies_from_file() == {"boto3"}

0 commit comments

Comments
 (0)