Skip to content

Commit a9e0197

Browse files
authored
CM-55107 add support for UV package manager for SCA scans (#441)
1 parent b7451e5 commit a9e0197

4 files changed

Lines changed: 202 additions & 0 deletions

File tree

cycode/cli/consts.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@
9191
'build.scala',
9292
'build.sbt.lock',
9393
'pyproject.toml',
94+
'uv.lock',
9495
'poetry.lock',
9596
'pipfile',
9697
'pipfile.lock',
@@ -124,6 +125,7 @@
124125
'.build',
125126
'.dart_tool',
126127
'.pub',
128+
'.uv',
127129
)
128130

129131
PROJECT_FILES_BY_ECOSYSTEM_MAP = {
@@ -145,6 +147,7 @@
145147
'nuget': ['packages.config', 'project.assets.json', 'packages.lock.json', 'nuget.config'],
146148
'ruby_gems': ['Gemfile', 'Gemfile.lock'],
147149
'sbt': ['build.sbt', 'build.scala', 'build.sbt.lock'],
150+
'pypi_uv': ['pyproject.toml', 'uv.lock'],
148151
'pypi_poetry': ['pyproject.toml', 'poetry.lock'],
149152
'pypi_pipenv': ['Pipfile', 'Pipfile.lock'],
150153
'pypi_requirements': ['requirements.txt'],
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
from pathlib import Path
2+
from typing import Optional
3+
4+
import typer
5+
6+
from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies, build_dep_tree_path
7+
from cycode.cli.models import Document
8+
from cycode.cli.utils.path_utils import get_file_content
9+
from cycode.logger import get_logger
10+
11+
logger = get_logger('UV Restore Dependencies')
12+
13+
UV_MANIFEST_FILE_NAME = 'pyproject.toml'
14+
UV_LOCK_FILE_NAME = 'uv.lock'
15+
16+
_UV_TOOL_SECTION = '[tool.uv]'
17+
18+
19+
def _indicates_uv(pyproject_content: Optional[str]) -> bool:
20+
"""Return True if pyproject.toml content signals that this project uses UV."""
21+
if not pyproject_content:
22+
return False
23+
return _UV_TOOL_SECTION in pyproject_content
24+
25+
26+
class RestoreUvDependencies(BaseRestoreDependencies):
27+
def __init__(self, ctx: typer.Context, is_git_diff: bool, command_timeout: int) -> None:
28+
super().__init__(ctx, is_git_diff, command_timeout)
29+
30+
def is_project(self, document: Document) -> bool:
31+
if Path(document.path).name != UV_MANIFEST_FILE_NAME:
32+
return False
33+
34+
manifest_dir = self.get_manifest_dir(document)
35+
if manifest_dir and (Path(manifest_dir) / UV_LOCK_FILE_NAME).is_file():
36+
return True
37+
38+
return _indicates_uv(document.content)
39+
40+
def try_restore_dependencies(self, document: Document) -> Optional[Document]:
41+
manifest_dir = self.get_manifest_dir(document)
42+
lockfile_path = Path(manifest_dir) / UV_LOCK_FILE_NAME if manifest_dir else None
43+
44+
if lockfile_path and lockfile_path.is_file():
45+
content = get_file_content(str(lockfile_path))
46+
relative_path = build_dep_tree_path(document.path, UV_LOCK_FILE_NAME)
47+
logger.debug('Using existing uv.lock, %s', {'path': str(lockfile_path)})
48+
return Document(relative_path, content, self.is_git_diff)
49+
50+
return super().try_restore_dependencies(document)
51+
52+
def get_commands(self, manifest_file_path: str) -> list[list[str]]:
53+
return [['uv', 'lock']]
54+
55+
def get_lock_file_name(self) -> str:
56+
return UV_LOCK_FILE_NAME
57+
58+
def get_lock_file_names(self) -> list[str]:
59+
return [UV_LOCK_FILE_NAME]

cycode/cli/files_collector/sca/sca_file_collector.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from cycode.cli.files_collector.sca.php.restore_composer_dependencies import RestoreComposerDependencies
1919
from cycode.cli.files_collector.sca.python.restore_pipenv_dependencies import RestorePipenvDependencies
2020
from cycode.cli.files_collector.sca.python.restore_poetry_dependencies import RestorePoetryDependencies
21+
from cycode.cli.files_collector.sca.python.restore_uv_dependencies import RestoreUvDependencies
2122
from cycode.cli.files_collector.sca.ruby.restore_ruby_dependencies import RestoreRubyDependencies
2223
from cycode.cli.files_collector.sca.sbt.restore_sbt_dependencies import RestoreSbtDependencies
2324
from cycode.cli.models import Document
@@ -159,6 +160,7 @@ def _get_restore_handlers(ctx: typer.Context, is_git_diff: bool) -> list[BaseRes
159160
RestoreDenoDependencies(ctx, is_git_diff, build_dep_tree_timeout),
160161
RestoreNpmDependencies(ctx, is_git_diff, build_dep_tree_timeout), # Must be after Yarn & Pnpm for fallback
161162
RestoreRubyDependencies(ctx, is_git_diff, build_dep_tree_timeout),
163+
RestoreUvDependencies(ctx, is_git_diff, build_dep_tree_timeout), # Must be before Poetry for pyproject.toml
162164
RestorePoetryDependencies(ctx, is_git_diff, build_dep_tree_timeout),
163165
RestorePipenvDependencies(ctx, is_git_diff, build_dep_tree_timeout),
164166
RestoreComposerDependencies(ctx, is_git_diff, build_dep_tree_timeout),
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
from pathlib import Path
2+
from typing import Optional
3+
from unittest.mock import MagicMock, patch
4+
5+
import pytest
6+
import typer
7+
8+
from cycode.cli.files_collector.sca.python.restore_uv_dependencies import (
9+
UV_LOCK_FILE_NAME,
10+
RestoreUvDependencies,
11+
)
12+
from cycode.cli.models import Document
13+
14+
15+
@pytest.fixture
16+
def mock_ctx(tmp_path: Path) -> typer.Context:
17+
ctx = MagicMock(spec=typer.Context)
18+
ctx.obj = {'monitor': False}
19+
ctx.params = {'path': str(tmp_path)}
20+
return ctx
21+
22+
23+
@pytest.fixture
24+
def restore_uv(mock_ctx: typer.Context) -> RestoreUvDependencies:
25+
return RestoreUvDependencies(mock_ctx, is_git_diff=False, command_timeout=30)
26+
27+
28+
class TestIsProject:
29+
def test_pyproject_toml_with_uv_lock_matches(self, restore_uv: RestoreUvDependencies, tmp_path: Path) -> None:
30+
(tmp_path / 'pyproject.toml').write_text('[build-system]\nrequires = ["hatchling"]\n')
31+
(tmp_path / 'uv.lock').write_text('version = 1\n')
32+
doc = Document(
33+
str(tmp_path / 'pyproject.toml'),
34+
'[build-system]\nrequires = ["hatchling"]\n',
35+
absolute_path=str(tmp_path / 'pyproject.toml'),
36+
)
37+
assert restore_uv.is_project(doc) is True
38+
39+
def test_pyproject_toml_with_tool_uv_section_matches(self, restore_uv: RestoreUvDependencies) -> None:
40+
content = '[tool.uv]\ndev-dependencies = ["pytest"]\n'
41+
doc = Document('pyproject.toml', content)
42+
assert restore_uv.is_project(doc) is True
43+
44+
def test_pyproject_toml_without_uv_signals_does_not_match(
45+
self, restore_uv: RestoreUvDependencies, tmp_path: Path
46+
) -> None:
47+
content = '[tool.poetry]\nname = "my-project"\n'
48+
(tmp_path / 'pyproject.toml').write_text(content)
49+
doc = Document(
50+
str(tmp_path / 'pyproject.toml'),
51+
content,
52+
absolute_path=str(tmp_path / 'pyproject.toml'),
53+
)
54+
assert restore_uv.is_project(doc) is False
55+
56+
def test_requirements_txt_does_not_match(self, restore_uv: RestoreUvDependencies) -> None:
57+
doc = Document('requirements.txt', 'requests==2.31.0\n')
58+
assert restore_uv.is_project(doc) is False
59+
60+
def test_empty_content_does_not_match(self, restore_uv: RestoreUvDependencies, tmp_path: Path) -> None:
61+
(tmp_path / 'pyproject.toml').write_text('')
62+
doc = Document(
63+
str(tmp_path / 'pyproject.toml'),
64+
'',
65+
absolute_path=str(tmp_path / 'pyproject.toml'),
66+
)
67+
assert restore_uv.is_project(doc) is False
68+
69+
70+
class TestTryRestoreDependencies:
71+
def test_existing_uv_lock_returned_directly(self, restore_uv: RestoreUvDependencies, tmp_path: Path) -> None:
72+
lock_content = 'version = 1\n\n[[package]]\nname = "requests"\n'
73+
(tmp_path / 'pyproject.toml').write_text('[tool.uv]\n')
74+
(tmp_path / 'uv.lock').write_text(lock_content)
75+
76+
doc = Document(
77+
str(tmp_path / 'pyproject.toml'),
78+
'[tool.uv]\n',
79+
absolute_path=str(tmp_path / 'pyproject.toml'),
80+
)
81+
result = restore_uv.try_restore_dependencies(doc)
82+
83+
assert result is not None
84+
assert UV_LOCK_FILE_NAME in result.path
85+
assert result.content == lock_content
86+
87+
def test_get_lock_file_name(self, restore_uv: RestoreUvDependencies) -> None:
88+
assert restore_uv.get_lock_file_name() == UV_LOCK_FILE_NAME
89+
90+
def test_get_commands_returns_uv_lock(self, restore_uv: RestoreUvDependencies) -> None:
91+
commands = restore_uv.get_commands('/path/to/pyproject.toml')
92+
assert commands == [['uv', 'lock']]
93+
94+
95+
_BASE_MODULE = 'cycode.cli.files_collector.sca.base_restore_dependencies'
96+
97+
98+
class TestCleanup:
99+
def test_generated_lockfile_is_deleted_after_restore(
100+
self, restore_uv: RestoreUvDependencies, tmp_path: Path
101+
) -> None:
102+
manifest_content = '[tool.uv]\ndev-dependencies = ["pytest"]\n'
103+
(tmp_path / 'pyproject.toml').write_text(manifest_content)
104+
doc = Document(
105+
str(tmp_path / 'pyproject.toml'), manifest_content, absolute_path=str(tmp_path / 'pyproject.toml')
106+
)
107+
lock_path = tmp_path / UV_LOCK_FILE_NAME
108+
109+
def side_effect(
110+
commands: list,
111+
timeout: int,
112+
output_file_path: Optional[str] = None,
113+
working_directory: Optional[str] = None,
114+
) -> str:
115+
lock_path.write_text('version = 1\n')
116+
return 'output'
117+
118+
with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect):
119+
result = restore_uv.try_restore_dependencies(doc)
120+
121+
assert result is not None
122+
assert not lock_path.exists(), f'{UV_LOCK_FILE_NAME} must be deleted after restore'
123+
124+
def test_preexisting_lockfile_is_not_deleted(self, restore_uv: RestoreUvDependencies, tmp_path: Path) -> None:
125+
lock_content = 'version = 1\n\n[[package]]\nname = "requests"\n'
126+
(tmp_path / 'pyproject.toml').write_text('[tool.uv]\n')
127+
lock_path = tmp_path / UV_LOCK_FILE_NAME
128+
lock_path.write_text(lock_content)
129+
doc = Document(
130+
str(tmp_path / 'pyproject.toml'),
131+
'[tool.uv]\n',
132+
absolute_path=str(tmp_path / 'pyproject.toml'),
133+
)
134+
135+
result = restore_uv.try_restore_dependencies(doc)
136+
137+
assert result is not None
138+
assert lock_path.exists(), f'Pre-existing {UV_LOCK_FILE_NAME} must not be deleted'

0 commit comments

Comments
 (0)