Skip to content

Commit 82eafe9

Browse files
Strum355ruromero
authored andcommitted
feat: add support for UV/poetry workspaces
Implements TC-4039
1 parent 38515a7 commit 82eafe9

20 files changed

Lines changed: 1549 additions & 5 deletions

src/providers/base_pyproject.js

Lines changed: 68 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { parse as parseToml } from 'smol-toml'
66

77
import { getLicense } from '../license/license_utils.js'
88
import Sbom from '../sbom.js'
9+
import { getCustom } from '../tools.js'
910

1011
const ecosystem = 'pip'
1112

@@ -31,10 +32,73 @@ export default class Base_pyproject {
3132

3233
/**
3334
* @param {string} manifestDir
35+
* @param {Object} [opts={}]
36+
* @returns {boolean}
37+
*/
38+
validateLockFile(manifestDir, opts = {}) {
39+
return this._findLockFileDir(manifestDir, opts) != null
40+
}
41+
42+
/**
43+
* Walk up from manifestDir to find the directory containing the lock file.
44+
* Follows the same pattern as Base_javascript._findLockFileDir().
45+
* @param {string} manifestDir
46+
* @param {Object} [opts={}]
47+
* @returns {string|null}
48+
* @protected
49+
*/
50+
_findLockFileDir(manifestDir, opts = {}) {
51+
const workspaceDir = getCustom('TRUSTIFY_DA_WORKSPACE_DIR', null, opts)
52+
if (workspaceDir) {
53+
const dir = path.resolve(workspaceDir)
54+
return fs.existsSync(path.join(dir, this._lockFileName())) ? dir : null
55+
}
56+
57+
let dir = path.resolve(manifestDir)
58+
let parent = dir
59+
60+
do {
61+
dir = parent
62+
63+
if (fs.existsSync(path.join(dir, this._lockFileName()))) {
64+
return dir
65+
}
66+
67+
if (this._isWorkspaceRoot(dir)) {
68+
return null
69+
}
70+
71+
parent = path.dirname(dir)
72+
} while (parent !== dir)
73+
74+
return null
75+
}
76+
77+
/**
78+
* Detect Python workspace root boundaries.
79+
* - uv: pyproject.toml with [tool.uv.workspace] section
80+
* - poetry: pyproject.toml with [tool.poetry] alongside poetry.lock
81+
* @param {string} dir
3482
* @returns {boolean}
83+
* @protected
3584
*/
36-
validateLockFile(manifestDir) {
37-
return fs.existsSync(path.join(manifestDir, this._lockFileName()))
85+
_isWorkspaceRoot(dir) {
86+
const pyprojectPath = path.join(dir, 'pyproject.toml')
87+
if (!fs.existsSync(pyprojectPath)) {
88+
return false
89+
}
90+
try {
91+
const content = parseToml(fs.readFileSync(pyprojectPath, 'utf-8'))
92+
if (content.tool?.uv?.workspace) {
93+
return true
94+
}
95+
if (content.tool?.poetry && fs.existsSync(path.join(dir, 'poetry.lock'))) {
96+
return true
97+
}
98+
} catch (_) {
99+
// ignore parse errors
100+
}
101+
return false
38102
}
39103

40104
/**
@@ -284,7 +348,8 @@ export default class Base_pyproject {
284348
let content = fs.readFileSync(manifest, 'utf-8')
285349
let parsed = parseToml(content)
286350

287-
let { directDeps, graph } = await this._getDependencyData(manifestDir, parsed, opts)
351+
let lockFileDir = this._findLockFileDir(manifestDir, opts) || manifestDir
352+
let { directDeps, graph } = await this._getDependencyData(lockFileDir, parsed, opts)
288353

289354
let ignoredDeps = this._getIgnoredDeps(manifest)
290355
let dependencies = this._buildDependencyTree(graph, directDeps, ignoredDeps, includeTransitive)

src/providers/python_uv.js

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
import fs from 'node:fs'
2+
import path from 'node:path'
3+
4+
import { parse as parseToml } from 'smol-toml'
5+
16
import { environmentVariableIsPopulated, getCustomPath, invokeCommand } from '../tools.js'
27

38
import Base_pyproject from './base_pyproject.js'
@@ -24,7 +29,7 @@ export default class Python_uv extends Base_pyproject {
2429
async _getDependencyData(manifestDir, parsed, opts) {
2530
let projectName = this._getProjectName(parsed)
2631
let uvOutput = this._getUvExportOutput(manifestDir, opts)
27-
return this._parseUvExport(uvOutput, projectName)
32+
return this._parseUvExport(uvOutput, projectName, manifestDir)
2833
}
2934

3035
/**
@@ -47,9 +52,10 @@ export default class Python_uv extends Base_pyproject {
4752
*
4853
* @param {string} output
4954
* @param {string} projectName - canonical project name to identify direct deps
55+
* @param {string} manifestDir - directory where uv export was run (for resolving editable installs)
5056
* @returns {Promise<{directDeps: string[], graph: Map<string, {name: string, version: string, children: string[]}>}>}
5157
*/
52-
async _parseUvExport(output, projectName) {
58+
async _parseUvExport(output, projectName, manifestDir) {
5359
let [parser, pinnedVersionQuery] = await Promise.all([
5460
getParser(), getPinnedVersionQuery()
5561
])
@@ -62,6 +68,30 @@ export default class Python_uv extends Base_pyproject {
6268
let collectingVia = false
6369

6470
for (let child of root.children) {
71+
if (child.type === 'global_opt') {
72+
let optNode = child.children.find(c => c.type === 'option')
73+
let pathNode = child.children.find(c => c.type === 'path')
74+
if (optNode?.text === '-e' && pathNode && manifestDir) {
75+
let memberDir = path.resolve(manifestDir, pathNode.text)
76+
let memberManifest = path.join(memberDir, 'pyproject.toml')
77+
if (fs.existsSync(memberManifest)) {
78+
let memberParsed = parseToml(fs.readFileSync(memberManifest, 'utf-8'))
79+
let name = memberParsed.project?.name || memberParsed.tool?.poetry?.name
80+
let version = memberParsed.project?.version || memberParsed.tool?.poetry?.version
81+
if (name && version) {
82+
let key = this._canonicalize(name)
83+
currentPkg = { name, version, parents: new Set() }
84+
packages.set(key, currentPkg)
85+
collectingVia = false
86+
continue
87+
}
88+
}
89+
}
90+
currentPkg = null
91+
collectingVia = false
92+
continue
93+
}
94+
6595
if (child.type === 'requirement') {
6696
let nameNode = child.children.find(c => c.type === 'package')
6797
if (!nameNode) { continue }

test/providers/python_pyproject.test.js

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import fs from 'fs'
2+
import path from 'path'
23

34
import { expect } from 'chai'
45
import { useFakeTimers } from 'sinon'
@@ -196,4 +197,187 @@ suite('testing the python-pyproject data provider', () => {
196197
}
197198
})
198199

200+
suite('workspace/monorepo support', () => {
201+
const uvWorkspace = 'test/providers/tst_manifests/pyproject/uv_workspace'
202+
const poetryWorkspace = 'test/providers/tst_manifests/pyproject/poetry_workspace'
203+
204+
test('uv validateLockFile finds uv.lock in parent directory', () => {
205+
expect(uvProvider.validateLockFile(
206+
path.join(uvWorkspace, 'packages/sub-pkg')
207+
)).to.equal(true)
208+
})
209+
210+
test('poetry validateLockFile finds poetry.lock in parent directory', () => {
211+
expect(poetryProvider.validateLockFile(
212+
path.join(poetryWorkspace, 'packages/sub-pkg')
213+
)).to.equal(true)
214+
})
215+
216+
test('validateLockFile stops at workspace root boundary when lock file is absent', () => {
217+
let tmpDir = 'test/providers/tst_manifests/pyproject/boundary_test'
218+
let subDir = path.join(tmpDir, 'packages', 'child')
219+
fs.mkdirSync(subDir, { recursive: true })
220+
// root has workspace marker but no lock file
221+
fs.writeFileSync(path.join(tmpDir, 'pyproject.toml'),
222+
'[tool.uv.workspace]\nmembers = ["packages/*"]\n')
223+
fs.writeFileSync(path.join(subDir, 'pyproject.toml'),
224+
'[project]\nname = "child"\nversion = "0.1.0"\n')
225+
try {
226+
expect(uvProvider.validateLockFile(subDir)).to.equal(false)
227+
} finally {
228+
fs.rmSync(tmpDir, { recursive: true, force: true })
229+
}
230+
})
231+
232+
test('poetry validateLockFile stops at workspace root boundary when lock file is absent', () => {
233+
let tmpDir = 'test/providers/tst_manifests/pyproject/boundary_test_poetry'
234+
let subDir = path.join(tmpDir, 'packages', 'child')
235+
fs.mkdirSync(subDir, { recursive: true })
236+
// root has [tool.poetry] + poetry.lock = workspace boundary
237+
// but we're testing a DIFFERENT poetry provider that looks for poetry.lock
238+
// The root IS the workspace root, and it HAS poetry.lock, so validateLockFile should return true
239+
// To test boundary stop: create a nested workspace inside another
240+
fs.writeFileSync(path.join(tmpDir, 'pyproject.toml'),
241+
'[tool.poetry]\nname = "root"\nversion = "0.1.0"\n')
242+
fs.writeFileSync(path.join(tmpDir, 'poetry.lock'), '')
243+
fs.writeFileSync(path.join(subDir, 'pyproject.toml'),
244+
'[tool.poetry]\nname = "child"\nversion = "0.1.0"\n')
245+
try {
246+
// poetry.lock exists at root, so walk-up should find it
247+
expect(poetryProvider.validateLockFile(subDir)).to.equal(true)
248+
249+
// Now remove poetry.lock — root still has [tool.poetry] but no lock file
250+
// _isWorkspaceRoot checks for [tool.poetry] + poetry.lock,
251+
// so without poetry.lock it won't be a boundary
252+
fs.unlinkSync(path.join(tmpDir, 'poetry.lock'))
253+
expect(poetryProvider.validateLockFile(subDir)).to.equal(false)
254+
} finally {
255+
fs.rmSync(tmpDir, { recursive: true, force: true })
256+
}
257+
})
258+
259+
test('TRUSTIFY_DA_WORKSPACE_DIR override directs lock file search', () => {
260+
let overrideDir = path.resolve(uvWorkspace)
261+
expect(uvProvider.validateLockFile(
262+
'test/providers/tst_manifests/pyproject/poetry_lock',
263+
{ TRUSTIFY_DA_WORKSPACE_DIR: overrideDir }
264+
)).to.equal(true)
265+
266+
expect(uvProvider.validateLockFile(
267+
'test/providers/tst_manifests/pyproject/poetry_lock',
268+
{ TRUSTIFY_DA_WORKSPACE_DIR: '/nonexistent/dir' }
269+
)).to.equal(false)
270+
})
271+
272+
test('verify uv workspace root stack analysis', async () => {
273+
let expectedSbom = fs.readFileSync(path.join(uvWorkspace, 'expected_stack_sbom.json')).toString()
274+
expectedSbom = JSON.stringify(JSON.parse(expectedSbom))
275+
let result = await uvProvider.provideStack(path.join(uvWorkspace, 'pyproject.toml'))
276+
expect(result).to.deep.equal({
277+
ecosystem: 'pip',
278+
contentType: 'application/vnd.cyclonedx+json',
279+
content: expectedSbom
280+
})
281+
}).timeout(TIMEOUT)
282+
283+
test('verify uv workspace root component analysis', async () => {
284+
let expectedSbom = fs.readFileSync(path.join(uvWorkspace, 'expected_component_sbom.json')).toString().trim()
285+
expectedSbom = JSON.stringify(JSON.parse(expectedSbom))
286+
let result = await uvProvider.provideComponent(path.join(uvWorkspace, 'pyproject.toml'))
287+
expect(result).to.deep.equal({
288+
ecosystem: 'pip',
289+
contentType: 'application/vnd.cyclonedx+json',
290+
content: expectedSbom
291+
})
292+
}).timeout(TIMEOUT)
293+
294+
test('verify uv workspace mid-package stack analysis', async () => {
295+
let expectedSbom = fs.readFileSync(path.join(uvWorkspace, 'packages/mid-pkg/expected_stack_sbom.json')).toString()
296+
expectedSbom = JSON.stringify(JSON.parse(expectedSbom))
297+
let result = await uvProvider.provideStack(path.join(uvWorkspace, 'packages/mid-pkg/pyproject.toml'))
298+
expect(result).to.deep.equal({
299+
ecosystem: 'pip',
300+
contentType: 'application/vnd.cyclonedx+json',
301+
content: expectedSbom
302+
})
303+
}).timeout(TIMEOUT)
304+
305+
test('verify uv workspace mid-package component analysis', async () => {
306+
let expectedSbom = fs.readFileSync(path.join(uvWorkspace, 'packages/mid-pkg/expected_component_sbom.json')).toString().trim()
307+
expectedSbom = JSON.stringify(JSON.parse(expectedSbom))
308+
let result = await uvProvider.provideComponent(path.join(uvWorkspace, 'packages/mid-pkg/pyproject.toml'))
309+
expect(result).to.deep.equal({
310+
ecosystem: 'pip',
311+
contentType: 'application/vnd.cyclonedx+json',
312+
content: expectedSbom
313+
})
314+
}).timeout(TIMEOUT)
315+
316+
test('verify uv workspace sub-package stack analysis', async () => {
317+
let expectedSbom = fs.readFileSync(path.join(uvWorkspace, 'packages/sub-pkg/expected_stack_sbom.json')).toString()
318+
expectedSbom = JSON.stringify(JSON.parse(expectedSbom))
319+
let result = await uvProvider.provideStack(path.join(uvWorkspace, 'packages/sub-pkg/pyproject.toml'))
320+
expect(result).to.deep.equal({
321+
ecosystem: 'pip',
322+
contentType: 'application/vnd.cyclonedx+json',
323+
content: expectedSbom
324+
})
325+
}).timeout(TIMEOUT)
326+
327+
test('verify uv workspace sub-package component analysis', async () => {
328+
let expectedSbom = fs.readFileSync(path.join(uvWorkspace, 'packages/sub-pkg/expected_component_sbom.json')).toString().trim()
329+
expectedSbom = JSON.stringify(JSON.parse(expectedSbom))
330+
let result = await uvProvider.provideComponent(path.join(uvWorkspace, 'packages/sub-pkg/pyproject.toml'))
331+
expect(result).to.deep.equal({
332+
ecosystem: 'pip',
333+
contentType: 'application/vnd.cyclonedx+json',
334+
content: expectedSbom
335+
})
336+
}).timeout(TIMEOUT)
337+
338+
test('verify poetry workspace root stack analysis', async () => {
339+
let expectedSbom = fs.readFileSync(path.join(poetryWorkspace, 'expected_stack_sbom.json')).toString()
340+
expectedSbom = JSON.stringify(JSON.parse(expectedSbom))
341+
let result = await poetryProvider.provideStack(path.join(poetryWorkspace, 'pyproject.toml'))
342+
expect(result).to.deep.equal({
343+
ecosystem: 'pip',
344+
contentType: 'application/vnd.cyclonedx+json',
345+
content: expectedSbom
346+
})
347+
}).timeout(TIMEOUT)
348+
349+
test('verify poetry workspace root component analysis', async () => {
350+
let expectedSbom = fs.readFileSync(path.join(poetryWorkspace, 'expected_component_sbom.json')).toString().trim()
351+
expectedSbom = JSON.stringify(JSON.parse(expectedSbom))
352+
let result = await poetryProvider.provideComponent(path.join(poetryWorkspace, 'pyproject.toml'))
353+
expect(result).to.deep.equal({
354+
ecosystem: 'pip',
355+
contentType: 'application/vnd.cyclonedx+json',
356+
content: expectedSbom
357+
})
358+
}).timeout(TIMEOUT)
359+
360+
test('verify poetry workspace sub-package stack analysis', async () => {
361+
let expectedSbom = fs.readFileSync(path.join(poetryWorkspace, 'packages/sub-pkg/expected_stack_sbom.json')).toString()
362+
expectedSbom = JSON.stringify(JSON.parse(expectedSbom))
363+
let result = await poetryProvider.provideStack(path.join(poetryWorkspace, 'packages/sub-pkg/pyproject.toml'))
364+
expect(result).to.deep.equal({
365+
ecosystem: 'pip',
366+
contentType: 'application/vnd.cyclonedx+json',
367+
content: expectedSbom
368+
})
369+
}).timeout(TIMEOUT)
370+
371+
test('verify poetry workspace sub-package component analysis', async () => {
372+
let expectedSbom = fs.readFileSync(path.join(poetryWorkspace, 'packages/sub-pkg/expected_component_sbom.json')).toString().trim()
373+
expectedSbom = JSON.stringify(JSON.parse(expectedSbom))
374+
let result = await poetryProvider.provideComponent(path.join(poetryWorkspace, 'packages/sub-pkg/pyproject.toml'))
375+
expect(result).to.deep.equal({
376+
ecosystem: 'pip',
377+
contentType: 'application/vnd.cyclonedx+json',
378+
content: expectedSbom
379+
})
380+
}).timeout(TIMEOUT)
381+
})
382+
199383
}).beforeAll(() => clock = useFakeTimers(new Date('2023-10-01T00:00:00.000Z'))).afterAll(() => clock.restore())
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": "poetry-mono",
9+
"version": "0.1.0",
10+
"purl": "pkg:pypi/poetry-mono@0.1.0",
11+
"type": "application",
12+
"bom-ref": "pkg:pypi/poetry-mono@0.1.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/poetry-mono@0.1.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)