Skip to content

Commit 669a38f

Browse files
ruromeroclaude
andcommitted
fix(python): build true dependency graph instead of tree
The pyproject base class built an intermediate tree with a visited set that prevented a dependency from appearing under multiple parents. This caused PySocks to be missing from requests' dependsOn when both requests and urllib3 declared it (via extras). All other providers (npm, cargo, go, maven) already produce true CycloneDX graphs. Replace _buildDependencyTree/_collectTransitive/_addAllDependencies with graph-based BFS reachability + flat edge iteration. No recursion prevention needed — Python resolvers reject circular dependencies. Regenerate all pyproject golden SBOM files (pip, uv, poetry, workspace). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 290f239 commit 669a38f

16 files changed

Lines changed: 685 additions & 711 deletions

src/providers/base_pyproject.js

Lines changed: 39 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -248,64 +248,27 @@ export default class Base_pyproject {
248248
}
249249

250250
/**
251-
* Build dependency tree from graph, starting from direct deps.
252-
* @param {Map<string, GraphEntry>} graph
253-
* @param {string[]} directDeps - canonical names of direct deps
251+
* Compute the set of graph nodes reachable from direct deps, excluding ignored.
252+
* @param {Map<string, {name: string, version: string, children: string[]}>} graph
253+
* @param {string[]} directDeps
254254
* @param {Set<string>} ignoredDeps
255-
* @param {boolean} includeTransitive
256-
* @returns {DepTreeEntry[]}
255+
* @returns {Set<string>}
257256
* @protected
258257
*/
259-
_buildDependencyTree(graph, directDeps, ignoredDeps, includeTransitive) {
260-
let result = []
261-
262-
for (let key of directDeps) {
263-
if (ignoredDeps.has(key)) { continue }
264-
265-
let entry = graph.get(key)
266-
if (!entry) { continue }
267-
268-
let depTree = []
269-
if (includeTransitive) {
270-
let visited = new Set()
271-
visited.add(key)
272-
this._collectTransitive(graph, entry.children, depTree, ignoredDeps, visited)
258+
_reachableNodes(graph, directDeps, ignoredDeps) {
259+
let reachable = new Set()
260+
let queue = directDeps.filter(k => !ignoredDeps.has(k) && graph.has(k))
261+
while (queue.length > 0) {
262+
let key = queue.shift()
263+
if (reachable.has(key)) { continue }
264+
reachable.add(key)
265+
for (let child of graph.get(key).children) {
266+
if (!ignoredDeps.has(child) && graph.has(child) && !reachable.has(child)) {
267+
queue.push(child)
268+
}
273269
}
274-
275-
result.push({ name: entry.name, version: entry.version, dependencies: depTree })
276270
}
277-
278-
result.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()))
279-
return result
280-
}
281-
282-
/**
283-
* Recursively collect transitive dependencies.
284-
* @param {Map<string, GraphEntry>} graph
285-
* @param {string[]} childKeys
286-
* @param {DepTreeEntry[]} result - mutated in place
287-
* @param {Set<string>} ignoredDeps
288-
* @param {Set<string>} visited
289-
* @returns {void}
290-
* @protected
291-
*/
292-
_collectTransitive(graph, childKeys, result, ignoredDeps, visited) {
293-
for (let childKey of childKeys) {
294-
let canonKey = this._canonicalize(childKey)
295-
if (ignoredDeps.has(canonKey)) { continue }
296-
if (visited.has(canonKey)) { continue }
297-
visited.add(canonKey)
298-
299-
let entry = graph.get(canonKey)
300-
if (!entry) { continue }
301-
302-
let childDeps = []
303-
this._collectTransitive(graph, entry.children, childDeps, ignoredDeps, visited)
304-
305-
result.push({ name: entry.name, version: entry.version, dependencies: childDeps })
306-
}
307-
308-
result.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()))
271+
return reachable
309272
}
310273

