Skip to content

Commit 7a2cffa

Browse files
committed
feat(pip): add pyproject.toml support via pip --dry-run --report
Add Python_pip_pyproject provider that resolves PEP 621 pyproject.toml dependencies using `pip install --dry-run --ignore-installed --report`. This is the fallback provider when no lock file (uv.lock/poetry.lock) is found, enabling analysis of standard pyproject.toml projects without requiring uv or poetry. The pip report JSON provides resolved versions, requires_dist for building the dependency tree, and requested flags for direct vs transitive classification. Extras-only deps are filtered out. Supports TRUSTIFY_DA_PIP_REPORT env var for testing without pip. Implements TC-4065 Assisted-by: Claude Code
1 parent dcb2120 commit 7a2cffa

9 files changed

Lines changed: 515 additions & 0 deletions

File tree

src/provider.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import Javascript_npm from './providers/javascript_npm.js';
88
import Javascript_pnpm from './providers/javascript_pnpm.js';
99
import Javascript_yarn from './providers/javascript_yarn.js';
1010
import pythonPipProvider from './providers/python_pip.js'
11+
import Python_pip_pyproject from './providers/python_pip_pyproject.js'
1112
import Python_poetry from './providers/python_poetry.js'
1213
import Python_uv from './providers/python_uv.js'
1314
import rustCargoProvider from './providers/rust_cargo.js'
@@ -30,6 +31,7 @@ export const availableProviders = [
3031
pythonPipProvider,
3132
new Python_poetry(),
3233
new Python_uv(),
34+
new Python_pip_pyproject(),
3335
rustCargoProvider]
3436

