Skip to content

Commit 4589536

Browse files
ruromeroclaude
andcommitted
feat(python): add pyproject.toml provider support
Add support for pyproject.toml manifest files in the Java client library, enabling analysis of Python projects using Poetry, uv, or PEP 621-compliant tools. - New PythonPyprojectProvider that parses TOML dependency sections and generates temporary requirements.txt for dependency tree resolution - Refactor shared Python infrastructure into PythonProvider base class - Support PEP 621 [project.dependencies] and Poetry [tool.poetry.dependencies] (production deps only) - Convert Poetry version operators (^ and ~) to PEP 440 ranges - Handle bare versions, pre-release suffixes, and ignore patterns - Add license resolution and identification support - Update README with pyproject.toml documentation - Comprehensive unit tests for parsing, conversion, and edge cases Jira-Issue: TC-3851 Assisted-by: Claude Code Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 1b2b88a commit 4589536

9 files changed

Lines changed: 756 additions & 263 deletions

File tree

README.md

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ public class TrustifyExample {
177177
<li><a href="https://www.java.com/">Java</a> - <a href="https://maven.apache.org/">Maven</a></li>
178178
<li><a href="https://www.javascript.com//">JavaScript</a> - <a href="https://www.npmjs.com//">Npm</a></li>
179179
<li><a href="https://go.dev//">Golang</a> - <a href="https://go.dev/blog/using-go-modules//">Go Modules</a></li>
180-
<li><a href="https://go.dev//">Python</a> - <a href="https://pypi.org/project/pip//">pip Installer</a></li>
180+
<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>
181181
<li><a href="https://gradle.org//">Gradle</a> - <a href="https://gradle.org/install//">Gradle Installation</a></li>
182182
<li><a href="https://www.rust-lang.org/">Rust</a> - <a href="https://doc.rust-lang.org/cargo/">Cargo</a></li>
183183

@@ -264,7 +264,7 @@ require (
264264
</li>
265265

266266
<li>
267-
<em>Python pip</em> users can add in requirement text a comment with #trustify-da-ignore(or # trustify-da-ignore) to the right of the same artifact to be ignored, for example:
267+
<em>Python pip</em> users can add in <code>requirements.txt</code> a comment with #trustify-da-ignore(or # trustify-da-ignore) to the right of the same artifact to be ignored, for example:
268268

269269
```properties
270270
anyio==3.6.2
@@ -297,6 +297,20 @@ zipp==3.6.0
297297
```
298298
</li>
299299

300+
<li>
301+
<em>Python pyproject.toml</em> users can add a comment with #trustify-da-ignore next to a dependency in <code>pyproject.toml</code>:
302+
303+
```toml
304+
[project]
305+
name = "my-project"
306+
dependencies = [
307+
"requests>=2.28.1",
308+
"flask>=2.0", # trustify-da-ignore
309+
"click>=8.0",
310+
]
311+
```
312+
</li>
313+
300314
<li>
301315
<em>Gradle</em> users can add in build.gradle a comment with //trustify-da-ignore next to the package to be ignored:
302316
```build.gradle
@@ -538,7 +552,7 @@ System.setProperty("TRUSTIFY_DA_MVN_LOCAL_REPO", "/home/user/custom-maven-repo")
538552
##### Background
539553

540554
In Python pip and in golang go modules package managers ( especially in Python pip) , There is a big chance that for a certain manifest and a given package inside it, the client machine environment has different version installed/resolved
541-
for that package, which can lead to perform the analysis on the installed packages' versions , instead on the declared versions ( in manifests - that is requirements.txt/go.mod ), and this
555+
for that package, which can lead to perform the analysis on the installed packages' versions , instead on the declared versions ( in manifests - that is requirements.txt/pyproject.toml/go.mod ), and this
542556
can cause a confusion for the user in the client consuming the API and leads to inconsistent output ( in THE manifest there is version X For a given Package `A` , and in the analysis report there is another version for the same package `A` - Y).
543557

544558
##### Usage
@@ -579,16 +593,18 @@ TRUSTIFY_DA_GO_MVS_LOGIC_ENABLED=false
579593

580594
#### Python Support
581595

596+
Python support works with both `requirements.txt` and `pyproject.toml` manifest files. The `pyproject.toml` provider scans production dependencies from PEP 621 `[project.dependencies]` and Poetry `[tool.poetry.dependencies]` sections. Optional dependencies (`[project.optional-dependencies]`) and Poetry group dependencies (`[tool.poetry.group.*.dependencies]`) are intentionally excluded to focus on runtime dependencies.
597+
582598
By default, Python support assumes that the package is installed using the pip/pip3 binary on the system PATH, or of the customized
583599
Binaries passed to environment variables. If the package is not installed , then an error will be thrown.
584600

585-
There is an experimental feature of installing the requirement.txt on a virtual env(only python3 or later is supported for this feature) - in this case,
601+
There is an experimental feature of installing the dependencies on a virtual env(only python3 or later is supported for this feature) - in this case,
586602
it's important to pass in a path to python3 binary as `TRUSTIFY_DA_PYTHON3_PATH` or instead make sure that python3 is on the system path.
587-
in such case, You can use that feature by setting environment variable `TRUSTIFY_DA_PYTHON_VIRTUAL_ENV` to true
603+
in such case, You can use that feature by setting environment variable `TRUSTIFY_DA_PYTHON_VIRTUAL_ENV` to true
588604

589605
##### "Best Efforts Installation"
590606
Since Python pip packages are very sensitive/picky regarding python version changes( every small range of versions is only tailored for a certain python version), I'm introducing this feature, that
591-
tries to install all packages in requirements.txt onto created virtual environment while **disregarding** versions declared for packages in requirements.txt
607+
tries to install all packages in the manifest onto created virtual environment while **disregarding** versions declared for packages
592608
This increasing the chances and the probability a lot that the automatic installation will succeed.
593609

594610
##### Usage
@@ -693,6 +709,9 @@ java -jar trustify-da-java-client-cli.jar stack /path/to/build.gradle --html
693709
# Component analysis with JSON output (default)
694710
java -jar trustify-da-java-client-cli.jar component /path/to/requirements.txt
695711

712+
# Component analysis for pyproject.toml
713+
java -jar trustify-da-java-client-cli.jar component /path/to/pyproject.toml
714+
696715
# Component analysis with summary
697716
java -jar trustify-da-java-client-cli.jar component /path/to/go.mod --summary
698717

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

Lines changed: 16 additions & 257 deletions
Original file line numberDiff line numberDiff line change
@@ -16,196 +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.license.LicenseUtils;
27-
import io.github.guacsec.trustifyda.logging.LoggersFactory;
28-
import io.github.guacsec.trustifyda.sbom.Sbom;
29-
import io.github.guacsec.trustifyda.sbom.SbomFactory;
30-
import io.github.guacsec.trustifyda.tools.Ecosystem;
31-
import io.github.guacsec.trustifyda.tools.Operations;
32-
import io.github.guacsec.trustifyda.utils.Environment;
33-
import io.github.guacsec.trustifyda.utils.IgnorePatternDetector;
3420
import io.github.guacsec.trustifyda.utils.PythonControllerBase;
35-
import io.github.guacsec.trustifyda.utils.PythonControllerRealEnv;
36-
import io.github.guacsec.trustifyda.utils.PythonControllerVirtualEnv;
37-
import java.io.IOException;
38-
import java.nio.charset.StandardCharsets;
39-
import java.nio.file.Files;
4021
import java.nio.file.Path;
4122
import java.util.Arrays;
42-
import java.util.List;
43-
import java.util.Map;
4423
import java.util.Set;
45-
import java.util.logging.Logger;
4624
import java.util.stream.Collectors;
4725

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

6028
public PythonPipProvider(Path manifest) {
61-
super(Ecosystem.Type.PYTHON, manifest);
29+
super(manifest);
6230
}
6331

6432
@Override
65-
public String readLicenseFromManifest() {
66-
return LicenseUtils.readLicenseFile(manifest);
33+
protected Path getRequirementsPath() {
34+
return manifest;
6735
}
6836

6937
@Override
70-
public Content provideStack() throws IOException {
71-
PythonControllerBase pythonController = getPythonController();
72-
List<Map<String, Object>> dependencies =
73-
pythonController.getDependencies(manifest.toString(), true);
74-
printDependenciesTree(dependencies);
75-
Sbom sbom = SbomFactory.newInstance(Sbom.BelongingCondition.PURL, "sensitive");
76-
sbom.addRoot(
77-
toPurl(DEFAULT_PIP_ROOT_COMPONENT_NAME, DEFAULT_PIP_ROOT_COMPONENT_VERSION),
78-
readLicenseFromManifest());
79-
for (Map<String, Object> component : dependencies) {
80-
addAllDependencies(sbom.getRoot(), component, sbom);
81-
}
82-
byte[] requirementsFile = Files.readAllBytes(manifest);
83-
handleIgnoredDependencies(new String(requirementsFile), sbom);
84-
return new Content(
85-
sbom.getAsJsonString().getBytes(StandardCharsets.UTF_8), Api.CYCLONEDX_MEDIA_TYPE);
86-
}
87-
88-
private void addAllDependencies(PackageURL source, Map<String, Object> component, Sbom sbom) {
89-
90-
PackageURL packageURL =
91-
toPurl((String) component.get("name"), (String) component.get("version"));
92-
sbom.addDependency(source, packageURL, null);
93-
94-
List<Map<String, Object>> directDeps =
95-
(List<Map<String, Object>>) component.get("dependencies");
96-
if (directDeps != null) {
97-
for (Map<String, Object> dep : directDeps) {
98-
addAllDependencies(packageURL, dep, sbom);
99-
}
100-
}
38+
protected void cleanupRequirementsPath(Path requirementsPath) {
39+
// No cleanup needed — the manifest is the requirements file itself.
10140
}
10241

10342
@Override
104-
public Content provideComponent() throws IOException {
105-
PythonControllerBase pythonController = getPythonController();
106-
List<Map<String, Object>> dependencies =
107-
pythonController.getDependencies(manifest.toString(), false);
108-
printDependenciesTree(dependencies);
109-
Sbom sbom = SbomFactory.newInstance();
110-
sbom.addRoot(
111-
toPurl(DEFAULT_PIP_ROOT_COMPONENT_NAME, DEFAULT_PIP_ROOT_COMPONENT_VERSION),
112-
readLicenseFromManifest());
113-
dependencies.forEach(
114-
(component) ->
115-
sbom.addDependency(
116-
sbom.getRoot(),
117-
toPurl((String) component.get("name"), (String) component.get("version")),
118-
null));
119-
120-
var manifestContent = Files.readString(manifest);
121-
handleIgnoredDependencies(manifestContent, sbom);
122-
return new Content(
123-
sbom.getAsJsonString().getBytes(StandardCharsets.UTF_8), Api.CYCLONEDX_MEDIA_TYPE);
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());
12451
}
12552

126-
private void printDependenciesTree(List<Map<String, Object>> dependencies)
127-
throws JsonProcessingException {
128-
if (debugLoggingIsNeeded()) {
129-
String pythonControllerTree =
130-
objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(dependencies);
131-
log.info(
132-
String.format(
133-
"Python Generated Dependency Tree in Json Format: %s %s %s",
134-
System.lineSeparator(), pythonControllerTree, System.lineSeparator()));
135-
}
136-
}
137-
138-
private void handleIgnoredDependencies(String manifestContent, Sbom sbom) {
139-
Set<PackageURL> ignoredDeps = getIgnoredDependencies(manifestContent);
140-
Set<String> ignoredDepsVersions =
141-
ignoredDeps.stream()
142-
.filter(dep -> !dep.getVersion().trim().equals("*"))
143-
.map(PackageURL::getCoordinates)
144-
.collect(Collectors.toSet());
145-
Set<String> ignoredDepsNoVersions =
146-
ignoredDeps.stream()
147-
.filter(dep -> dep.getVersion().trim().equals("*"))
148-
.map(PackageURL::getCoordinates)
149-
.collect(Collectors.toSet());
150-
151-
// filter out by name only from sbom all exhortignore dependencies that their version will be
152-
// resolved by pip.
153-
sbom.setBelongingCriteriaBinaryAlgorithm(Sbom.BelongingCondition.NAME);
154-
sbom.filterIgnoredDeps(ignoredDepsNoVersions);
155-
boolean matchManifestVersions = Environment.getBoolean(PROP_MATCH_MANIFEST_VERSIONS, true);
156-
// filter out by purl from sbom all exhortignore dependencies that their version hardcoded in
157-
// requirements.txt -
158-
// in case all versions in manifest matching installed versions of packages in environment.
159-
if (matchManifestVersions) {
160-
sbom.setBelongingCriteriaBinaryAlgorithm(Sbom.BelongingCondition.PURL);
161-
sbom.filterIgnoredDeps(ignoredDepsVersions);
162-
} else {
163-
// in case version mismatch is possible (MATCH_MANIFEST_VERSIONS=false) , need to parse the
164-
// name of package
165-
// from the purl, and remove the package name from sbom according to name only
166-
Set<String> deps =
167-
ignoredDepsVersions.stream()
168-
.map(
169-
purlString -> {
170-
try {
171-
return new PackageURL(purlString).getName();
172-
} catch (MalformedPackageURLException e) {
173-
throw new RuntimeException(e);
174-
}
175-
})
176-
.collect(Collectors.toSet());
177-
sbom.setBelongingCriteriaBinaryAlgorithm(Sbom.BelongingCondition.NAME);
178-
sbom.filterIgnoredDeps(deps);
179-
}
180-
}
181-
182-
/**
183-
* Checks if a text line contains a Python pip ignore pattern. Handles both '#exhortignore' and
184-
* '#trustify-da-ignore' with optional spacing.
185-
*
186-
* @param line the line to check
187-
* @return true if the line contains a Python pip ignore pattern
188-
*/
189-
private boolean containsPythonIgnorePattern(String line) {
190-
return line.contains("#" + IgnorePatternDetector.IGNORE_PATTERN)
191-
|| line.contains("# " + IgnorePatternDetector.IGNORE_PATTERN)
192-
|| line.contains("#" + IgnorePatternDetector.LEGACY_IGNORE_PATTERN)
193-
|| line.contains("# " + IgnorePatternDetector.LEGACY_IGNORE_PATTERN);
194-
}
195-
196-
private Set<PackageURL> getIgnoredDependencies(String requirementsDeps) {
197-
198-
String[] requirementsLines = requirementsDeps.split(System.lineSeparator());
199-
Set<PackageURL> collected =
200-
Arrays.stream(requirementsLines)
201-
.filter(this::containsPythonIgnorePattern)
202-
.map(PythonPipProvider::extractDepFull)
203-
.map(this::splitToNameVersion)
204-
.map(dep -> toPurl(dep[0], dep[1]))
205-
// .map(packageURL -> packageURL.getCoordinates())
206-
.collect(Collectors.toSet());
207-
208-
return collected;
53+
private static String extractDepFull(String requirementLine) {
54+
return requirementLine.substring(0, requirementLine.indexOf("#")).trim();
20955
}
21056

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

0 commit comments

Comments
 (0)