311274
/**
@@ -318,22 +281,6 @@ export default class Base_pyproject {
318281
return new PackageURL('pypi', undefined, name, version, undefined, undefined)
319282
}
320283

321-
/**
322-
* Recursively add a dependency and its transitive deps to the SBOM.
323-
* @param {PackageURL} source
324-
* @param {DepTreeEntry} dep
325-
* @param {Sbom} sbom
326-
* @returns {void}
327-
* @private
328-
*/
329-
_addAllDependencies(source, dep, sbom) {
330-
let targetPurl = this._toPurl(dep.name, dep.version)
331-
sbom.addDependency(source, targetPurl)
332-
if (dep.dependencies && dep.dependencies.length > 0) {
333-
dep.dependencies.forEach(child => this._addAllDependencies(this._toPurl(dep.name, dep.version), child, sbom))
334-
}
335-
}
336-
337284
/**
338285
* Create SBOM json string for a pyproject.toml project.
339286
* @param {string} manifest - path to pyproject.toml
@@ -351,7 +298,6 @@ export default class Base_pyproject {
351298
let { directDeps, graph } = await this._getDependencyData(manifestDir, workspaceDir, parsed, opts)
352299

353300
let ignoredDeps = this._getIgnoredDeps(manifest)
354-
let dependencies = this._buildDependencyTree(graph, directDeps, ignoredDeps, includeTransitive)
355301

356302
let sbom = new Sbom()
357303
let rootName = this._getProjectName(parsed) || DEFAULT_ROOT_NAME
@@ -360,13 +306,30 @@ export default class Base_pyproject {
360306
let license = this.readLicenseFromManifest(manifest)
361307
sbom.addRoot(rootPurl, license)
362308

363-
dependencies.forEach(dep => {
364-
if (includeTransitive) {
365-
this._addAllDependencies(rootPurl, dep, sbom)
366-
} else {
367-
sbom.addDependency(rootPurl, this._toPurl(dep.name, dep.version))
309+
if (includeTransitive) {
310+
let reachable = this._reachableNodes(graph, directDeps, ignoredDeps)
311+
for (let key of directDeps) {
312+
if (!reachable.has(key)) { continue }
313+
let entry = graph.get(key)
314+
sbom.addDependency(rootPurl, this._toPurl(entry.name, entry.version))
368315
}
369-
})
316+
for (let [key, entry] of graph) {
317+
if (!reachable.has(key)) { continue }
318+
let parentPurl = this._toPurl(entry.name, entry.version)
319+
for (let child of entry.children) {
320+
if (!reachable.has(child)) { continue }
321+
let childEntry = graph.get(child)
322+
sbom.addDependency(parentPurl, this._toPurl(childEntry.name, childEntry.version))
323+
}
324+
}
325+
} else {
326+
for (let key of directDeps) {
327+
if (ignoredDeps.has(key)) { continue }
328+
let entry = graph.get(key)
329+
if (!entry) { continue }
330+
sbom.addDependency(rootPurl, this._toPurl(entry.name, entry.version))
331+
}
332+
}
370333

371334
return sbom.getAsJsonString(opts)
372335
}

src/providers/python_pip_pyproject.js

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,6 @@ export default class Python_pip_pyproject extends Base_pyproject {
9191
let entry = graph.get(key)
9292
let requires = pkg.metadata.requires_dist || []
9393
for (let req of requires) {
94-
if (this._hasExtraMarker(req)) { continue }
9594
let depName = this._extractDepName(req)
9695
if (!depName) { continue }
9796
let depKey = this._canonicalize(depName)

test/providers/python_pyproject.test.js

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -210,15 +210,6 @@ suite('testing the python-pyproject data provider', () => {
210210
expect(transNames).to.include('urllib3')
211211
}).timeout(TIMEOUT)
212212

213-
/** Verifies extras-only dependencies (e.g. PySocks for socks extra) are excluded. */
214-
test('extras-only dependencies are filtered from the dependency tree', async () => {
215-
let result = await pipProvider.provideStack(path.join(pipFixtureDir, 'pyproject.toml'))
216-
let sbom = JSON.parse(result.content)
217-
let names = sbom.components.map(c => c.name)
218-
expect(names).to.not.include('PySocks')
219-
expect(names).to.not.include('pysocks')
220-
}).timeout(TIMEOUT)
221-
222213
/** Verifies exhortignore marker produces expected SBOM for stack and component analysis. */
223214
SBOM_CASES.forEach(({type, method, fixture}) => {
224215
test(`verify exhortignore produces expected sbom for ${type} analysis with pip`, async () => {

test/providers/tst_manifests/pyproject/pep621_ignore_and_extras/expected_component_sbom.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,4 +69,4 @@
6969
"dependsOn": []
7070
}
7171
]
72-
}
72+
}

