Skip to content
Open
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
3 changes: 3 additions & 0 deletions cycode/cli/consts.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@
'build.scala',
'build.sbt.lock',
'pyproject.toml',
'uv.lock',
'poetry.lock',
'pipfile',
'pipfile.lock',
Expand Down Expand Up @@ -124,6 +125,7 @@
'.build',
'.dart_tool',
'.pub',
'.uv',
)

PROJECT_FILES_BY_ECOSYSTEM_MAP = {
Expand All @@ -145,6 +147,7 @@
'nuget': ['packages.config', 'project.assets.json', 'packages.lock.json', 'nuget.config'],
'ruby_gems': ['Gemfile', 'Gemfile.lock'],
'sbt': ['build.sbt', 'build.scala', 'build.sbt.lock'],
'pypi_uv': ['pyproject.toml', 'uv.lock'],
'pypi_poetry': ['pyproject.toml', 'poetry.lock'],
'pypi_pipenv': ['Pipfile', 'Pipfile.lock'],
'pypi_requirements': ['requirements.txt'],
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
from pathlib import Path
from typing import Optional

import typer

from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies, build_dep_tree_path
from cycode.cli.models import Document
from cycode.cli.utils.path_utils import get_file_content
from cycode.logger import get_logger

logger = get_logger('UV Restore Dependencies')

UV_MANIFEST_FILE_NAME = 'pyproject.toml'
UV_LOCK_FILE_NAME = 'uv.lock'

_UV_TOOL_SECTION = '[tool.uv]'


def _indicates_uv(pyproject_content: Optional[str]) -> bool:
"""Return True if pyproject.toml content signals that this project uses UV."""
if not pyproject_content:
return False
return _UV_TOOL_SECTION in pyproject_content


class RestoreUvDependencies(BaseRestoreDependencies):
def __init__(self, ctx: typer.Context, is_git_diff: bool, command_timeout: int) -> None:
super().__init__(ctx, is_git_diff, command_timeout)

def is_project(self, document: Document) -> bool:
if Path(document.path).name != UV_MANIFEST_FILE_NAME:
return False

manifest_dir = self.get_manifest_dir(document)
if manifest_dir and (Path(manifest_dir) / UV_LOCK_FILE_NAME).is_file():
return True

return _indicates_uv(document.content)

def try_restore_dependencies(self, document: Document) -> Optional[Document]:
manifest_dir = self.get_manifest_dir(document)
lockfile_path = Path(manifest_dir) / UV_LOCK_FILE_NAME if manifest_dir else None

if lockfile_path and lockfile_path.is_file():
content = get_file_content(str(lockfile_path))
relative_path = build_dep_tree_path(document.path, UV_LOCK_FILE_NAME)
logger.debug('Using existing uv.lock, %s', {'path': str(lockfile_path)})
return Document(relative_path, content, self.is_git_diff)

return super().try_restore_dependencies(document)

def get_commands(self, manifest_file_path: str) -> list[list[str]]:
return [['uv', 'lock']]

def get_lock_file_name(self) -> str:
return UV_LOCK_FILE_NAME

def get_lock_file_names(self) -> list[str]:
return [UV_LOCK_FILE_NAME]
2 changes: 2 additions & 0 deletions cycode/cli/files_collector/sca/sca_file_collector.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from cycode.cli.files_collector.sca.php.restore_composer_dependencies import RestoreComposerDependencies
from cycode.cli.files_collector.sca.python.restore_pipenv_dependencies import RestorePipenvDependencies
from cycode.cli.files_collector.sca.python.restore_poetry_dependencies import RestorePoetryDependencies
from cycode.cli.files_collector.sca.python.restore_uv_dependencies import RestoreUvDependencies
from cycode.cli.files_collector.sca.ruby.restore_ruby_dependencies import RestoreRubyDependencies
from cycode.cli.files_collector.sca.sbt.restore_sbt_dependencies import RestoreSbtDependencies
from cycode.cli.models import Document
Expand Down Expand Up @@ -159,6 +160,7 @@ def _get_restore_handlers(ctx: typer.Context, is_git_diff: bool) -> list[BaseRes
RestoreDenoDependencies(ctx, is_git_diff, build_dep_tree_timeout),
RestoreNpmDependencies(ctx, is_git_diff, build_dep_tree_timeout), # Must be after Yarn & Pnpm for fallback
RestoreRubyDependencies(ctx, is_git_diff, build_dep_tree_timeout),
RestoreUvDependencies(ctx, is_git_diff, build_dep_tree_timeout), # Must be before Poetry for pyproject.toml
RestorePoetryDependencies(ctx, is_git_diff, build_dep_tree_timeout),
RestorePipenvDependencies(ctx, is_git_diff, build_dep_tree_timeout),
RestoreComposerDependencies(ctx, is_git_diff, build_dep_tree_timeout),
Expand Down
138 changes: 138 additions & 0 deletions tests/cli/files_collector/sca/python/test_restore_uv_dependencies.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
from pathlib import Path
from typing import Optional
from unittest.mock import MagicMock, patch

import pytest
import typer

from cycode.cli.files_collector.sca.python.restore_uv_dependencies import (
UV_LOCK_FILE_NAME,
RestoreUvDependencies,
)
from cycode.cli.models import Document


@pytest.fixture
def mock_ctx(tmp_path: Path) -> typer.Context:
ctx = MagicMock(spec=typer.Context)
ctx.obj = {'monitor': False}
ctx.params = {'path': str(tmp_path)}
return ctx


@pytest.fixture
def restore_uv(mock_ctx: typer.Context) -> RestoreUvDependencies:
return RestoreUvDependencies(mock_ctx, is_git_diff=False, command_timeout=30)


class TestIsProject:
def test_pyproject_toml_with_uv_lock_matches(self, restore_uv: RestoreUvDependencies, tmp_path: Path) -> None:
(tmp_path / 'pyproject.toml').write_text('[build-system]\nrequires = ["hatchling"]\n')
(tmp_path / 'uv.lock').write_text('version = 1\n')
doc = Document(
str(tmp_path / 'pyproject.toml'),
'[build-system]\nrequires = ["hatchling"]\n',
absolute_path=str(tmp_path / 'pyproject.toml'),
)
assert restore_uv.is_project(doc) is True

def test_pyproject_toml_with_tool_uv_section_matches(self, restore_uv: RestoreUvDependencies) -> None:
content = '[tool.uv]\ndev-dependencies = ["pytest"]\n'
doc = Document('pyproject.toml', content)
assert restore_uv.is_project(doc) is True

def test_pyproject_toml_without_uv_signals_does_not_match(
self, restore_uv: RestoreUvDependencies, tmp_path: Path
) -> None:
content = '[tool.poetry]\nname = "my-project"\n'
(tmp_path / 'pyproject.toml').write_text(content)
doc = Document(
str(tmp_path / 'pyproject.toml'),
content,
absolute_path=str(tmp_path / 'pyproject.toml'),
)
assert restore_uv.is_project(doc) is False

def test_requirements_txt_does_not_match(self, restore_uv: RestoreUvDependencies) -> None:
doc = Document('requirements.txt', 'requests==2.31.0\n')
assert restore_uv.is_project(doc) is False

def test_empty_content_does_not_match(self, restore_uv: RestoreUvDependencies, tmp_path: Path) -> None:
(tmp_path / 'pyproject.toml').write_text('')
doc = Document(
str(tmp_path / 'pyproject.toml'),
'',
absolute_path=str(tmp_path / 'pyproject.toml'),
)
assert restore_uv.is_project(doc) is False


class TestTryRestoreDependencies:
def test_existing_uv_lock_returned_directly(self, restore_uv: RestoreUvDependencies, tmp_path: Path) -> None:
lock_content = 'version = 1\n\n[[package]]\nname = "requests"\n'
(tmp_path / 'pyproject.toml').write_text('[tool.uv]\n')
(tmp_path / 'uv.lock').write_text(lock_content)

doc = Document(
str(tmp_path / 'pyproject.toml'),
'[tool.uv]\n',
absolute_path=str(tmp_path / 'pyproject.toml'),
)
result = restore_uv.try_restore_dependencies(doc)

assert result is not None
assert UV_LOCK_FILE_NAME in result.path
assert result.content == lock_content

def test_get_lock_file_name(self, restore_uv: RestoreUvDependencies) -> None:
assert restore_uv.get_lock_file_name() == UV_LOCK_FILE_NAME

def test_get_commands_returns_uv_lock(self, restore_uv: RestoreUvDependencies) -> None:
commands = restore_uv.get_commands('/path/to/pyproject.toml')
assert commands == [['uv', 'lock']]


_BASE_MODULE = 'cycode.cli.files_collector.sca.base_restore_dependencies'


class TestCleanup:
def test_generated_lockfile_is_deleted_after_restore(
self, restore_uv: RestoreUvDependencies, tmp_path: Path
) -> None:
manifest_content = '[tool.uv]\ndev-dependencies = ["pytest"]\n'
(tmp_path / 'pyproject.toml').write_text(manifest_content)
doc = Document(
str(tmp_path / 'pyproject.toml'), manifest_content, absolute_path=str(tmp_path / 'pyproject.toml')
)
lock_path = tmp_path / UV_LOCK_FILE_NAME

def side_effect(
commands: list,
timeout: int,
output_file_path: Optional[str] = None,
working_directory: Optional[str] = None,
) -> str:
lock_path.write_text('version = 1\n')
return 'output'

with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect):
result = restore_uv.try_restore_dependencies(doc)

assert result is not None
assert not lock_path.exists(), f'{UV_LOCK_FILE_NAME} must be deleted after restore'

def test_preexisting_lockfile_is_not_deleted(self, restore_uv: RestoreUvDependencies, tmp_path: Path) -> None:
lock_content = 'version = 1\n\n[[package]]\nname = "requests"\n'
(tmp_path / 'pyproject.toml').write_text('[tool.uv]\n')
lock_path = tmp_path / UV_LOCK_FILE_NAME
lock_path.write_text(lock_content)
doc = Document(
str(tmp_path / 'pyproject.toml'),
'[tool.uv]\n',
absolute_path=str(tmp_path / 'pyproject.toml'),
)

result = restore_uv.try_restore_dependencies(doc)

assert result is not None
assert lock_path.exists(), f'Pre-existing {UV_LOCK_FILE_NAME} must not be deleted'
Loading