Skip to content

Commit 58a8ac6

Browse files
authored
Merge branch 'main' into CM-68943-guardrails-report-violations
2 parents 04c89c7 + 9fdb9a4 commit 58a8ac6

7 files changed

Lines changed: 349 additions & 2 deletions

File tree

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# Cycode CLI User Guide
22

3+
[![MCP Toplist](https://mcptoplist.com/badge/glama%2Fcycodehq%2Fcycode-cli.svg)](https://mcptoplist.com/server/glama%2Fcycodehq%2Fcycode-cli)
4+
35
The Cycode Command Line Interface (CLI) is an application you can install locally to scan your repositories for secrets, infrastructure as code misconfigurations, software composition analysis vulnerabilities, and static application security testing issues.
46

57
This guide walks you through both installation and usage.
@@ -842,6 +844,7 @@ The following ecosystems support automatic lockfile restoration:
842844
| NuGet | `*.csproj` | `packages.lock.json` | `dotnet restore --use-lock-file` |
843845
| Ruby | `Gemfile` | `Gemfile.lock` | `bundle --quiet` |
844846
| Poetry | `pyproject.toml` | `poetry.lock` | `poetry lock` |
847+
| pip | `pyproject.toml` / `requirements.txt` | `pylock.toml` | `pip lock .` / `pip lock -r requirements.txt -o pylock.toml` |
845848
| Pipenv | `Pipfile` | `Pipfile.lock` | `pipenv lock` |
846849
| PHP Composer | `composer.json` | `composer.lock` | `composer update --no-cache --no-install --no-scripts --ignore-platform-reqs` |
847850

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'],

cycode/cli/files_collector/sca/base_restore_dependencies.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ def execute_commands(
4040

4141
for command in commands:
4242
command_output = shell(command=command, timeout=timeout, working_directory=working_directory)
43+
if command_output is None: # shell returns None when the command exited non-zero
44+
logger.debug('Restore command failed, %s', {'command': command})
45+
return None
4346
if command_output:
4447
outputs.append(command_output)
4548

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 (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)