test/providers/tst_manifests/pyproject/pep621_ignore_and_extras/expected_stack_sbom.json

Lines changed: 39 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,27 @@
2020
"type": "library",
2121
"bom-ref": "pkg:pypi/flask@2.0.3"
2222
},
23+
{
24+
"name": "pywin32",
25+
"version": "311",
26+
"purl": "pkg:pypi/pywin32@311",
27+
"type": "library",
28+
"bom-ref": "pkg:pypi/pywin32@311"
29+
},
30+
{
31+
"name": "requests",
32+
"version": "2.25.1",
33+
"purl": "pkg:pypi/requests@2.25.1",
34+
"type": "library",
35+
"bom-ref": "pkg:pypi/requests@2.25.1"
36+
},
37+
{
38+
"name": "typing-extensions",
39+
"version": "4.1.1",
40+
"purl": "pkg:pypi/typing-extensions@4.1.1",
41+
"type": "library",
42+
"bom-ref": "pkg:pypi/typing-extensions@4.1.1"
43+
},
2344
{
2445
"name": "click",
2546
"version": "8.3.1",
@@ -55,20 +76,6 @@
5576
"type": "library",
5677
"bom-ref": "pkg:pypi/werkzeug@2.0.3"
5778
},
58-
{
59-
"name": "pywin32",
60-
"version": "311",
61-
"purl": "pkg:pypi/pywin32@311",
62-
"type": "library",
63-
"bom-ref": "pkg:pypi/pywin32@311"
64-
},
65-
{
66-
"name": "requests",
67-
"version": "2.25.1",
68-
"purl": "pkg:pypi/requests@2.25.1",
69-
"type": "library",
70-
"bom-ref": "pkg:pypi/requests@2.25.1"
71-
},
7279
{
7380
"name": "certifi",
7481
"version": "2023.7.22",
@@ -96,13 +103,6 @@
96103
"purl": "pkg:pypi/urllib3@1.26.16",
97104
"type": "library",
98105
"bom-ref": "pkg:pypi/urllib3@1.26.16"
99-
},
100-
{
101-
"name": "typing-extensions",
102-
"version": "4.1.1",
103-
"purl": "pkg:pypi/typing-extensions@4.1.1",
104-
"type": "library",
105-
"bom-ref": "pkg:pypi/typing-extensions@4.1.1"
106106
}
107107
],
108108
"dependencies": [
@@ -124,6 +124,23 @@
124124
"pkg:pypi/werkzeug@2.0.3"
125125
]
126126
},
127+
{
128+
"ref": "pkg:pypi/pywin32@311",
129+
"dependsOn": []
130+
},
131+
{
132+
"ref": "pkg:pypi/requests@2.25.1",
133+
"dependsOn": [
134+
"pkg:pypi/certifi@2023.7.22",
135+
"pkg:pypi/chardet@4.0.0",
136+
"pkg:pypi/idna@2.10",
137+
"pkg:pypi/urllib3@1.26.16"
138+
]
139+
},
140+
{
141+
"ref": "pkg:pypi/typing-extensions@4.1.1",
142+
"dependsOn": []
143+
},
127144
{
128145
"ref": "pkg:pypi/click@8.3.1",
129146
"dependsOn": [
@@ -146,19 +163,6 @@
146163
"ref": "pkg:pypi/werkzeug@2.0.3",
147164
"dependsOn": []
148165
},
149-
{
150-
"ref": "pkg:pypi/pywin32@311",
151-
"dependsOn": []
152-
},
153-
{
154-
"ref": "pkg:pypi/requests@2.25.1",
155-
"dependsOn": [
156-
"pkg:pypi/certifi@2023.7.22",
157-
"pkg:pypi/chardet@4.0.0",
158-
"pkg:pypi/idna@2.10",
159-
"pkg:pypi/urllib3@1.26.16"
160-
]
161-
},
162166
{
163167
"ref": "pkg:pypi/certifi@2023.7.22",
164168
"dependsOn": []
@@ -174,10 +178,6 @@
174178
{
175179
"ref": "pkg:pypi/urllib3@1.26.16",
176180
"dependsOn": []
177-
},
178-
{
179-
"ref": "pkg:pypi/typing-extensions@4.1.1",
180-
"dependsOn": []
181181
}
182182
]
183-
}
183+
}

