Skip to content

Commit 2102dab

Browse files
ruromeroclaude
andauthored
refactor: use pip --dry-run --report for pyproject.toml (#406)
## Summary - Replace the venv + pip install/freeze/show chain with a single `pip install --dry-run --ignore-installed --report` command for pyproject.toml dependency resolution - Parse pip report JSON to build the full dependency tree directly, eliminating temporary file creation and virtual environment setup - Add comprehensive tests for pip report parsing, extras filtering, name canonicalization, and SBOM generation ## Jira [TC-4087](https://redhat.atlassian.net/browse/TC-4087) ## Test plan - [x] All existing pyproject tests pass (26 tests) - [x] New pip report parsing tests pass (7 tests: direct deps, transitive graph, extras filtering, root exclusion, name canonicalization, helper methods) - [x] SBOM generation integration tests (3 tests: provideStack, provideComponent, exhortignore — skipped in local env due to pre-existing CycloneDX classpath issue) - [x] CI pipeline passes 🤖 Generated with [Claude Code](https://claude.com/claude-code) [TC-4087]: https://redhat.atlassian.net/browse/TC-4087?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ --------- Signed-off-by: Ruben Romero Montes <rromerom@redhat.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 5023985 commit 2102dab

7 files changed

Lines changed: 962 additions & 408 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ public class TrustifyExample {
182182
<li><a href="https://www.java.com/">Java</a> - <a href="https://maven.apache.org/">Maven</a></li>
183183
<li><a href="https://www.javascript.com//">JavaScript</a> - <a href="https://www.npmjs.com//">Npm</a></li>
184184
<li><a href="https://go.dev//">Golang</a> - <a href="https://go.dev/blog/using-go-modules//">Go Modules</a></li>
185-
<li><a href="https://www.python.org/">Python</a> - <a href="https://pypi.org/project/pip/">pip Installer</a> (<code>requirements.txt</code>, <code>pyproject.toml</code>)</li>
185+
<li><a href="https://www.python.org/">Python</a> - <a href="https://pypi.org/project/pip/">pip Installer</a> (<code>requirements.txt</code>, <code>pyproject.toml</code> with PEP 621 format). <strong>Note:</strong> Poetry-style dependencies (<code>[tool.poetry.dependencies]</code>) are not supported.</li>
186186
<li><a href="https://gradle.org//">Gradle</a> - <a href="https://gradle.org/install//">Gradle Installation</a></li>
187187
<li><a href="https://www.rust-lang.org/">Rust</a> - <a href="https://doc.rust-lang.org/cargo/">Cargo</a></li>
188188

src/main/java/io/github/guacsec/trustifyda/providers/PythonPipProvider.java

Lines changed: 157 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,27 +16,79 @@
1616
*/
1717
package io.github.guacsec.trustifyda.providers;
1818

19+
import static io.github.guacsec.trustifyda.impl.ExhortApi.debugLoggingIsNeeded;
20+
21+
import com.fasterxml.jackson.core.JsonProcessingException;
1922
import com.github.packageurl.PackageURL;
23+
import io.github.guacsec.trustifyda.Api;
24+
import io.github.guacsec.trustifyda.logging.LoggersFactory;
25+
import io.github.guacsec.trustifyda.sbom.Sbom;
26+
import io.github.guacsec.trustifyda.sbom.SbomFactory;
27+
import io.github.guacsec.trustifyda.tools.Operations;
28+
import io.github.guacsec.trustifyda.utils.Environment;
2029
import io.github.guacsec.trustifyda.utils.PythonControllerBase;
30+
import io.github.guacsec.trustifyda.utils.PythonControllerRealEnv;
31+
import io.github.guacsec.trustifyda.utils.PythonControllerVirtualEnv;
32+
import java.io.IOException;
33+
import java.nio.charset.StandardCharsets;
34+
import java.nio.file.Files;
2135
import java.nio.file.Path;
2236
import java.util.Arrays;
37+
import java.util.List;
38+
import java.util.Map;
2339
import java.util.Set;
40+
import java.util.logging.Logger;
2441
import java.util.stream.Collectors;
2542

2643
public final class PythonPipProvider extends PythonProvider {
2744

45+
private static final Logger log = LoggersFactory.getLogger(PythonPipProvider.class.getName());
46+
47+
private PythonControllerBase pythonController;
48+
2849
public PythonPipProvider(Path manifest) {
2950
super(manifest);
3051
}
3152

53+
public void setPythonController(PythonControllerBase pythonController) {
54+
this.pythonController = pythonController;
55+
}
56+
3257
@Override
33-
protected Path getRequirementsPath() {
34-
return manifest;
58+
public Content provideStack() throws IOException {
59+
PythonControllerBase controller = getPythonController();
60+
List<Map<String, Object>> dependencies = controller.getDependencies(manifest.toString(), true);
61+
printDependenciesTree(dependencies);
62+
Sbom sbom = SbomFactory.newInstance(Sbom.BelongingCondition.PURL, "sensitive");
63+
sbom.addRoot(
64+
toPurl(getRootComponentName(), getRootComponentVersion()), readLicenseFromManifest());
65+
for (Map<String, Object> component : dependencies) {
66+
addAllDependencies(sbom.getRoot(), component, sbom);
67+
}
68+
String manifestContent = Files.readString(manifest);
69+
handleIgnoredDependencies(manifestContent, sbom);
70+
return new Content(
71+
sbom.getAsJsonString().getBytes(StandardCharsets.UTF_8), Api.CYCLONEDX_MEDIA_TYPE);
3572
}
3673

3774
@Override
38-
protected void cleanupRequirementsPath(Path requirementsPath) {
39-
// No cleanup needed — the manifest is the requirements file itself.
75+
public Content provideComponent() throws IOException {
76+
PythonControllerBase controller = getPythonController();
77+
List<Map<String, Object>> dependencies = controller.getDependencies(manifest.toString(), false);
78+
printDependenciesTree(dependencies);
79+
Sbom sbom = SbomFactory.newInstance();
80+
sbom.addRoot(
81+
toPurl(getRootComponentName(), getRootComponentVersion()), readLicenseFromManifest());
82+
dependencies.forEach(
83+
(component) ->
84+
sbom.addDependency(
85+
sbom.getRoot(),
86+
toPurl((String) component.get("name"), (String) component.get("version")),
87+
null));
88+
String manifestContent = Files.readString(manifest);
89+
handleIgnoredDependencies(manifestContent, sbom);
90+
return new Content(
91+
sbom.getAsJsonString().getBytes(StandardCharsets.UTF_8), Api.CYCLONEDX_MEDIA_TYPE);
4092
}
4193

4294
@Override
@@ -50,6 +102,107 @@ protected Set<PackageURL> getIgnoredDependencies(String manifestContent) {
50102
.collect(Collectors.toSet());
51103
}
52104

105+
@SuppressWarnings("unchecked")
106+
private void addAllDependencies(PackageURL source, Map<String, Object> component, Sbom sbom) {
107+
PackageURL packageURL =
108+
toPurl((String) component.get("name"), (String) component.get("version"));
109+
sbom.addDependency(source, packageURL, null);
110+
111+
List<Map<String, Object>> directDeps =
112+
(List<Map<String, Object>>) component.get("dependencies");
113+
if (directDeps != null) {
114+
for (Map<String, Object> dep : directDeps) {
115+
addAllDependencies(packageURL, dep, sbom);
116+
}
117+
}
118+
}
119+
120+
private void printDependenciesTree(List<Map<String, Object>> dependencies)
121+
throws JsonProcessingException {
122+
if (debugLoggingIsNeeded()) {
123+
String pythonControllerTree =
124+
objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(dependencies);
125+
log.info(
126+
String.format(
127+
"Python Generated Dependency Tree in Json Format: %s %s %s",
128+
System.lineSeparator(), pythonControllerTree, System.lineSeparator()));
129+
}
130+
}
131+
132+
private PythonControllerBase getPythonController() {
133+
String pythonPipBinaries;
134+
boolean useVirtualPythonEnv;
135+
if (!Environment.get(PythonControllerBase.PROP_TRUSTIFY_DA_PIP_SHOW, "").trim().isEmpty()
136+
&& !Environment.get(PythonControllerBase.PROP_TRUSTIFY_DA_PIP_FREEZE, "")
137+
.trim()
138+
.isEmpty()) {
139+
pythonPipBinaries = "python;;pip";
140+
useVirtualPythonEnv = false;
141+
} else {
142+
pythonPipBinaries = getExecutable("python", "--version");
143+
useVirtualPythonEnv =
144+
Environment.getBoolean(PythonControllerBase.PROP_TRUSTIFY_DA_PYTHON_VIRTUAL_ENV, false);
145+
}
146+
147+
String[] parts = pythonPipBinaries.split(";;");
148+
var python = parts[0];
149+
var pip = parts[1];
150+
PythonControllerBase controller;
151+
if (this.pythonController == null) {
152+
if (useVirtualPythonEnv) {
153+
controller = new PythonControllerVirtualEnv(python);
154+
} else {
155+
controller = new PythonControllerRealEnv(python, pip);
156+
}
157+
} else {
158+
controller = this.pythonController;
159+
}
160+
return controller;
161+
}
162+
163+
private String getExecutable(String command, String args) {
164+
String python = Operations.getCustomPathOrElse("python3");
165+
String pip = Operations.getCustomPathOrElse("pip3");
166+
try {
167+
Operations.runProcess(python, args);
168+
Operations.runProcess(pip, args);
169+
} catch (Exception e) {
170+
python = Operations.getCustomPathOrElse("python");
171+
pip = Operations.getCustomPathOrElse("pip");
172+
try {
173+
Process process = new ProcessBuilder(command, args).redirectErrorStream(true).start();
174+
int exitCode = process.waitFor();
175+
if (exitCode != 0) {
176+
throw new IOException(
177+
"Python executable found, but it exited with error code " + exitCode);
178+
}
179+
} catch (IOException | InterruptedException ex) {
180+
throw new RuntimeException(
181+
String.format(
182+
"Unable to find or run Python executable '%s'. Please ensure Python is installed"
183+
+ " and available in your PATH.",
184+
command),
185+
ex);
186+
}
187+
188+
try {
189+
Process process = new ProcessBuilder("pip", args).redirectErrorStream(true).start();
190+
int exitCode = process.waitFor();
191+
if (exitCode != 0) {
192+
throw new IOException("Pip executable found, but it exited with error code " + exitCode);
193+
}
194+
} catch (IOException | InterruptedException ex) {
195+
throw new RuntimeException(
196+
String.format(
197+
"Unable to find or run Pip executable '%s'. Please ensure Pip is installed and"
198+
+ " available in your PATH.",
199+
command),
200+
ex);
201+
}
202+
}
203+
return String.format("%s;;%s", python, pip);
204+
}
205+
53206
private static String extractDepFull(String requirementLine) {
54207
return requirementLine.substring(0, requirementLine.indexOf("#")).trim();
55208
}

0 commit comments

Comments
 (0)