Skip to content

Commit fc50b82

Browse files
committed
refactor(python): extract shared logic into PythonProvider base class
Move duplicated Python infrastructure (~150 lines) from PythonPipProvider and PythonPyprojectProvider into an abstract PythonProvider base class: - Controller resolution (getPythonController, getExecutable) - SBOM construction (provideStack, provideComponent, addAllDependencies) - Ignore pattern handling (handleIgnoredDependencies, containsIgnorePattern) - PURL creation (toPurl) and debug logging (printDependenciesTree) Subclasses now only implement manifest-specific logic: - PythonPipProvider: requirements.txt line parsing - PythonPyprojectProvider: TOML parsing and temp file generation Implements TC-3851 Assisted-by: Claude Code
1 parent ecfa5ce commit fc50b82

3 files changed

Lines changed: 317 additions & 496 deletions

File tree

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

Lines changed: 17 additions & 248 deletions
Original file line numberDiff line numberDiff line change
@@ -16,186 +16,42 @@
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;
22-
import com.github.packageurl.MalformedPackageURLException;
2319
import com.github.packageurl.PackageURL;
24-
import io.github.guacsec.trustifyda.Api;
25-
import io.github.guacsec.trustifyda.Provider;
26-
import io.github.guacsec.trustifyda.logging.LoggersFactory;
27-
import io.github.guacsec.trustifyda.sbom.Sbom;
28-
import io.github.guacsec.trustifyda.sbom.SbomFactory;
29-
import io.github.guacsec.trustifyda.tools.Ecosystem;
30-
import io.github.guacsec.trustifyda.tools.Operations;
31-
import io.github.guacsec.trustifyda.utils.Environment;
32-
import io.github.guacsec.trustifyda.utils.IgnorePatternDetector;
3320
import io.github.guacsec.trustifyda.utils.PythonControllerBase;
34-
import io.github.guacsec.trustifyda.utils.PythonControllerRealEnv;
35-
import io.github.guacsec.trustifyda.utils.PythonControllerVirtualEnv;
36-
import java.io.IOException;
37-
import java.nio.charset.StandardCharsets;
38-
import java.nio.file.Files;
3921
import java.nio.file.Path;
4022
import java.util.Arrays;
41-
import java.util.List;
42-
import java.util.Map;
4323
import java.util.Set;
44-
import java.util.logging.Logger;
4524
import java.util.stream.Collectors;
4625