test/providers/tst_manifests/pyproject/pip_pep621/expected_stack_sbom.json

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,6 @@
2020
"type": "library",
2121
"bom-ref": "pkg:pypi/requests@2.32.3"
2222
},
23-
{
24-
"name": "certifi",
25-
"version": "2026.2.25",
26-
"purl": "pkg:pypi/certifi@2026.2.25",
27-
"type": "library",
28-
"bom-ref": "pkg:pypi/certifi@2026.2.25"
29-
},
3023
{
3124
"name": "charset-normalizer",
3225
"version": "3.4.7",
@@ -47,6 +40,20 @@
4740
"purl": "pkg:pypi/urllib3@2.6.3",
4841
"type": "library",
4942
"bom-ref": "pkg:pypi/urllib3@2.6.3"
43+
},
44+
{
45+
"name": "certifi",
46+
"version": "2026.2.25",
47+
"purl": "pkg:pypi/certifi@2026.2.25",
48+
"type": "library",
49+
"bom-ref": "pkg:pypi/certifi@2026.2.25"
50+
},
51+
{
52+
"name": "pysocks",
53+
"version": "1.7.1",
54+
"purl": "pkg:pypi/pysocks@1.7.1",
55+
"type": "library",
56+
"bom-ref": "pkg:pypi/pysocks@1.7.1"
5057
}
5158
],
5259
"dependencies": [
@@ -59,16 +66,13 @@
5966
{
6067
"ref": "pkg:pypi/requests@2.32.3",
6168
"dependsOn": [
62-
"pkg:pypi/certifi@2026.2.25",
6369
"pkg:pypi/charset-normalizer@3.4.7",
6470
"pkg:pypi/idna@3.11",
65-
"pkg:pypi/urllib3@2.6.3"
71+
"pkg:pypi/urllib3@2.6.3",
72+
"pkg:pypi/certifi@2026.2.25",
73+
"pkg:pypi/pysocks@1.7.1"
6674
]
6775
},
68-
{
69-
"ref": "pkg:pypi/certifi@2026.2.25",
70-
"dependsOn": []
71-
},
7276
{
7377
"ref": "pkg:pypi/charset-normalizer@3.4.7",
7478
"dependsOn": []
@@ -79,6 +83,16 @@
7983
},
8084
{
8185
"ref": "pkg:pypi/urllib3@2.6.3",
86+
"dependsOn": [
87+
"pkg:pypi/pysocks@1.7.1"
88+
]
89+
},
90+
{
91+
"ref": "pkg:pypi/certifi@2026.2.25",
92+
"dependsOn": []
93+
},
94+
{
95+
"ref": "pkg:pypi/pysocks@1.7.1",
8296
"dependsOn": []
8397
}
8498
]

test/providers/tst_manifests/pyproject/poetry_lock/expected_component_sbom.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,4 +45,4 @@
4545
"dependsOn": []
4646
}
4747
]
48-
}
48+
}

0 commit comments

Comments
 (0)