@@ -4,6 +4,7 @@ import path from 'path'
44import { expect } from 'chai'
55import { useFakeTimers } from 'sinon'
66
7+ import Python_pip_pyproject from '../../src/providers/python_pip_pyproject.js'
78import Python_poetry from '../../src/providers/python_poetry.js'
89import Python_uv from '../../src/providers/python_uv.js'
910
@@ -13,6 +14,7 @@ const TIMEOUT = process.env.GITHUB_ACTIONS ? 30000 : 10000
1314
1415const uvProvider = new Python_uv ( )
1516const poetryProvider = new Python_poetry ( )
17+ const pipProvider = new Python_pip_pyproject ( )
1618
1719suite ( '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 } )
0 commit comments