47-
public final class PythonPipProvider extends Provider {
48-
49-
private static final Logger log = LoggersFactory.getLogger(PythonPipProvider.class.getName());
50-
private static final String DEFAULT_PIP_ROOT_COMPONENT_NAME = "default-pip-root";
51-
private static final String DEFAULT_PIP_ROOT_COMPONENT_VERSION = "0.0.0";
52-
53-
public void setPythonController(PythonControllerBase pythonController) {
54-
this.pythonController = pythonController;
55-
}
56-
57-
private PythonControllerBase pythonController;
26+
public final class PythonPipProvider extends PythonProvider {
5827

5928
public PythonPipProvider(Path manifest) {
60-
super(Ecosystem.Type.PYTHON, manifest);
29+
super(manifest);
6130
}
6231

6332
@Override
64-
public Content provideStack() throws IOException {
65-
PythonControllerBase pythonController = getPythonController();
66-
List<Map<String, Object>> dependencies =
67-
pythonController.getDependencies(manifest.toString(), true);
68-
printDependenciesTree(dependencies);
69-
Sbom sbom = SbomFactory.newInstance(Sbom.BelongingCondition.PURL, "sensitive");
70-
sbom.addRoot(toPurl(DEFAULT_PIP_ROOT_COMPONENT_NAME, DEFAULT_PIP_ROOT_COMPONENT_VERSION));
71-
for (Map<String, Object> component : dependencies) {
72-
addAllDependencies(sbom.getRoot(), component, sbom);
73-
}
74-
byte[] requirementsFile = Files.readAllBytes(manifest);
75-
handleIgnoredDependencies(new String(requirementsFile), sbom);
76-
return new Content(
77-
sbom.getAsJsonString().getBytes(StandardCharsets.UTF_8), Api.CYCLONEDX_MEDIA_TYPE);
78-
}
79-
80-
private void addAllDependencies(PackageURL source, Map<String, Object> component, Sbom sbom) {
81-
82-
PackageURL packageURL =
83-
toPurl((String) component.get("name"), (String) component.get("version"));
84-
sbom.addDependency(source, packageURL, null);
85-
86-
List<Map<String, Object>> directDeps =
87-
(List<Map<String, Object>>) component.get("dependencies");
88-
if (directDeps != null) {
89-
for (Map<String, Object> dep : directDeps) {
90-
addAllDependencies(packageURL, dep, sbom);
91-
}
92-
}
33+
protected Path getRequirementsPath() {
34+
return manifest;
9335
}
9436

9537
@Override
96-
public Content provideComponent() throws IOException {
97-
PythonControllerBase pythonController = getPythonController();
98-
List<Map<String, Object>> dependencies =
99-
pythonController.getDependencies(manifest.toString(), false);
100-
printDependenciesTree(dependencies);
101-
Sbom sbom = SbomFactory.newInstance();
102-
sbom.addRoot(toPurl(DEFAULT_PIP_ROOT_COMPONENT_NAME, DEFAULT_PIP_ROOT_COMPONENT_VERSION));
103-
dependencies.forEach(
104-
(component) ->
105-
sbom.addDependency(
106-
sbom.getRoot(),
107-
toPurl((String) component.get("name"), (String) component.get("version")),
108-
null));
109-
110-
var manifestContent = Files.readString(manifest);
111-
handleIgnoredDependencies(manifestContent, sbom);
112-
return new Content(
113-
sbom.getAsJsonString().getBytes(StandardCharsets.UTF_8), Api.CYCLONEDX_MEDIA_TYPE);
114-
}
115-
116-
private void printDependenciesTree(List<Map<String, Object>> dependencies)
117-
throws JsonProcessingException {
118-
if (debugLoggingIsNeeded()) {
119-
String pythonControllerTree =
120-
objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(dependencies);
121-
log.info(
122-
String.format(
123-
"Python Generated Dependency Tree in Json Format: %s %s %s",
124-
System.lineSeparator(), pythonControllerTree, System.lineSeparator()));
125-
}
38+
protected void cleanupRequirementsPath(Path requirementsPath) {
39+
// No cleanup needed — the manifest is the requirements file itself.
12640
}
12741

128-
private void handleIgnoredDependencies(String manifestContent, Sbom sbom) {
129-
Set<PackageURL> ignoredDeps = getIgnoredDependencies(manifestContent);
130-
Set<String> ignoredDepsVersions =
131-
ignoredDeps.stream()
132-
.filter(dep -> !dep.getVersion().trim().equals("*"))
133-
.map(PackageURL::getCoordinates)
134-
.collect(Collectors.toSet());
135-
Set<String> ignoredDepsNoVersions =
136-
ignoredDeps.stream()
137-
.filter(dep -> dep.getVersion().trim().equals("*"))
138-
.map(PackageURL::getCoordinates)
139-
.collect(Collectors.toSet());
140-
141-
// filter out by name only from sbom all exhortignore dependencies that their version will be
142-
// resolved by pip.
143-
sbom.setBelongingCriteriaBinaryAlgorithm(Sbom.BelongingCondition.NAME);
144-
sbom.filterIgnoredDeps(ignoredDepsNoVersions);
145-
boolean matchManifestVersions = Environment.getBoolean(PROP_MATCH_MANIFEST_VERSIONS, true);
146-
// filter out by purl from sbom all exhortignore dependencies that their version hardcoded in
147-
// requirements.txt -
148-
// in case all versions in manifest matching installed versions of packages in environment.
149-
if (matchManifestVersions) {
150-
sbom.setBelongingCriteriaBinaryAlgorithm(Sbom.BelongingCondition.PURL);
151-
sbom.filterIgnoredDeps(ignoredDepsVersions);
152-
} else {
153-
// in case version mismatch is possible (MATCH_MANIFEST_VERSIONS=false) , need to parse the
154-
// name of package
155-
// from the purl, and remove the package name from sbom according to name only
156-
Set<String> deps =
157-
ignoredDepsVersions.stream()
158-
.map(
159-
purlString -> {
160-
try {
161-
return new PackageURL(purlString).getName();
162-
} catch (MalformedPackageURLException e) {
163-
throw new RuntimeException(e);
164-
}
165-
})
166-
.collect(Collectors.toSet());
167-
sbom.setBelongingCriteriaBinaryAlgorithm(Sbom.BelongingCondition.NAME);
168-
sbom.filterIgnoredDeps(deps);
169-
}
170-
}
171-
172-
/**
173-
* Checks if a text line contains a Python pip ignore pattern. Handles both '#exhortignore' and
174-
* '#trustify-da-ignore' with optional spacing.
175-
*
176-
* @param line the line to check
177-
* @return true if the line contains a Python pip ignore pattern
178-
*/
179-
private boolean containsPythonIgnorePattern(String line) {
180-
return line.contains("#" + IgnorePatternDetector.IGNORE_PATTERN)
181-
|| line.contains("# " + IgnorePatternDetector.IGNORE_PATTERN)
182-
|| line.contains("#" + IgnorePatternDetector.LEGACY_IGNORE_PATTERN)
183-
|| line.contains("# " + IgnorePatternDetector.LEGACY_IGNORE_PATTERN);
42+
@Override
43+
protected Set<PackageURL> getIgnoredDependencies(String manifestContent) {
44+
String[] lines = manifestContent.split(System.lineSeparator());
45+
return Arrays.stream(lines)
46+
.filter(this::containsIgnorePattern)
47+
.map(PythonPipProvider::extractDepFull)
48+
.map(this::splitToNameVersion)
49+
.map(dep -> toPurl(dep[0], dep[1]))
50+
.collect(Collectors.toSet());
18451
}
18552

186-
private Set<PackageURL> getIgnoredDependencies(String requirementsDeps) {
187-
188-
String[] requirementsLines = requirementsDeps.split(System.lineSeparator());
189-
Set<PackageURL> collected =
190-
Arrays.stream(requirementsLines)
191-
.filter(this::containsPythonIgnorePattern)
192-
.map(PythonPipProvider::extractDepFull)
193-
.map(this::splitToNameVersion)
194-
.map(dep -> toPurl(dep[0], dep[1]))
195-
// .map(packageURL -> packageURL.getCoordinates())
196-
.collect(Collectors.toSet());
197-
198-
return collected;
53+
private static String extractDepFull(String requirementLine) {
54+
return requirementLine.substring(0, requirementLine.indexOf("#")).trim();
19955
}
20056

20157
private String[] splitToNameVersion(String nameVersion) {
@@ -209,91 +65,4 @@ private String[] splitToNameVersion(String nameVersion) {
20965
}
21066
return result;
21167
}
212-
213-
private static String extractDepFull(String requirementLine) {
214-
return requirementLine.substring(0, requirementLine.indexOf("#")).trim();
215-
}
216-
217-
private PackageURL toPurl(String name, String version) {
218-
219-
try {
220-
return new PackageURL(Ecosystem.Type.PYTHON.getType(), null, name, version, null, null);
221-
} catch (MalformedPackageURLException e) {
222-
throw new RuntimeException(e);
223-
}
224-
}
225-
226-
private PythonControllerBase getPythonController() {
227-
String pythonPipBinaries;
228-
boolean useVirtualPythonEnv;
229-
if (!Environment.get(PythonControllerBase.PROP_TRUSTIFY_DA_PIP_SHOW, "").trim().isEmpty()
230-
&& !Environment.get(PythonControllerBase.PROP_TRUSTIFY_DA_PIP_FREEZE, "")
231-
.trim()
232-
.isEmpty()) {
233-
pythonPipBinaries = "python;;pip";
234-
useVirtualPythonEnv = false;
235-
} else {
236-
pythonPipBinaries = getExecutable("python", "--version");
237-
useVirtualPythonEnv =
238-
Environment.getBoolean(PythonControllerBase.PROP_TRUSTIFY_DA_PYTHON_VIRTUAL_ENV, false);
239-
}
240-
241-
String[] parts = pythonPipBinaries.split(";;");
242-
var python = parts[0];
243-
var pip = parts[1];
244-
PythonControllerBase pythonController;
245-
if (this.pythonController == null) {
246-
if (useVirtualPythonEnv) {
247-
pythonController = new PythonControllerVirtualEnv(python);
248-
} else {
249-
pythonController = new PythonControllerRealEnv(python, pip);
250-
}
251-
} else {
252-
pythonController = this.pythonController;
253-
}
254-
return pythonController;
255-
}
256-
257-
private String getExecutable(String command, String args) {
258-
String python = Operations.getCustomPathOrElse("python3");
259-
String pip = Operations.getCustomPathOrElse("pip3");
260-
try {
261-
Operations.runProcess(python, args);
262-
Operations.runProcess(pip, args);
263-
} catch (Exception e) {
264-
python = Operations.getCustomPathOrElse("python");
265-
pip = Operations.getCustomPathOrElse("pip");
266-
try {
267-
Process process = new ProcessBuilder(command, args).redirectErrorStream(true).start();
268-
int exitCode = process.waitFor();
269-
if (exitCode != 0) {
270-
throw new IOException(
271-
"Python executable found, but it exited with error code " + exitCode);
272-
}
273-
} catch (IOException | InterruptedException ex) {
274-
throw new RuntimeException(
275-
String.format(
276-
"Unable to find or run Python executable '%s'. Please ensure Python is installed"
277-
+ " and available in your PATH.",
278-
command),
279-
ex);
280-
}
281-
282-
try {
283-
Process process = new ProcessBuilder("pip", args).redirectErrorStream(true).start();
284-
int exitCode = process.waitFor();
285-
if (exitCode != 0) {
286-
throw new IOException("Pip executable found, but it exited with error code " + exitCode);
287-
}
288-
} catch (IOException | InterruptedException ex) {
289-
throw new RuntimeException(
290-
String.format(
291-
"Unable to find or run Pip executable '%s'. Please ensure Pip is installed and"
292-
+ " available in your PATH.",
293-
command),
294-
ex);
295-
}
296-
}
297-
return String.format("%s;;%s", python, pip);
298-
}
29968
}

0 commit comments

Comments
 (0)