From 425df76d5264cdc268289f4deba668bae2112a66 Mon Sep 17 00:00:00 2001 From: Valerii Stryhun Date: Fri, 24 Jul 2026 10:17:26 +0200 Subject: [PATCH] CM-68709: Add CLI lock restoration for plain-pip projects --- README.md | 1 + cycode/cli/consts.py | 4 +- .../sca/python/restore_pip_dependencies.py | 69 ++++++ .../files_collector/sca/sca_file_collector.py | 2 + .../python/test_restore_pip_dependencies.py | 217 ++++++++++++++++++ 5 files changed, 292 insertions(+), 1 deletion(-) create mode 100644 cycode/cli/files_collector/sca/python/restore_pip_dependencies.py create mode 100644 tests/cli/files_collector/sca/python/test_restore_pip_dependencies.py diff --git a/README.md b/README.md index d48c4fcc..6ccbee88 100644 --- a/README.md +++ b/README.md @@ -814,6 +814,7 @@ The following ecosystems support automatic lockfile restoration: | NuGet | `*.csproj` | `packages.lock.json` | `dotnet restore --use-lock-file` | | Ruby | `Gemfile` | `Gemfile.lock` | `bundle --quiet` | | Poetry | `pyproject.toml` | `poetry.lock` | `poetry lock` | +| pip | `pyproject.toml` / `requirements.txt` | `pylock.toml` | `pip lock .` / `pip lock -r requirements.txt -o pylock.toml` | | Pipenv | `Pipfile` | `Pipfile.lock` | `pipenv lock` | | PHP Composer | `composer.json` | `composer.lock` | `composer update --no-cache --no-install --no-scripts --ignore-platform-reqs` | diff --git a/cycode/cli/consts.py b/cycode/cli/consts.py index 37ef2298..9007fda9 100644 --- a/cycode/cli/consts.py +++ b/cycode/cli/consts.py @@ -118,6 +118,7 @@ 'pyproject.toml', 'uv.lock', 'poetry.lock', + 'pylock.toml', 'pipfile', 'pipfile.lock', 'requirements.txt', @@ -175,8 +176,9 @@ 'sbt': ['build.sbt', 'build.scala', 'build.sbt.lock'], 'pypi_uv': ['pyproject.toml', 'uv.lock'], 'pypi_poetry': ['pyproject.toml', 'poetry.lock'], + 'pypi_pip': ['pyproject.toml', 'pylock.toml'], 'pypi_pipenv': ['Pipfile', 'Pipfile.lock'], - 'pypi_requirements': ['requirements.txt'], + 'pypi_requirements': ['requirements.txt', 'pylock.toml'], 'pypi_setup': ['setup.py'], 'hex': ['mix.exs', 'mix.lock'], 'swift_pm': ['Package.swift', 'Package.resolved'], diff --git a/cycode/cli/files_collector/sca/python/restore_pip_dependencies.py b/cycode/cli/files_collector/sca/python/restore_pip_dependencies.py new file mode 100644 index 00000000..29ebfc6e --- /dev/null +++ b/cycode/cli/files_collector/sca/python/restore_pip_dependencies.py @@ -0,0 +1,69 @@ +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('Pip Restore Dependencies') + +PIP_PYPROJECT_MANIFEST_FILE_NAME = 'pyproject.toml' +PIP_REQUIREMENTS_MANIFEST_FILE_NAME = 'requirements.txt' +PIP_LOCK_FILE_NAME = 'pylock.toml' + +_POETRY_TOOL_SECTION = '[tool.poetry]' +_UV_TOOL_SECTION = '[tool.uv]' + + +def _indicates_plain_pip(pyproject_content: Optional[str]) -> bool: + """Return True if pyproject.toml content signals a plain-pip project (no Poetry, no uv).""" + if not pyproject_content: + return False + return _POETRY_TOOL_SECTION not in pyproject_content and _UV_TOOL_SECTION not in pyproject_content + + +class RestorePipDependencies(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: + manifest_name = Path(document.path).name + + if manifest_name == PIP_REQUIREMENTS_MANIFEST_FILE_NAME: + return True + + if manifest_name != PIP_PYPROJECT_MANIFEST_FILE_NAME: + return False + + manifest_dir = self.get_manifest_dir(document) + if manifest_dir and (Path(manifest_dir) / PIP_LOCK_FILE_NAME).is_file(): + return True + + return _indicates_plain_pip(document.content) + + def try_restore_dependencies(self, document: Document) -> Optional[Document]: + manifest_dir = self.get_manifest_dir(document) + lockfile_path = Path(manifest_dir) / PIP_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, PIP_LOCK_FILE_NAME) + logger.debug('Using existing pylock.toml, %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]]: + if Path(manifest_file_path).name == PIP_REQUIREMENTS_MANIFEST_FILE_NAME: + return [['pip', 'lock', '-r', 'requirements.txt', '-o', PIP_LOCK_FILE_NAME]] + + return [['pip', 'lock', '.']] + + def get_lock_file_name(self) -> str: + return PIP_LOCK_FILE_NAME + + def get_lock_file_names(self) -> list[str]: + return [PIP_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 6bcfd494..4db5cd04 100644 --- a/cycode/cli/files_collector/sca/sca_file_collector.py +++ b/cycode/cli/files_collector/sca/sca_file_collector.py @@ -17,6 +17,7 @@ from cycode.cli.files_collector.sca.npm.restore_yarn_dependencies import RestoreYarnDependencies from cycode.cli.files_collector.sca.nuget.restore_nuget_dependencies import RestoreNugetDependencies from cycode.cli.files_collector.sca.php.restore_composer_dependencies import RestoreComposerDependencies +from cycode.cli.files_collector.sca.python.restore_pip_dependencies import RestorePipDependencies 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 @@ -164,6 +165,7 @@ def _get_restore_handlers(ctx: typer.Context, is_git_diff: bool) -> list[BaseRes 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), + RestorePipDependencies(ctx, is_git_diff, build_dep_tree_timeout), # Must be after Uv & Poetry (pyproject.toml) 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_pip_dependencies.py b/tests/cli/files_collector/sca/python/test_restore_pip_dependencies.py new file mode 100644 index 00000000..c0f17476 --- /dev/null +++ b/tests/cli/files_collector/sca/python/test_restore_pip_dependencies.py @@ -0,0 +1,217 @@ +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_pip_dependencies import ( + PIP_LOCK_FILE_NAME, + RestorePipDependencies, +) +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_pip(mock_ctx: typer.Context) -> RestorePipDependencies: + return RestorePipDependencies(mock_ctx, is_git_diff=False, command_timeout=30) + + +class TestIsProject: + def test_plain_pyproject_toml_matches(self, restore_pip: RestorePipDependencies) -> None: + content = '[project]\nname = "my-project"\ndependencies = ["requests"]\n' + doc = Document('pyproject.toml', content) + assert restore_pip.is_project(doc) is True + + def test_pyproject_toml_with_poetry_section_does_not_match(self, restore_pip: RestorePipDependencies) -> None: + content = '[tool.poetry]\nname = "my-project"\n' + doc = Document('pyproject.toml', content) + assert restore_pip.is_project(doc) is False + + def test_pyproject_toml_with_uv_section_does_not_match(self, restore_pip: RestorePipDependencies) -> None: + content = '[tool.uv]\nindex-url = "https://example.com"\n' + doc = Document('pyproject.toml', content) + assert restore_pip.is_project(doc) is False + + def test_pyproject_toml_with_existing_pylock_matches( + self, restore_pip: RestorePipDependencies, tmp_path: Path + ) -> None: + (tmp_path / 'pyproject.toml').write_text('[project]\nname = "test"\n') + (tmp_path / PIP_LOCK_FILE_NAME).write_text('lock-version = "1.0"\n') + doc = Document( + str(tmp_path / 'pyproject.toml'), + '[project]\nname = "test"\n', + absolute_path=str(tmp_path / 'pyproject.toml'), + ) + assert restore_pip.is_project(doc) is True + + def test_requirements_txt_matches(self, restore_pip: RestorePipDependencies) -> None: + doc = Document('requirements.txt', 'requests==2.31.0\n') + assert restore_pip.is_project(doc) is True + + def test_setup_py_does_not_match(self, restore_pip: RestorePipDependencies) -> None: + doc = Document('setup.py', 'from setuptools import setup\nsetup()\n') + assert restore_pip.is_project(doc) is False + + def test_empty_pyproject_toml_does_not_match(self, restore_pip: RestorePipDependencies) -> None: + # Same conservative behavior as Poetry/Uv's own is_project: empty content can't be + # confirmed as plain-pip, so don't claim it. + doc = Document('pyproject.toml', '') + assert restore_pip.is_project(doc) is False + + +class TestGetCommands: + def test_get_commands_for_pyproject_toml(self, restore_pip: RestorePipDependencies) -> None: + commands = restore_pip.get_commands('/path/to/pyproject.toml') + assert commands == [['pip', 'lock', '.']] + + def test_get_commands_for_requirements_txt(self, restore_pip: RestorePipDependencies) -> None: + commands = restore_pip.get_commands('/path/to/requirements.txt') + assert commands == [['pip', 'lock', '-r', 'requirements.txt', '-o', PIP_LOCK_FILE_NAME]] + + def test_get_lock_file_name(self, restore_pip: RestorePipDependencies) -> None: + assert restore_pip.get_lock_file_name() == PIP_LOCK_FILE_NAME + + +class TestTryRestoreDependencies: + def test_existing_pylock_returned_directly_for_pyproject_toml( + self, restore_pip: RestorePipDependencies, tmp_path: Path + ) -> None: + lock_content = 'lock-version = "1.0"\n\n[[packages]]\nname = "requests"\n' + (tmp_path / 'pyproject.toml').write_text('[project]\nname = "test"\n') + (tmp_path / PIP_LOCK_FILE_NAME).write_text(lock_content) + + doc = Document( + str(tmp_path / 'pyproject.toml'), + '[project]\nname = "test"\n', + absolute_path=str(tmp_path / 'pyproject.toml'), + ) + result = restore_pip.try_restore_dependencies(doc) + + assert result is not None + assert PIP_LOCK_FILE_NAME in result.path + assert result.content == lock_content + + def test_existing_pylock_returned_directly_for_requirements_txt( + self, restore_pip: RestorePipDependencies, tmp_path: Path + ) -> None: + lock_content = 'lock-version = "1.0"\n\n[[packages]]\nname = "requests"\n' + (tmp_path / 'requirements.txt').write_text('requests==2.31.0\n') + (tmp_path / PIP_LOCK_FILE_NAME).write_text(lock_content) + + doc = Document( + str(tmp_path / 'requirements.txt'), + 'requests==2.31.0\n', + absolute_path=str(tmp_path / 'requirements.txt'), + ) + result = restore_pip.try_restore_dependencies(doc) + + assert result is not None + assert result.content == lock_content + + +_BASE_MODULE = 'cycode.cli.files_collector.sca.base_restore_dependencies' + + +class TestRestoreWithoutExistingLock: + def test_pyproject_toml_runs_pip_lock_dot(self, restore_pip: RestorePipDependencies, tmp_path: Path) -> None: + manifest_content = '[project]\nname = "test"\ndependencies = ["requests"]\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') + ) + + seen_commands = [] + + def side_effect( + commands: list, + timeout: int, + output_file_path: Optional[str] = None, + working_directory: Optional[str] = None, + ) -> str: + seen_commands.extend(commands) + (tmp_path / PIP_LOCK_FILE_NAME).write_text('lock-version = "1.0"\n') + return 'output' + + with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect): + result = restore_pip.try_restore_dependencies(doc) + + assert result is not None + assert seen_commands == [['pip', 'lock', '.']] + + def test_requirements_txt_runs_pip_lock_dash_r(self, restore_pip: RestorePipDependencies, tmp_path: Path) -> None: + (tmp_path / 'requirements.txt').write_text('requests==2.31.0\n') + doc = Document( + str(tmp_path / 'requirements.txt'), + 'requests==2.31.0\n', + absolute_path=str(tmp_path / 'requirements.txt'), + ) + + seen_commands = [] + + def side_effect( + commands: list, + timeout: int, + output_file_path: Optional[str] = None, + working_directory: Optional[str] = None, + ) -> str: + seen_commands.extend(commands) + (tmp_path / PIP_LOCK_FILE_NAME).write_text('lock-version = "1.0"\n') + return 'output' + + with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect): + result = restore_pip.try_restore_dependencies(doc) + + assert result is not None + assert seen_commands == [['pip', 'lock', '-r', 'requirements.txt', '-o', PIP_LOCK_FILE_NAME]] + + +class TestCleanup: + def test_generated_lockfile_is_deleted_after_restore( + self, restore_pip: RestorePipDependencies, tmp_path: Path + ) -> None: + manifest_content = '[project]\nname = "test"\ndependencies = ["requests"]\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 / PIP_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('lock-version = "1.0"\n') + return 'output' + + with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect): + result = restore_pip.try_restore_dependencies(doc) + + assert result is not None + assert not lock_path.exists(), f'{PIP_LOCK_FILE_NAME} must be deleted after restore' + + def test_preexisting_lockfile_is_not_deleted(self, restore_pip: RestorePipDependencies, tmp_path: Path) -> None: + lock_content = 'lock-version = "1.0"\n' + (tmp_path / 'pyproject.toml').write_text('[project]\nname = "test"\n') + lock_path = tmp_path / PIP_LOCK_FILE_NAME + lock_path.write_text(lock_content) + doc = Document( + str(tmp_path / 'pyproject.toml'), + '[project]\nname = "test"\n', + absolute_path=str(tmp_path / 'pyproject.toml'), + ) + + result = restore_pip.try_restore_dependencies(doc) + + assert result is not None + assert lock_path.exists(), f'Pre-existing {PIP_LOCK_FILE_NAME} must not be deleted'