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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |

Expand Down
4 changes: 3 additions & 1 deletion cycode/cli/consts.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@
'pyproject.toml',
'uv.lock',
'poetry.lock',
'pylock.toml',
'pipfile',
'pipfile.lock',
'requirements.txt',
Expand Down Expand Up @@ -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'],
Expand Down
Original file line number Diff line number Diff line change
@@ -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]
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 @@ -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
Expand Down Expand Up @@ -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),
]
Expand Down
Original file line number Diff line number Diff line change
@@ -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'