Skip to content

Commit 94696a4

Browse files
CM-68709: Add CLI lock restoration for plain-pip projects
1 parent ed2f714 commit 94696a4

5 files changed

Lines changed: 292 additions & 1 deletion

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -814,6 +814,7 @@ The following ecosystems support automatic lockfile restoration:
814814
| NuGet | `*.csproj` | `packages.lock.json` | `dotnet restore --use-lock-file` |
815815
| Ruby | `Gemfile` | `Gemfile.lock` | `bundle --quiet` |
816816
| Poetry | `pyproject.toml` | `poetry.lock` | `poetry lock` |
817+
| pip | `pyproject.toml` / `requirements.txt` | `pylock.toml` | `pip lock .` / `pip lock -r requirements.txt -o pylock.toml` |
817818
| Pipenv | `Pipfile` | `Pipfile.lock` | `pipenv lock` |
818819
| PHP Composer | `composer.json` | `composer.lock` | `composer update --no-cache --no-install --no-scripts --ignore-platform-reqs` |
819820

cycode/cli/consts.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@
118118
'pyproject.toml',
119119
'uv.lock',
120120
'poetry.lock',
121+
'pylock.toml',
121122
'pipfile',
122123
'pipfile.lock',
123124
'requirements.txt',
@@ -175,8 +176,9 @@
175176
'sbt': ['build.sbt', 'build.scala', 'build.sbt.lock'],
176177
'pypi_uv': ['pyproject.toml', 'uv.lock'],
177178
'pypi_poetry': ['pyproject.toml', 'poetry.lock'],
179+
'pypi_pip': ['pyproject.toml', 'pylock.toml'],
178180
'pypi_pipenv': ['Pipfile', 'Pipfile.lock'],
179-
'pypi_requirements': ['requirements.txt'],
181+
'pypi_requirements': ['requirements.txt', 'pylock.toml'],
180182
'pypi_setup': ['setup.py'],
181183
'hex': ['mix.exs', 'mix.lock'],
182184
'swift_pm': ['Package.swift', 'Package.resolved'],
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
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('Pip Restore Dependencies')
12+
13+
PIP_PYPROJECT_MANIFEST_FILE_NAME = 'pyproject.toml'
14+
PIP_REQUIREMENTS_MANIFEST_FILE_NAME = 'requirements.txt'
15+
PIP_LOCK_FILE_NAME = 'pylock.toml'
16+
17+
_POETRY_TOOL_SECTION = '[tool.poetry]'
18+
_UV_TOOL_SECTION = '[tool.uv]'
19+
20+
21+
def _indicates_plain_pip(pyproject_content: Optional[str]) -> bool:
22+
"""Return True if pyproject.toml content signals a plain-pip project (no Poetry, no uv)."""
23+
if not pyproject_content:
24+
return False
25+
return _POETRY_TOOL_SECTION not in pyproject_content and _UV_TOOL_SECTION not in pyproject_content
26+
27+
28+
class RestorePipDependencies(BaseRestoreDependencies):
29+
def __init__(self, ctx: typer.Context, is_git_diff: bool, command_timeout: int) -> None:
30+
super().__init__(ctx, is_git_diff, command_timeout)
31+
32+
def is_project(self, document: Document) -> bool:
33+
manifest_name = Path(document.path).name
34+
35+
if manifest_name == PIP_REQUIREMENTS_MANIFEST_FILE_NAME:
36+
return True
37+
38+
if manifest_name != PIP_PYPROJECT_MANIFEST_FILE_NAME:
39+
return False
40+
41+
manifest_dir = self.get_manifest_dir(document)
42+
if manifest_dir and (Path(manifest_dir) / PIP_LOCK_FILE_NAME).is_file():
43+
return True
44+
45+
return _indicates_plain_pip(document.content)
46+
47+
def try_restore_dependencies(self, document: Document) -> Optional[Document]:
48+
manifest_dir = self.get_manifest_dir(document)
49+
lockfile_path = Path(manifest_dir) / PIP_LOCK_FILE_NAME if manifest_dir else None
50+
51+
if lockfile_path and lockfile_path.is_file():
52+
content = get_file_content(str(lockfile_path))
53+
relative_path = build_dep_tree_path(document.path, PIP_LOCK_FILE_NAME)
54+
logger.debug('Using existing pylock.toml, %s', {'path': str(lockfile_path)})
55+
return Document(relative_path, content, self.is_git_diff)
56+
57+
return super().try_restore_dependencies(document)
58+
59+
def get_commands(self, manifest_file_path: str) -> list[list[str]]:
60+
if Path(manifest_file_path).name == PIP_REQUIREMENTS_MANIFEST_FILE_NAME:
61+
return [['pip', 'lock', '-r', 'requirements.txt', '-o', PIP_LOCK_FILE_NAME]]
62+
63+
return [['pip', 'lock', '.']]
64+
65+
def get_lock_file_name(self) -> str:
66+
return PIP_LOCK_FILE_NAME
67+
68+
def get_lock_file_names(self) -> list[str]:
69+
return [PIP_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
@@ -17,6 +17,7 @@
1717
from cycode.cli.files_collector.sca.npm.restore_yarn_dependencies import RestoreYarnDependencies
1818
from cycode.cli.files_collector.sca.nuget.restore_nuget_dependencies import RestoreNugetDependencies
1919
from cycode.cli.files_collector.sca.php.restore_composer_dependencies import RestoreComposerDependencies
20+
from cycode.cli.files_collector.sca.python.restore_pip_dependencies import RestorePipDependencies
2021
from cycode.cli.files_collector.sca.python.restore_pipenv_dependencies import RestorePipenvDependencies
2122
from cycode.cli.files_collector.sca.python.restore_poetry_dependencies import RestorePoetryDependencies
2223
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
164165
RestoreRubyDependencies(ctx, is_git_diff, build_dep_tree_timeout),
165166
RestoreUvDependencies(ctx, is_git_diff, build_dep_tree_timeout), # Must be before Poetry for pyproject.toml
166167
RestorePoetryDependencies(ctx, is_git_diff, build_dep_tree_timeout),
168+
RestorePipDependencies(ctx, is_git_diff, build_dep_tree_timeout), # Must be after Uv & Poetry for pyproject.toml
167169
RestorePipenvDependencies(ctx, is_git_diff, build_dep_tree_timeout),
168170
RestoreComposerDependencies(ctx, is_git_diff, build_dep_tree_timeout),
169171
]
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
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_pip_dependencies import (
9+
PIP_LOCK_FILE_NAME,
10+
RestorePipDependencies,
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_pip(mock_ctx: typer.Context) -> RestorePipDependencies:
25+
return RestorePipDependencies(mock_ctx, is_git_diff=False, command_timeout=30)
26+
27+
28+
class TestIsProject:
29+
def test_plain_pyproject_toml_matches(self, restore_pip: RestorePipDependencies) -> None:
30+
content = '[project]\nname = "my-project"\ndependencies = ["requests"]\n'
31+
doc = Document('pyproject.toml', content)
32+
assert restore_pip.is_project(doc) is True
33+
34+
def test_pyproject_toml_with_poetry_section_does_not_match(self, restore_pip: RestorePipDependencies) -> None:
35+
content = '[tool.poetry]\nname = "my-project"\n'
36+
doc = Document('pyproject.toml', content)
37+
assert restore_pip.is_project(doc) is False
38+
39+
def test_pyproject_toml_with_uv_section_does_not_match(self, restore_pip: RestorePipDependencies) -> None:
40+
content = '[tool.uv]\nindex-url = "https://example.com"\n'
41+
doc = Document('pyproject.toml', content)
42+
assert restore_pip.is_project(doc) is False
43+
44+
def test_pyproject_toml_with_existing_pylock_matches(
45+
self, restore_pip: RestorePipDependencies, tmp_path: Path
46+
) -> None:
47+
(tmp_path / 'pyproject.toml').write_text('[project]\nname = "test"\n')
48+
(tmp_path / PIP_LOCK_FILE_NAME).write_text('lock-version = "1.0"\n')
49+
doc = Document(
50+
str(tmp_path / 'pyproject.toml'),
51+
'[project]\nname = "test"\n',
52+
absolute_path=str(tmp_path / 'pyproject.toml'),
53+
)
54+
assert restore_pip.is_project(doc) is True
55+
56+
def test_requirements_txt_matches(self, restore_pip: RestorePipDependencies) -> None:
57+
doc = Document('requirements.txt', 'requests==2.31.0\n')
58+
assert restore_pip.is_project(doc) is True
59+
60+
def test_setup_py_does_not_match(self, restore_pip: RestorePipDependencies) -> None:
61+
doc = Document('setup.py', 'from setuptools import setup\nsetup()\n')
62+
assert restore_pip.is_project(doc) is False
63+
64+
def test_empty_pyproject_toml_does_not_match(self, restore_pip: RestorePipDependencies) -> None:
65+
# Same conservative behavior as Poetry/Uv's own is_project: empty content can't be
66+
# confirmed as plain-pip, so don't claim it.
67+
doc = Document('pyproject.toml', '')
68+
assert restore_pip.is_project(doc) is False
69+
70+
71+
class TestGetCommands:
72+
def test_get_commands_for_pyproject_toml(self, restore_pip: RestorePipDependencies) -> None:
73+
commands = restore_pip.get_commands('/path/to/pyproject.toml')
74+
assert commands == [['pip', 'lock', '.']]
75+
76+
def test_get_commands_for_requirements_txt(self, restore_pip: RestorePipDependencies) -> None:
77+
commands = restore_pip.get_commands('/path/to/requirements.txt')
78+
assert commands == [['pip', 'lock', '-r', 'requirements.txt', '-o', PIP_LOCK_FILE_NAME]]
79+
80+
def test_get_lock_file_name(self, restore_pip: RestorePipDependencies) -> None:
81+
assert restore_pip.get_lock_file_name() == PIP_LOCK_FILE_NAME
82+
83+
84+
class TestTryRestoreDependencies:
85+
def test_existing_pylock_returned_directly_for_pyproject_toml(
86+
self, restore_pip: RestorePipDependencies, tmp_path: Path
87+
) -> None:
88+
lock_content = 'lock-version = "1.0"\n\n[[packages]]\nname = "requests"\n'
89+
(tmp_path / 'pyproject.toml').write_text('[project]\nname = "test"\n')
90+
(tmp_path / PIP_LOCK_FILE_NAME).write_text(lock_content)
91+
92+
doc = Document(
93+
str(tmp_path / 'pyproject.toml'),
94+
'[project]\nname = "test"\n',
95+
absolute_path=str(tmp_path / 'pyproject.toml'),
96+
)
97+
result = restore_pip.try_restore_dependencies(doc)
98+
99+
assert result is not None
100+
assert PIP_LOCK_FILE_NAME in result.path
101+
assert result.content == lock_content
102+
103+
def test_existing_pylock_returned_directly_for_requirements_txt(
104+
self, restore_pip: RestorePipDependencies, tmp_path: Path
105+
) -> None:
106+
lock_content = 'lock-version = "1.0"\n\n[[packages]]\nname = "requests"\n'
107+
(tmp_path / 'requirements.txt').write_text('requests==2.31.0\n')
108+
(tmp_path / PIP_LOCK_FILE_NAME).write_text(lock_content)
109+
110+
doc = Document(
111+
str(tmp_path / 'requirements.txt'),
112+
'requests==2.31.0\n',
113+
absolute_path=str(tmp_path / 'requirements.txt'),
114+
)
115+
result = restore_pip.try_restore_dependencies(doc)
116+
117+
assert result is not None
118+
assert result.content == lock_content
119+
120+
121+
_BASE_MODULE = 'cycode.cli.files_collector.sca.base_restore_dependencies'
122+
123+
124+
class TestRestoreWithoutExistingLock:
125+
def test_pyproject_toml_runs_pip_lock_dot(self, restore_pip: RestorePipDependencies, tmp_path: Path) -> None:
126+
manifest_content = '[project]\nname = "test"\ndependencies = ["requests"]\n'
127+
(tmp_path / 'pyproject.toml').write_text(manifest_content)
128+
doc = Document(
129+
str(tmp_path / 'pyproject.toml'), manifest_content, absolute_path=str(tmp_path / 'pyproject.toml')
130+
)
131+
132+
seen_commands = []
133+
134+
def side_effect(
135+
commands: list,
136+
timeout: int,
137+
output_file_path: Optional[str] = None,
138+
working_directory: Optional[str] = None,
139+
) -> str:
140+
seen_commands.extend(commands)
141+
(tmp_path / PIP_LOCK_FILE_NAME).write_text('lock-version = "1.0"\n')
142+
return 'output'
143+
144+
with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect):
145+
result = restore_pip.try_restore_dependencies(doc)
146+
147+
assert result is not None
148+
assert seen_commands == [['pip', 'lock', '.']]
149+
150+
def test_requirements_txt_runs_pip_lock_dash_r(self, restore_pip: RestorePipDependencies, tmp_path: Path) -> None:
151+
(tmp_path / 'requirements.txt').write_text('requests==2.31.0\n')
152+
doc = Document(
153+
str(tmp_path / 'requirements.txt'),
154+
'requests==2.31.0\n',
155+
absolute_path=str(tmp_path / 'requirements.txt'),
156+
)
157+
158+
seen_commands = []
159+
160+
def side_effect(
161+
commands: list,
162+
timeout: int,
163+
output_file_path: Optional[str] = None,
164+
working_directory: Optional[str] = None,
165+
) -> str:
166+
seen_commands.extend(commands)
167+
(tmp_path / PIP_LOCK_FILE_NAME).write_text('lock-version = "1.0"\n')
168+
return 'output'
169+
170+
with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect):
171+
result = restore_pip.try_restore_dependencies(doc)
172+
173+
assert result is not None
174+
assert seen_commands == [['pip', 'lock', '-r', 'requirements.txt', '-o', PIP_LOCK_FILE_NAME]]
175+
176+
177+
class TestCleanup:
178+
def test_generated_lockfile_is_deleted_after_restore(
179+
self, restore_pip: RestorePipDependencies, tmp_path: Path
180+
) -> None:
181+
manifest_content = '[project]\nname = "test"\ndependencies = ["requests"]\n'
182+
(tmp_path / 'pyproject.toml').write_text(manifest_content)
183+
doc = Document(
184+
str(tmp_path / 'pyproject.toml'), manifest_content, absolute_path=str(tmp_path / 'pyproject.toml')
185+
)
186+
lock_path = tmp_path / PIP_LOCK_FILE_NAME
187+
188+
def side_effect(
189+
commands: list,
190+
timeout: int,
191+
output_file_path: Optional[str] = None,
192+
working_directory: Optional[str] = None,
193+
) -> str:
194+
lock_path.write_text('lock-version = "1.0"\n')
195+
return 'output'
196+
197+
with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect):
198+
result = restore_pip.try_restore_dependencies(doc)
199+
200+
assert result is not None
201+
assert not lock_path.exists(), f'{PIP_LOCK_FILE_NAME} must be deleted after restore'
202+
203+
def test_preexisting_lockfile_is_not_deleted(self, restore_pip: RestorePipDependencies, tmp_path: Path) -> None:
204+
lock_content = 'lock-version = "1.0"\n'
205+
(tmp_path / 'pyproject.toml').write_text('[project]\nname = "test"\n')
206+
lock_path = tmp_path / PIP_LOCK_FILE_NAME
207+
lock_path.write_text(lock_content)
208+
doc = Document(
209+
str(tmp_path / 'pyproject.toml'),
210+
'[project]\nname = "test"\n',
211+
absolute_path=str(tmp_path / 'pyproject.toml'),
212+
)
213+
214+
result = restore_pip.try_restore_dependencies(doc)
215+
216+
assert result is not None
217+
assert lock_path.exists(), f'Pre-existing {PIP_LOCK_FILE_NAME} must not be deleted'

0 commit comments

Comments
 (0)