3537
/**
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import { environmentVariableIsPopulated, getCustomPath, invokeCommand } from '../tools.js'
2+
3+
import Base_pyproject from './base_pyproject.js'
4+
5+
/**
6+
* Python provider for pyproject.toml files using PEP 621 format without a lock file.
7+
* Uses `pip install --dry-run --ignore-installed --report` to resolve the full dependency tree.
8+
* Acts as the fallback provider when no lock file (uv.lock/poetry.lock) is found.
9+
*/
10+
export default class Python_pip_pyproject extends Base_pyproject {
11+
12+
/** @returns {string} */
13+
_lockFileName() {
14+
return '.pip-lock-nonexistent'
15+
}
16+
17+
/** @returns {string} */
18+
_cmdName() {
19+
return 'pip'
20+
}
21+
22+
/**
23+
* Always returns true — pip provider is the fallback when no lock file is found.
24+
* @param {string} manifestDir
25+
* @param {{}} [opts={}]
26+
* @returns {boolean}
27+
*/
28+
// eslint-disable-next-line no-unused-vars
29+
validateLockFile(manifestDir, opts = {}) {
30+
return true
31+
}
32+
33+
/**
34+
* Get pip report output from env var override or by running pip.
35+
* @param {string} manifestDir - directory containing pyproject.toml
36+
* @param {{}} [opts={}]
37+
* @returns {string} pip report JSON string
38+
*/
39+
_getPipReportOutput(manifestDir, opts) {
40+
if (environmentVariableIsPopulated('TRUSTIFY_DA_PIP_REPORT')) {
41+
return Buffer.from(process.env['TRUSTIFY_DA_PIP_REPORT'], 'base64').toString('ascii')
42+
}
43+
let pipBin = getCustomPath('pip3', opts)
44+
try {
45+
invokeCommand(pipBin, ['--version'])
46+
} catch {
47+
pipBin = getCustomPath('pip', opts)
48+
}
49+
return invokeCommand(pipBin, [
50+
'install', '--dry-run', '--ignore-installed', '--report', '-', '.'
51+
], { cwd: manifestDir }).toString()
52+
}
53+
54+
/**
55+
* Parse pip report JSON and build dependency graph.
56+
* @param {string} reportJson - pip report JSON string
57+
* @returns {{directDeps: string[], graph: Map<string, {name: string, version: string, children: string[]}>}}
58+
*/
59+
_parsePipReport(reportJson) {
60+
let report = JSON.parse(reportJson)
61+
let packages = report.install || []
62+
63+
let rootEntry = packages.find(p => p.download_info?.dir_info !== undefined)
64+
let rootRequires = rootEntry?.metadata?.requires_dist || []
65+
66+
let directDepNames = new Set()
67+
for (let req of rootRequires) {
68+
if (this._hasExtraMarker(req)) { continue }
69+
let name = this._extractDepName(req)
70+
if (name) { directDepNames.add(this._canonicalize(name)) }
71+
}
72+
73+
let graph = new Map()
74+
let nonRootPackages = packages.filter(p => p.download_info?.dir_info === undefined)
75+
76+
for (let pkg of nonRootPackages) {
77+
let name = pkg.metadata.name
78+
let version = pkg.metadata.version
79+
let key = this._canonicalize(name)
80+
graph.set(key, { name, version, children: [] })
81+
}
82+
83+
for (let pkg of nonRootPackages) {
84+
let key = this._canonicalize(pkg.metadata.name)
85+
let entry = graph.get(key)
86+
let requires = pkg.metadata.requires_dist || []
87+
for (let req of requires) {
88+
if (this._hasExtraMarker(req)) { continue }
89+
let depName = this._extractDepName(req)
90+
if (!depName) { continue }
91+
let depKey = this._canonicalize(depName)
92+
if (graph.has(depKey)) {
93+
entry.children.push(depKey)
94+
}
95+
}
96+
}
97+
98+
let directDeps = [...directDepNames].filter(key => graph.has(key))
99+
return { directDeps, graph }
100+
}
101+
102+
/**
103+
* Check if a requires_dist entry is an extras-only dependency.
104+
* @param {string} req - e.g. "PySocks!=1.5.7,>=1.5.6; extra == \"socks\""
105+
* @returns {boolean}
106+
*/
107+
_hasExtraMarker(req) {
108+
return /;\s*.*extra\s*==/.test(req)
109+
}
110+
111+
/**
112+
* Extract package name from a requires_dist entry.
113+
* @param {string} req - e.g. "charset_normalizer<4,>=2"
114+
* @returns {string|null}
115+
*/
116+
_extractDepName(req) {
117+
let match = req.match(/^([A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?)/)
118+
return match ? match[1] : null
119+
}
120+
121+
/**
122+
* Resolve dependencies using pip install --dry-run --report.
123+
* @param {string} manifestDir
124+
* @param {object} parsed - parsed pyproject.toml
125+
* @param {{}} [opts={}]
126+
* @returns {Promise<{directDeps: string[], graph: Map}>}
127+
*/
128+
async _getDependencyData(manifestDir, parsed, opts) {
129+
let reportOutput = this._getPipReportOutput(manifestDir, opts)
130+
return this._parsePipReport(reportOutput)
131+
}
132+
}

test/providers/python_pyproject.test.js

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import path from 'path'
44
import { expect } from 'chai'
55
import { useFakeTimers } from 'sinon'
66

7+
import Python_pip_pyproject from '../../src/providers/python_pip_pyproject.js'
78
import Python_poetry from '../../src/providers/python_poetry.js'
89
import Python_uv from '../../src/providers/python_uv.js'
910

@@ -13,6 +14,7 @@ const TIMEOUT = process.env.GITHUB_ACTIONS ? 30000 : 10000
1314

1415
const uvProvider = new Python_uv()
1516
const poetryProvider = new Python_poetry()
17+
const pipProvider = new Python_pip_pyproject()
1618

1719
suite('testing the python-pyproject data provider', () => {
1820
[
@@ -184,6 +186,126 @@ suite('testing the python-pyproject data provider', () => {
184186
}).timeout(TIMEOUT)
185187
})
186188

189+
/** Verifies the pip provider's validateLockFile always returns true (fallback). */
190+
test('verify pip validateLockFile always returns true (fallback provider)', () => {
191+
expect(pipProvider.validateLockFile('test/providers/tst_manifests/pyproject/pip_pep621')).to.equal(true)
192+
expect(pipProvider.validateLockFile('/nonexistent/dir')).to.equal(true)
193+
})
194+
195+
suite('pip projects (via pip --dry-run --report)', () => {
196+
const pipFixtureDir = 'test/providers/tst_manifests/pyproject/pip_pep621'
197+
const pipIgnoreDir = 'test/providers/tst_manifests/pyproject/pip_pep621_ignore'
198+
let savedEnv
199+
200+
setup(() => {
201+
savedEnv = process.env.TRUSTIFY_DA_PIP_REPORT
202+
let report = fs.readFileSync(path.join(pipFixtureDir, 'pip_report.json'), 'utf-8')
203+
process.env.TRUSTIFY_DA_PIP_REPORT = Buffer.from(report).toString('base64')
204+
})
205+
206+
teardown(() => {
207+
if (savedEnv === undefined) {
208+
delete process.env.TRUSTIFY_DA_PIP_REPORT
209+
} else {
210+
process.env.TRUSTIFY_DA_PIP_REPORT = savedEnv
211+
}
212+
})
213+
214+
/** Verifies stack analysis produces correct SBOM with transitive deps. */
215+
test('verify pyproject.toml sbom provided for stack analysis with pip', async () => {
216+
// Given a PEP 621 pyproject.toml and pre-recorded pip report
217+
let expectedSbom = fs.readFileSync(path.join(pipFixtureDir, 'expected_stack_sbom.json')).toString()
218+
expectedSbom = JSON.stringify(JSON.parse(expectedSbom))
219+
220+
// When running stack analysis
221+
let result = await pipProvider.provideStack(path.join(pipFixtureDir, 'pyproject.toml'))
222+
223+
// Then the SBOM matches expected output
224+
expect(result).to.deep.equal({
225+
ecosystem: 'pip',
226+
contentType: 'application/vnd.cyclonedx+json',
227+
content: expectedSbom
228+
})
229+
}).timeout(TIMEOUT)
230+
231+
/** Verifies component analysis produces correct SBOM with direct deps only. */
232+
test('verify pyproject.toml sbom provided for component analysis with pip', async () => {
233+
// Given a PEP 621 pyproject.toml and pre-recorded pip report
234+
let expectedSbom = fs.readFileSync(path.join(pipFixtureDir, 'expected_component_sbom.json')).toString().trim()
235+
expectedSbom = JSON.stringify(JSON.parse(expectedSbom))
236+
237+
// When running component analysis
238+
let result = await pipProvider.provideComponent(path.join(pipFixtureDir, 'pyproject.toml'))
239+
240+
// Then the SBOM matches expected output
241+
expect(result).to.deep.equal({
242+
ecosystem: 'pip',
243+
contentType: 'application/vnd.cyclonedx+json',
244+
content: expectedSbom
245+
})
246+
}).timeout(TIMEOUT)
247+
248+
/** Verifies direct and transitive deps are correctly classified in stack SBOM. */
249+
test('stack analysis classifies direct and transitive dependencies correctly', async () => {
250+
// When running stack analysis
251+
let result = await pipProvider.provideStack(path.join(pipFixtureDir, 'pyproject.toml'))
252+
let sbom = JSON.parse(result.content)
253+
254+
// Then requests is a direct dep of the root
255+
let rootDep = sbom.dependencies.find(d => d.ref.includes('/test-project@'))
256+
expect(rootDep.dependsOn).to.have.lengthOf(1)
257+
expect(rootDep.dependsOn[0]).to.include('/requests@')
258+
259+
// And requests has its own transitive deps
260+
let requestsDep = sbom.dependencies.find(d => d.ref.includes('/requests@'))
261+
let transNames = requestsDep.dependsOn.map(d => d.split('/').pop().split('@')[0])
262+
expect(transNames).to.include('certifi')
263+
expect(transNames).to.include('charset-normalizer')
264+
expect(transNames).to.include('idna')
265+
expect(transNames).to.include('urllib3')
266+
}).timeout(TIMEOUT)
267+
268+
/** Verifies extras-only dependencies (e.g. PySocks for socks extra) are excluded. */
269+
test('extras-only dependencies are filtered from the dependency tree', async () => {
270+
let result = await pipProvider.provideStack(path.join(pipFixtureDir, 'pyproject.toml'))
271+
let sbom = JSON.parse(result.content)
272+
let names = sbom.components.map(c => c.name)
273+
expect(names).to.not.include('PySocks')
274+
expect(names).to.not.include('pysocks')
275+
}).timeout(TIMEOUT)
276+
277+
/** Verifies exhortignore marker in PEP 621 dependencies excludes the dep. */
278+
test('exhortignore marker excludes dep from component analysis', async () => {
279+
// Given a pyproject.toml with requests marked as exhortignore
280+
let result = await pipProvider.provideComponent(path.join(pipIgnoreDir, 'pyproject.toml'))
281+
let sbom = JSON.parse(result.content)
282+
283+
// Then requests is excluded
284+
let names = sbom.components.map(c => c.name)
285+
expect(names).to.not.include('requests')
286+
}).timeout(TIMEOUT)
287+
288+
/** Verifies exhortignore excludes dep and its exclusive transitive deps from stack analysis. */
289+
test('exhortignore marker excludes dep from stack analysis', async () => {
290+
// Given a pyproject.toml with requests marked as exhortignore
291+
let result = await pipProvider.provideStack(path.join(pipIgnoreDir, 'pyproject.toml'))
292+
let sbom = JSON.parse(result.content)
293+
294+
// Then requests and all its exclusive transitive deps are excluded
295+
let names = sbom.components.map(c => c.name)
296+
expect(names).to.not.include('requests')
297+
}).timeout(TIMEOUT)
298+
299+
/** Verifies name canonicalization (charset_normalizer → charset-normalizer). */
300+
test('name canonicalization: charset_normalizer resolved as charset-normalizer', async () => {
301+
let result = await pipProvider.provideStack(path.join(pipFixtureDir, 'pyproject.toml'))
302+
let sbom = JSON.parse(result.content)
303+
let pkg = sbom.components.find(c => c.name === 'charset-normalizer')
304+
expect(pkg).to.exist
305+
expect(pkg.version).to.equal('3.4.7')
306+
}).timeout(TIMEOUT)
307+
})
308+
187309
test('validateLockFile returns false when no lock file is present', () => {
188310
let tmpDir = 'test/providers/tst_manifests/pyproject/no_lock_file_dummy'
189311
fs.mkdirSync(tmpDir, { recursive: true })
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
{
2+
"bomFormat": "CycloneDX",
3+
"specVersion": "1.4",
4+
"version": 1,
5+
"metadata": {
6+
"timestamp": "2023-10-01T00:00:00.000Z",
7+
"component": {
8+
"name": "test-project",
9+
"version": "1.0.0",
10+
"purl": "pkg:pypi/test-project@1.0.0",
11+
"type": "application",
12+
"bom-ref": "pkg:pypi/test-project@1.0.0"
13+
}
14+
},
15+
"components": [
16+
{
17+
"name": "requests",
18+
"version": "2.33.1",
19+
"purl": "pkg:pypi/requests@2.33.1",
20+
"type": "library",
21+
"bom-ref": "pkg:pypi/requests@2.33.1"
22+
}
23+
],
24+
"dependencies": [
25+
{
26+
"ref": "pkg:pypi/test-project@1.0.0",
27+
"dependsOn": [
28+
"pkg:pypi/requests@2.33.1"
29+
]
30+
},
31+
{
32+
"ref": "pkg:pypi/requests@2.33.1",
33+
"dependsOn": []
34+
}
35+
]
36+
}

0 commit comments

Comments
 (0)