Skip to content

Commit 3690d39

Browse files
committed
chore: reorganize pyproject.toml support with base class
1 parent bd9df59 commit 3690d39

14 files changed

Lines changed: 683 additions & 588 deletions

src/provider.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ 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 pythonPyprojectProvider from './providers/python_pyproject.js'
11+
import Python_poetry from './providers/python_poetry.js'
12+
import Python_uv from './providers/python_uv.js'
1213
import rustCargoProvider from './providers/rust_cargo.js'
1314

1415
/** @typedef {{ecosystem: string, contentType: string, content: string}} Provided */
@@ -27,7 +28,8 @@ export const availableProviders = [
2728
new Javascript_npm(),
2829
golangGomodulesProvider,
2930
pythonPipProvider,
30-
pythonPyprojectProvider,
31+
new Python_poetry(),
32+
new Python_uv(),
3133
rustCargoProvider]
3234

3335
/**

src/providers/base_pyproject.js

Lines changed: 309 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,309 @@
1+
import fs from 'node:fs'
2+
import path from 'node:path'
3+
4+
import { PackageURL } from 'packageurl-js'
5+
import { parse as parseToml } from 'smol-toml'
6+
7+
import { getLicense } from '../license/license_utils.js'
8+
import Sbom from '../sbom.js'
9+
10+
const ecosystem = 'pip'
11+
12+
const IGNORE_MARKERS = ['exhortignore', 'trustify-da-ignore']
13+
14+
const DEFAULT_ROOT_NAME = 'default-pip-root'
15+
const DEFAULT_ROOT_VERSION = '0.0.0'
16+
17+
/** @typedef {{name: string, version: string, children: string[]}} GraphEntry */
18+
/** @typedef {{name: string, version: string, dependencies: DepTreeEntry[]}} DepTreeEntry */
19+
/** @typedef {{directDeps: string[], graph: Map<string, GraphEntry>}} DependencyData */
20+
/** @typedef {{ecosystem: string, content: string, contentType: string}} Provided */
21+
22+
export default class Base_pyproject {
23+
24+
/**
25+
* @param {string} manifestName
26+
* @returns {boolean}
27+
*/
28+
isSupported(manifestName) {
29+
return 'pyproject.toml' === manifestName
30+
}
31+
32+
/**
33+
* @param {string} manifestDir
34+
* @returns {boolean}
35+
*/
36+
validateLockFile(manifestDir) {
37+
return fs.existsSync(path.join(manifestDir, this._lockFileName()))
38+
}
39+
40+
/**
41+
* Read project license from pyproject.toml, with fallback to LICENSE file.
42+
* @param {string} manifestPath
43+
* @returns {string|null}
44+
*/
45+
readLicenseFromManifest(manifestPath) {
46+
let fromManifest = null
47+
try {
48+
let content = fs.readFileSync(manifestPath, 'utf-8')
49+
let parsed = parseToml(content)
50+
fromManifest = parsed.project?.license
51+
if (typeof fromManifest === 'object' && fromManifest != null) {
52+
fromManifest = fromManifest.text || null
53+
}
54+
if (!fromManifest) {
55+
fromManifest = parsed.tool?.poetry?.license || null
56+
}
57+
} catch (_) {
58+
// leave fromManifest as null
59+
}
60+
return getLicense(fromManifest, manifestPath)
61+
}
62+
63+
/**
64+
* @param {string} manifest - path to pyproject.toml
65+
* @param {Object} [opts={}]
66+
* @returns {Promise<Provided>}
67+
*/
68+
async provideStack(manifest, opts = {}) {
69+
return {
70+
ecosystem,
71+
content: await this._createSbom(manifest, opts, true),
72+
contentType: 'application/vnd.cyclonedx+json'
73+
}
74+
}
75+
76+
/**
77+
* @param {string} manifest - path to pyproject.toml
78+
* @param {Object} [opts={}]
79+
* @returns {Promise<Provided>}
80+
*/
81+
async provideComponent(manifest, opts = {}) {
82+
return {
83+
ecosystem,
84+
content: await this._createSbom(manifest, opts, false),
85+
contentType: 'application/vnd.cyclonedx+json'
86+
}
87+
}
88+
89+
// --- abstract methods (subclasses must override) ---
90+
91+
/**
92+
* @returns {string}
93+
* @protected
94+
*/
95+
_lockFileName() {
96+
throw new TypeError('_lockFileName must be implemented')
97+
}
98+
99+
/**
100+
* @returns {string}
101+
* @protected
102+
*/
103+
_cmdName() {
104+
throw new TypeError('_cmdName must be implemented')
105+
}
106+
107+
/**
108+
* Resolve dependencies using the tool-specific command and parser.
109+
* @param {string} manifestDir
110+
* @param {object} parsed - parsed pyproject.toml
111+
* @param {Object} opts
112+
* @returns {Promise<DependencyData>}
113+
* @protected
114+
*/
115+
// eslint-disable-next-line no-unused-vars
116+
async _getDependencyData(manifestDir, parsed, opts) {
117+
throw new TypeError('_getDependencyData must be implemented')
118+
}
119+
120+
// --- shared helpers ---
121+
122+
/**
123+
* Canonicalize a Python package name per PEP 503.
124+
* @param {string} name
125+
* @returns {string}
126+
* @protected
127+
*/
128+
_canonicalize(name) {
129+
return name.toLowerCase().replace(/[-_.]+/g, '-')
130+
}
131+
132+
/**
133+
* Get the project name from pyproject.toml.
134+
* @param {object} parsed
135+
* @returns {string|null}
136+
* @protected
137+
*/
138+
_getProjectName(parsed) {
139+
return parsed.project?.name || parsed.tool?.poetry?.name || null
140+
}
141+
142+
/**
143+
* Get the project version from pyproject.toml.
144+
* @param {object} parsed
145+
* @returns {string|null}
146+
* @protected
147+
*/
148+
_getProjectVersion(parsed) {
149+
return parsed.project?.version || parsed.tool?.poetry?.version || null
150+
}
151+
152+
/**
153+
* Scan raw pyproject.toml text for dependencies with ignore markers.
154+
* @param {string} manifestPath
155+
* @returns {Set<string>}
156+
* @protected
157+
*/
158+
_getIgnoredDeps(manifestPath) {
159+
let ignored = new Set()
160+
let content = fs.readFileSync(manifestPath, 'utf-8')
161+
let lines = content.split(/\r?\n/)
162+
163+
for (let line of lines) {
164+
if (!IGNORE_MARKERS.some(m => line.includes(m))) { continue }
165+
166+
// PEP 621 style: "requests>=2.25" #exhortignore
167+
let pep621Match = line.match(/^\s*"([^"]+)"/)
168+
if (pep621Match) {
169+
let reqStr = pep621Match[1]
170+
let nameMatch = reqStr.match(/^([A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?)/)
171+
if (nameMatch) {
172+
ignored.add(this._canonicalize(nameMatch[1]))
173+
}
174+
continue
175+
}
176+
177+
// Poetry style: requests = "^2.25" #exhortignore
178+
let poetryMatch = line.match(/^\s*([A-Za-z0-9][A-Za-z0-9._-]*)\s*=/)
179+
if (poetryMatch) {
180+
ignored.add(this._canonicalize(poetryMatch[1]))
181+
}
182+
}
183+
184+
return ignored
185+
}
186+
187+
/**
188+
* Build dependency tree from graph, starting from direct deps.
189+
* @param {Map<string, GraphEntry>} graph
190+
* @param {string[]} directDeps - canonical names of direct deps
191+
* @param {Set<string>} ignoredDeps
192+
* @param {boolean} includeTransitive
193+
* @returns {DepTreeEntry[]}
194+
* @protected
195+
*/
196+
_buildDependencyTree(graph, directDeps, ignoredDeps, includeTransitive) {
197+
let result = []
198+
199+
for (let key of directDeps) {
200+
if (ignoredDeps.has(key)) { continue }
201+
202+
let entry = graph.get(key)
203+
if (!entry) { continue }
204+
205+
let depTree = []
206+
if (includeTransitive) {
207+
let visited = new Set()
208+
visited.add(key)
209+
this._collectTransitive(graph, entry.children, depTree, ignoredDeps, visited)
210+
}
211+
212+
result.push({ name: entry.name, version: entry.version, dependencies: depTree })
213+
}
214+
215+
result.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()))
216+
return result
217+
}
218+
219+
/**
220+
* Recursively collect transitive dependencies.
221+
* @param {Map<string, GraphEntry>} graph
222+
* @param {string[]} childKeys
223+
* @param {DepTreeEntry[]} result - mutated in place
224+
* @param {Set<string>} ignoredDeps
225+
* @param {Set<string>} visited
226+
* @returns {void}
227+
* @protected
228+
*/
229+
_collectTransitive(graph, childKeys, result, ignoredDeps, visited) {
230+
for (let childKey of childKeys) {
231+
let canonKey = this._canonicalize(childKey)
232+
if (ignoredDeps.has(canonKey)) { continue }
233+
if (visited.has(canonKey)) { continue }
234+
visited.add(canonKey)
235+
236+
let entry = graph.get(canonKey)
237+
if (!entry) { continue }
238+
239+
let childDeps = []
240+
this._collectTransitive(graph, entry.children, childDeps, ignoredDeps, visited)
241+
242+
result.push({ name: entry.name, version: entry.version, dependencies: childDeps })
243+
}
244+
245+
result.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()))
246+
}
247+
248+
/**
249+
* @param {string} name
250+
* @param {string} version
251+
* @returns {PackageURL}
252+
* @protected
253+
*/
254+
_toPurl(name, version) {
255+
return new PackageURL('pypi', undefined, name, version, undefined, undefined)
256+
}
257+
258+
/**
259+
* Recursively add a dependency and its transitive deps to the SBOM.
260+
* @param {PackageURL} source
261+
* @param {DepTreeEntry} dep
262+
* @param {Sbom} sbom
263+
* @returns {void}
264+
* @private
265+
*/
266+
_addAllDependencies(source, dep, sbom) {
267+
let targetPurl = this._toPurl(dep.name, dep.version)
268+
sbom.addDependency(source, targetPurl)
269+
if (dep.dependencies && dep.dependencies.length > 0) {
270+
dep.dependencies.forEach(child => this._addAllDependencies(this._toPurl(dep.name, dep.version), child, sbom))
271+
}
272+
}
273+
274+
/**
275+
* Create SBOM json string for a pyproject.toml project.
276+
* @param {string} manifest - path to pyproject.toml
277+
* @param {Object} opts
278+
* @param {boolean} includeTransitive
279+
* @returns {Promise<string>}
280+
* @private
281+
*/
282+
async _createSbom(manifest, opts, includeTransitive) {
283+
let manifestDir = path.dirname(manifest)
284+
let content = fs.readFileSync(manifest, 'utf-8')
285+
let parsed = parseToml(content)
286+
287+
let { directDeps, graph } = await this._getDependencyData(manifestDir, parsed, opts)
288+
289+
let ignoredDeps = this._getIgnoredDeps(manifest)
290+
let dependencies = this._buildDependencyTree(graph, directDeps, ignoredDeps, includeTransitive)
291+
292+
let sbom = new Sbom()
293+
let rootName = this._getProjectName(parsed) || DEFAULT_ROOT_NAME
294+
let rootVersion = this._getProjectVersion(parsed) || DEFAULT_ROOT_VERSION
295+
let rootPurl = this._toPurl(rootName, rootVersion)
296+
let license = this.readLicenseFromManifest(manifest)
297+
sbom.addRoot(rootPurl, license)
298+
299+
dependencies.forEach(dep => {
300+
if (includeTransitive) {
301+
this._addAllDependencies(rootPurl, dep, sbom)
302+
} else {
303+
sbom.addDependency(rootPurl, this._toPurl(dep.name, dep.version))
304+
}
305+
})
306+
307+
return sbom.getAsJsonString(opts)
308+
}
309+
}

0 commit comments

Comments
 (0)