diff --git a/cycode/cli/consts.py b/cycode/cli/consts.py index 108aa0e1..52a6827d 100644 --- a/cycode/cli/consts.py +++ b/cycode/cli/consts.py @@ -91,6 +91,7 @@ 'build.scala', 'build.sbt.lock', 'pyproject.toml', + 'uv.lock', 'poetry.lock', 'pipfile', 'pipfile.lock', @@ -124,6 +125,7 @@ '.build', '.dart_tool', '.pub', + '.uv', ) PROJECT_FILES_BY_ECOSYSTEM_MAP = { @@ -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'], diff --git a/cycode/cli/files_collector/sca/python/restore_uv_dependencies.py b/cycode/cli/files_collector/sca/python/restore_uv_dependencies.py new file mode 100644 index 00000000..c05d857c --- /dev/null +++ b/cycode/cli/files_collector/sca/python/restore_uv_dependencies.py @@ -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] diff --git a/cycode/cli/files_collector/sca/sca_file_collector.py b/cycode/cli/files_collector/sca/sca_file_collector.py index c9c17ebf..b57061b0 100644 --- a/cycode/cli/files_collector/sca/sca_file_collector.py +++ b/cycode/cli/files_collector/sca/sca_file_collector.py @@ -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 @@ -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), diff --git a/tests/cli/files_collector/sca/python/test_restore_uv_dependencies.py b/tests/cli/files_collector/sca/python/test_restore_uv_dependencies.py new file mode 100644 index 00000000..70e4e7ae --- /dev/null +++ b/tests/cli/files_collector/sca/python/test_restore_uv_dependencies.py @@ -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'