Skip to content

Commit 7f8eff5

Browse files
authored
feat(python): use pyproject.toml metadata for root SBOM component (#368)
## Summary - Read project name, version, and license from `pyproject.toml` metadata (PEP 621 and Poetry formats) instead of using hardcoded defaults for the root SBOM component - Add `getRootComponentName()` / `getRootComponentVersion()` virtual methods in `PythonProvider` base class for subclass override - Override `readLicenseFromManifest()` in `PythonPyprojectProvider` to extract license from TOML before falling back to LICENSE file - Cache parsed TOML object to avoid redundant parsing ## Test plan - [x] PEP 621 name/version extraction from `[project]` section - [x] Poetry name/version extraction from `[tool.poetry]` section - [x] Fallback to defaults when no metadata present - [x] PEP 621 license extraction - [x] Poetry license extraction - [x] Existing requirements.txt provider behavior unchanged - [x] All 26 pyproject tests pass (8 new + 18 existing) Implements [TC-3894](https://redhat.atlassian.net/browse/TC-3894) 🤖 Generated with [Claude Code](https://claude.com/claude-code)
1 parent 1f6426a commit 7f8eff5

6 files changed

Lines changed: 177 additions & 8 deletions

File tree

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

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,14 @@ public String readLicenseFromManifest() {
6969
return LicenseUtils.readLicenseFile(manifest);
7070
}
7171

72+
protected String getRootComponentName() {
73+
return DEFAULT_PIP_ROOT_COMPONENT_NAME;
74+
}
75+
76+
protected String getRootComponentVersion() {
77+
return DEFAULT_PIP_ROOT_COMPONENT_VERSION;
78+
}
79+
7280
/**
7381
* Returns the path to a requirements-format file that the {@link PythonControllerBase} can
7482
* consume. For requirements.txt this is the manifest itself; for pyproject.toml a temporary file
@@ -92,8 +100,7 @@ public Content provideStack() throws IOException {
92100
printDependenciesTree(dependencies);
93101
Sbom sbom = SbomFactory.newInstance(Sbom.BelongingCondition.PURL, "sensitive");
94102
sbom.addRoot(
95-
toPurl(DEFAULT_PIP_ROOT_COMPONENT_NAME, DEFAULT_PIP_ROOT_COMPONENT_VERSION),
96-
readLicenseFromManifest());
103+
toPurl(getRootComponentName(), getRootComponentVersion()), readLicenseFromManifest());
97104
for (Map<String, Object> component : dependencies) {
98105
addAllDependencies(sbom.getRoot(), component, sbom);
99106
}
@@ -120,8 +127,7 @@ public Content provideComponent() throws IOException {
120127
printDependenciesTree(dependencies);
121128
Sbom sbom = SbomFactory.newInstance();
122129
sbom.addRoot(
123-
toPurl(DEFAULT_PIP_ROOT_COMPONENT_NAME, DEFAULT_PIP_ROOT_COMPONENT_VERSION),
124-
readLicenseFromManifest());
130+
toPurl(getRootComponentName(), getRootComponentVersion()), readLicenseFromManifest());
125131
dependencies.forEach(
126132
(component) ->
127133
sbom.addDependency(

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

Lines changed: 79 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
package io.github.guacsec.trustifyda.providers;
1818

1919
import com.github.packageurl.PackageURL;
20+
import io.github.guacsec.trustifyda.license.LicenseUtils;
21+
import io.github.guacsec.trustifyda.logging.LoggersFactory;
2022
import io.github.guacsec.trustifyda.utils.PythonControllerBase;
2123
import java.io.IOException;
2224
import java.nio.file.Files;
@@ -25,6 +27,7 @@
2527
import java.util.HashSet;
2628
import java.util.List;
2729
import java.util.Set;
30+
import java.util.logging.Logger;
2831
import java.util.stream.Collectors;
2932
import org.tomlj.Toml;
3033
import org.tomlj.TomlArray;
@@ -33,7 +36,11 @@
3336

3437
public final class PythonPyprojectProvider extends PythonProvider {
3538

39+
private static final Logger log =
40+
LoggersFactory.getLogger(PythonPyprojectProvider.class.getName());
41+
3642
private Set<String> collectedIgnoredDeps;
43+
private TomlParseResult cachedToml;
3744

3845
public PythonPyprojectProvider(Path manifest) {
3946
super(manifest);
@@ -54,6 +61,77 @@ protected void cleanupRequirementsPath(Path requirementsPath) throws IOException
5461
Files.deleteIfExists(requirementsPath.getParent());
5562
}
5663

64+
private TomlParseResult getToml() throws IOException {
65+
if (cachedToml == null) {
66+
TomlParseResult parsed = Toml.parse(manifest);
67+
if (parsed.hasErrors()) {
68+
throw new IOException(
69+
"Invalid pyproject.toml format: " + parsed.errors().get(0).getMessage());
70+
}
71+
cachedToml = parsed;
72+
}
73+
return cachedToml;
74+
}
75+
76+
@Override
77+
protected String getRootComponentName() {
78+
try {
79+
TomlParseResult toml = getToml();
80+
String name = toml.getString("project.name");
81+
if (name != null && !name.isBlank()) {
82+
return name;
83+
}
84+
String poetryName = toml.getString("tool.poetry.name");
85+
if (poetryName != null && !poetryName.isBlank()) {
86+
return poetryName;
87+
}
88+
} catch (IOException e) {
89+
log.fine("Failed to parse pyproject.toml for root component name: " + e.getMessage());
90+
}
91+
return super.getRootComponentName();
92+
}
93+
94+
@Override
95+
protected String getRootComponentVersion() {
96+
try {
97+
TomlParseResult toml = getToml();
98+
String version = toml.getString("project.version");
99+
if (version != null && !version.isBlank()) {
100+
return version;
101+
}
102+
String poetryVersion = toml.getString("tool.poetry.version");
103+
if (poetryVersion != null && !poetryVersion.isBlank()) {
104+
return poetryVersion;
105+
}
106+
} catch (IOException e) {
107+
log.fine("Failed to parse pyproject.toml for root component version: " + e.getMessage());
108+
}
109+
return super.getRootComponentVersion();
110+
}
111+
112+
@Override
113+
public String readLicenseFromManifest() {
114+
try {
115+
TomlParseResult toml = getToml();
116+
String license = toml.getString("project.license");
117+
if (license != null && !license.isBlank()) {
118+
return license;
119+
}
120+
// PEP 639: license may be in project.license.text
121+
String licenseText = toml.getString("project.license.text");
122+
if (licenseText != null && !licenseText.isBlank()) {
123+
return licenseText;
124+
}
125+
String poetryLicense = toml.getString("tool.poetry.license");
126+
if (poetryLicense != null && !poetryLicense.isBlank()) {
127+
return poetryLicense;
128+
}
129+
} catch (IOException e) {
130+
log.fine("Failed to parse pyproject.toml for license: " + e.getMessage());
131+
}
132+
return LicenseUtils.readLicenseFile(manifest);
133+
}
134+
57135
@Override
58136
protected Set<PackageURL> getIgnoredDependencies(String manifestContent) {
59137
if (collectedIgnoredDeps == null) {
@@ -69,10 +147,7 @@ protected Set<PackageURL> getIgnoredDependencies(String manifestContent) {
69147
}
70148

71149
List<String> parseDependencyStrings() throws IOException {
72-
TomlParseResult toml = Toml.parse(manifest);
73-
if (toml.hasErrors()) {
74-
throw new IOException("Invalid pyproject.toml format: " + toml.errors().get(0).getMessage());
75-
}
150+
TomlParseResult toml = getToml();
76151

77152
List<String> rawLines = Files.readAllLines(manifest);
78153
collectedIgnoredDeps = new HashSet<>();

src/test/java/io/github/guacsec/trustifyda/providers/Python_Pyproject_Provider_Test.java

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,75 @@ void test_ignored_deps_collected_during_parsing() throws IOException {
163163
assertThat(ignoredNames).doesNotContain("anyio", "requests");
164164
}
165165

166+
@Test
167+
void test_getRootComponentName_reads_pep621_name() {
168+
Path pyprojectPath =
169+
Path.of("src/test/resources/tst_manifests/pip/pip_pyproject_toml_no_ignore/pyproject.toml");
170+
var provider = new PythonPyprojectProvider(pyprojectPath);
171+
assertThat(provider.getRootComponentName()).isEqualTo("test-project");
172+
}
173+
174+
@Test
175+
void test_getRootComponentName_reads_poetry_name() {
176+
Path pyprojectPath =
177+
Path.of("src/test/resources/tst_manifests/pip/pip_pyproject_toml_poetry/pyproject.toml");
178+
var provider = new PythonPyprojectProvider(pyprojectPath);
179+
assertThat(provider.getRootComponentName()).isEqualTo("test-project");
180+
}
181+
182+
@Test
183+
void test_getRootComponentName_falls_back_to_default() {
184+
Path pyprojectPath =
185+
Path.of(
186+
"src/test/resources/tst_manifests/pip/pip_pyproject_toml_no_metadata/pyproject.toml");
187+
var provider = new PythonPyprojectProvider(pyprojectPath);
188+
assertThat(provider.getRootComponentName()).isEqualTo("default-pip-root");
189+
}
190+
191+
@Test
192+
void test_getRootComponentVersion_reads_pep621_version() {
193+
Path pyprojectPath =
194+
Path.of(
195+
"src/test/resources/tst_manifests/pip/pip_pyproject_toml_pep621_license/pyproject.toml");
196+
var provider = new PythonPyprojectProvider(pyprojectPath);
197+
assertThat(provider.getRootComponentVersion()).isEqualTo("2.0.0");
198+
}
199+
200+
@Test
201+
void test_getRootComponentVersion_reads_poetry_version() {
202+
Path pyprojectPath =
203+
Path.of("src/test/resources/tst_manifests/pip/pip_pyproject_toml_poetry/pyproject.toml");
204+
var provider = new PythonPyprojectProvider(pyprojectPath);
205+
assertThat(provider.getRootComponentVersion()).isEqualTo("0.1.0");
206+
}
207+
208+
@Test
209+
void test_getRootComponentVersion_falls_back_to_default() {
210+
Path pyprojectPath =
211+
Path.of(
212+
"src/test/resources/tst_manifests/pip/pip_pyproject_toml_no_metadata/pyproject.toml");
213+
var provider = new PythonPyprojectProvider(pyprojectPath);
214+
assertThat(provider.getRootComponentVersion()).isEqualTo("0.0.0");
215+
}
216+
217+
@Test
218+
void test_readLicenseFromManifest_reads_pep621_license() {
219+
Path pyprojectPath =
220+
Path.of(
221+
"src/test/resources/tst_manifests/pip/pip_pyproject_toml_pep621_license/pyproject.toml");
222+
var provider = new PythonPyprojectProvider(pyprojectPath);
223+
assertThat(provider.readLicenseFromManifest()).isEqualTo("MIT");
224+
}
225+
226+
@Test
227+
void test_readLicenseFromManifest_reads_poetry_license() {
228+
Path pyprojectPath =
229+
Path.of(
230+
"src/test/resources/tst_manifests/pip/pip_pyproject_toml_poetry_license/pyproject.toml");
231+
var provider = new PythonPyprojectProvider(pyprojectPath);
232+
assertThat(provider.readLicenseFromManifest()).isEqualTo("Apache-2.0");
233+
}
234+
166235
@Test
167236
void test_provideComponent_generates_correct_media_type() throws IOException {
168237
Path pyprojectPath =
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
[project]
2+
dependencies = [
3+
"anyio==3.6.2",
4+
]
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
[project]
2+
name = "licensed-project"
3+
version = "2.0.0"
4+
license = "MIT"
5+
dependencies = [
6+
"anyio==3.6.2",
7+
]
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
[tool.poetry]
2+
name = "poetry-licensed"
3+
version = "1.5.0"
4+
license = "Apache-2.0"
5+
6+
[tool.poetry.dependencies]
7+
python = "^3.9"
8+
anyio = "^3.6.2"

0 commit comments

Comments
 (0)