Skip to content

Commit dcb2120

Browse files
authored
feat: add support for UV workspaces (#461)
Implements TC-4039
1 parent c2f6c64 commit dcb2120

14 files changed

Lines changed: 1053 additions & 12 deletions

src/providers/base_pyproject.js

Lines changed: 69 additions & 5 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,71 @@ 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 workspace root boundaries.
79+
* Currently only uv has native workspace support ([tool.uv.workspace] in pyproject.toml).
80+
* Poetry has no workspace/monorepo support (python-poetry/poetry#2270), so each
81+
* poetry project is treated independently — see Python_poetry._findLockFileDir().
82+
* @param {string} dir
3483
* @returns {boolean}
84+
* @protected
3585
*/
36-
validateLockFile(manifestDir) {
37-
return fs.existsSync(path.join(manifestDir, this._lockFileName()))
86+
_isWorkspaceRoot(dir) {
87+
const pyprojectPath = path.join(dir, 'pyproject.toml')
88+
if (!fs.existsSync(pyprojectPath)) {
89+
return false
90+
}
91+
try {
92+
const content = parseToml(fs.readFileSync(pyprojectPath, 'utf-8'))
93+
if (content.tool?.uv?.workspace) {
94+
return true
95+
}
96+
} catch (_) {
97+
// ignore parse errors
98+
}
99+
return false
38100
}
39101

40102
/**
@@ -106,14 +168,15 @@ export default class Base_pyproject {
106168

107169
/**
108170
* Resolve dependencies using the tool-specific command and parser.
109-
* @param {string} manifestDir
171+
* @param {string} manifestDir - directory containing the target pyproject.toml
172+
* @param {string} workspaceDir - workspace root (where the lock file lives), or same as manifestDir for standalone projects
110173
* @param {object} parsed - parsed pyproject.toml
111174
* @param {Object} opts
112175
* @returns {Promise<DependencyData>}
113176
* @protected
114177
*/
115178
// eslint-disable-next-line no-unused-vars
116-
async _getDependencyData(manifestDir, parsed, opts) {
179+
async _getDependencyData(manifestDir, workspaceDir, parsed, opts) {
117180
throw new TypeError('_getDependencyData must be implemented')
118181
}
119182

@@ -284,7 +347,8 @@ export default class Base_pyproject {
284347
let content = fs.readFileSync(manifest, 'utf-8')
285348
let parsed = parseToml(content)
286349

287-
let { directDeps, graph } = await this._getDependencyData(manifestDir, parsed, opts)
350+
let workspaceDir = this._findLockFileDir(manifestDir, opts) || manifestDir
351+
let { directDeps, graph } = await this._getDependencyData(manifestDir, workspaceDir, parsed, opts)
288352

289353
let ignoredDeps = this._getIgnoredDeps(manifest)
290354
let dependencies = this._buildDependencyTree(graph, directDeps, ignoredDeps, includeTransitive)

src/providers/python_poetry.js

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,32 @@
1-
import { environmentVariableIsPopulated, getCustomPath, invokeCommand } from '../tools.js'
1+
import fs from 'node:fs'
2+
import path from 'node:path'
3+
4+
import { environmentVariableIsPopulated, getCustom, getCustomPath, invokeCommand } from '../tools.js'
25

36
import Base_pyproject from './base_pyproject.js'
47

58
export default class Python_poetry extends Base_pyproject {
69

10+
/**
11+
* Poetry has no native workspace/monorepo support (python-poetry/poetry#2270).
12+
* Each poetry project is treated independently — no lock file walk-up.
13+
* Running `poetry show` from a parent directory returns the parent's deps, not
14+
* the sub-package's, so walk-up would produce incorrect SBOMs.
15+
* @param {string} manifestDir
16+
* @param {Object} [opts={}]
17+
* @returns {string|null}
18+
* @protected
19+
*/
20+
_findLockFileDir(manifestDir, opts = {}) {
21+
const workspaceDir = getCustom('TRUSTIFY_DA_WORKSPACE_DIR', null, opts)
22+
if (workspaceDir) {
23+
const dir = path.resolve(workspaceDir)
24+
return fs.existsSync(path.join(dir, this._lockFileName())) ? dir : null
25+
}
26+
const dir = path.resolve(manifestDir)
27+
return fs.existsSync(path.join(dir, this._lockFileName())) ? dir : null
28+
}
29+
730
/** @returns {string} */
831
_lockFileName() {
932
return 'poetry.lock'
@@ -16,11 +39,13 @@ export default class Python_poetry extends Base_pyproject {
1639

1740
/**
1841
* @param {string} manifestDir
42+
* @param {string} _workspaceDir - unused (poetry has no workspace support)
1943
* @param {object} parsed - parsed pyproject.toml
2044
* @param {Object} opts
2145
* @returns {Promise<{directDeps: string[], graph: Map<string, {name: string, version: string, children: string[]}>}>}
2246
*/
23-
async _getDependencyData(manifestDir, parsed, opts) {
47+
// eslint-disable-next-line no-unused-vars
48+
async _getDependencyData(manifestDir, _workspaceDir, parsed, opts) {
2449
let treeOutput = this._getPoetryShowTreeOutput(manifestDir, opts)
2550
let showAllOutput = this._getPoetryShowAllOutput(manifestDir, opts)
2651
let versionMap = this._parsePoetryShowAll(showAllOutput)
@@ -97,7 +122,7 @@ export default class Python_poetry extends Base_pyproject {
97122
if (!line.trim()) { continue }
98123

99124
// top-level line: "name version description..."
100-
let topMatch = line.match(/^([A-Za-z0-9][A-Za-z0-9._-]*)\s+(\S+)\s/)
125+
let topMatch = line.match(/^([A-Za-z0-9][A-Za-z0-9._-]*)\s+(\S+)(?:\s|$)/)
101126
if (topMatch) {
102127
let name = topMatch[1]
103128
let version = topMatch[2]

src/providers/python_uv.js

Lines changed: 35 additions & 4 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'
@@ -16,15 +21,16 @@ export default class Python_uv extends Base_pyproject {
1621
}
1722

1823
/**
19-
* @param {string} manifestDir
24+
* @param {string} manifestDir - directory containing the target pyproject.toml
25+
* @param {string} workspaceDir - workspace root (for resolving editable install paths)
2026
* @param {object} parsed - parsed pyproject.toml
2127
* @param {Object} opts
2228
* @returns {Promise<{directDeps: string[], graph: Map<string, {name: string, version: string, children: string[]}>}>}
2329
*/
24-
async _getDependencyData(manifestDir, parsed, opts) {
30+
async _getDependencyData(manifestDir, workspaceDir, parsed, opts) {
2531
let projectName = this._getProjectName(parsed)
2632
let uvOutput = this._getUvExportOutput(manifestDir, opts)
27-
return this._parseUvExport(uvOutput, projectName)
33+
return this._parseUvExport(uvOutput, projectName, workspaceDir)
2834
}
2935

3036
/**
@@ -47,9 +53,10 @@ export default class Python_uv extends Base_pyproject {
4753
*
4854
* @param {string} output
4955
* @param {string} projectName - canonical project name to identify direct deps
56+
* @param {string} workspaceDir - workspace root (for resolving editable install paths)
5057
* @returns {Promise<{directDeps: string[], graph: Map<string, {name: string, version: string, children: string[]}>}>}
5158
*/
52-
async _parseUvExport(output, projectName) {
59+
async _parseUvExport(output, projectName, workspaceDir) {
5360
let [parser, pinnedVersionQuery] = await Promise.all([
5461
getParser(), getPinnedVersionQuery()
5562
])
@@ -62,6 +69,30 @@ export default class Python_uv extends Base_pyproject {
6269
let collectingVia = false
6370

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

test/providers/python_pyproject.test.js

Lines changed: 125 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,128 @@ 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+
203+
test('uv validateLockFile finds uv.lock in parent directory', () => {
204+
expect(uvProvider.validateLockFile(
205+
path.join(uvWorkspace, 'packages/sub-pkg')
206+
)).to.equal(true)
207+
})
208+
209+
test('poetry validateLockFile does not walk up to parent directory', () => {
210+
// Poetry has no native workspace support (python-poetry/poetry#2270).
211+
// Each poetry project is treated independently — no lock file walk-up.
212+
let tmpDir = 'test/providers/tst_manifests/pyproject/boundary_test_poetry'
213+
let subDir = path.join(tmpDir, 'packages', 'child')
214+
fs.mkdirSync(subDir, { recursive: true })
215+
fs.writeFileSync(path.join(tmpDir, 'pyproject.toml'),
216+
'[tool.poetry]\nname = "root"\nversion = "0.1.0"\n')
217+
fs.writeFileSync(path.join(tmpDir, 'poetry.lock'), '')
218+
fs.writeFileSync(path.join(subDir, 'pyproject.toml'),
219+
'[tool.poetry]\nname = "child"\nversion = "0.1.0"\n')
220+
try {
221+
// poetry.lock exists at root but poetry should NOT walk up
222+
expect(poetryProvider.validateLockFile(subDir)).to.equal(false)
223+
} finally {
224+
fs.rmSync(tmpDir, { recursive: true, force: true })
225+
}
226+
})
227+
228+
test('validateLockFile stops at uv workspace root boundary when lock file is absent', () => {
229+
let tmpDir = 'test/providers/tst_manifests/pyproject/boundary_test'
230+
let subDir = path.join(tmpDir, 'packages', 'child')
231+
fs.mkdirSync(subDir, { recursive: true })
232+
// root has workspace marker but no lock file
233+
fs.writeFileSync(path.join(tmpDir, 'pyproject.toml'),
234+
'[tool.uv.workspace]\nmembers = ["packages/*"]\n')
235+
fs.writeFileSync(path.join(subDir, 'pyproject.toml'),
236+
'[project]\nname = "child"\nversion = "0.1.0"\n')
237+
try {
238+
expect(uvProvider.validateLockFile(subDir)).to.equal(false)
239+
} finally {
240+
fs.rmSync(tmpDir, { recursive: true, force: true })
241+
}
242+
})
243+
244+
test('TRUSTIFY_DA_WORKSPACE_DIR override directs lock file search', () => {
245+
let overrideDir = path.resolve(uvWorkspace)
246+
expect(uvProvider.validateLockFile(
247+
'test/providers/tst_manifests/pyproject/poetry_lock',
248+
{ TRUSTIFY_DA_WORKSPACE_DIR: overrideDir }
249+
)).to.equal(true)
250+
251+
expect(uvProvider.validateLockFile(
252+
'test/providers/tst_manifests/pyproject/poetry_lock',
253+
{ TRUSTIFY_DA_WORKSPACE_DIR: '/nonexistent/dir' }
254+
)).to.equal(false)
255+
})
256+
257+
test('verify uv workspace root stack analysis', async () => {
258+
let expectedSbom = fs.readFileSync(path.join(uvWorkspace, 'expected_stack_sbom.json')).toString()
259+
expectedSbom = JSON.stringify(JSON.parse(expectedSbom))
260+
let result = await uvProvider.provideStack(path.join(uvWorkspace, 'pyproject.toml'))
261+
expect(result).to.deep.equal({
262+
ecosystem: 'pip',
263+
contentType: 'application/vnd.cyclonedx+json',
264+
content: expectedSbom
265+
})
266+
}).timeout(TIMEOUT)
267+
268+
test('verify uv workspace root component analysis', async () => {
269+
let expectedSbom = fs.readFileSync(path.join(uvWorkspace, 'expected_component_sbom.json')).toString().trim()
270+
expectedSbom = JSON.stringify(JSON.parse(expectedSbom))
271+
let result = await uvProvider.provideComponent(path.join(uvWorkspace, 'pyproject.toml'))
272+
expect(result).to.deep.equal({
273+
ecosystem: 'pip',
274+
contentType: 'application/vnd.cyclonedx+json',
275+
content: expectedSbom
276+
})
277+
}).timeout(TIMEOUT)
278+
279+
test('verify uv workspace mid-package stack analysis', async () => {
280+
let expectedSbom = fs.readFileSync(path.join(uvWorkspace, 'packages/mid-pkg/expected_stack_sbom.json')).toString()
281+
expectedSbom = JSON.stringify(JSON.parse(expectedSbom))
282+
let result = await uvProvider.provideStack(path.join(uvWorkspace, 'packages/mid-pkg/pyproject.toml'))
283+
expect(result).to.deep.equal({
284+
ecosystem: 'pip',
285+
contentType: 'application/vnd.cyclonedx+json',
286+
content: expectedSbom
287+
})
288+
}).timeout(TIMEOUT)
289+
290+
test('verify uv workspace mid-package component analysis', async () => {
291+
let expectedSbom = fs.readFileSync(path.join(uvWorkspace, 'packages/mid-pkg/expected_component_sbom.json')).toString().trim()
292+
expectedSbom = JSON.stringify(JSON.parse(expectedSbom))
293+
let result = await uvProvider.provideComponent(path.join(uvWorkspace, 'packages/mid-pkg/pyproject.toml'))
294+
expect(result).to.deep.equal({
295+
ecosystem: 'pip',
296+
contentType: 'application/vnd.cyclonedx+json',
297+
content: expectedSbom
298+
})
299+
}).timeout(TIMEOUT)
300+
301+
test('verify uv workspace sub-package stack analysis', async () => {
302+
let expectedSbom = fs.readFileSync(path.join(uvWorkspace, 'packages/sub-pkg/expected_stack_sbom.json')).toString()
303+
expectedSbom = JSON.stringify(JSON.parse(expectedSbom))
304+
let result = await uvProvider.provideStack(path.join(uvWorkspace, 'packages/sub-pkg/pyproject.toml'))
305+
expect(result).to.deep.equal({
306+
ecosystem: 'pip',
307+
contentType: 'application/vnd.cyclonedx+json',
308+
content: expectedSbom
309+
})
310+
}).timeout(TIMEOUT)
311+
312+
test('verify uv workspace sub-package component analysis', async () => {
313+
let expectedSbom = fs.readFileSync(path.join(uvWorkspace, 'packages/sub-pkg/expected_component_sbom.json')).toString().trim()
314+
expectedSbom = JSON.stringify(JSON.parse(expectedSbom))
315+
let result = await uvProvider.provideComponent(path.join(uvWorkspace, 'packages/sub-pkg/pyproject.toml'))
316+
expect(result).to.deep.equal({
317+
ecosystem: 'pip',
318+
contentType: 'application/vnd.cyclonedx+json',
319+
content: expectedSbom
320+
})
321+
}).timeout(TIMEOUT)
322+
})
323+
199324
}).beforeAll(() => clock = useFakeTimers(new Date('2023-10-01T00:00:00.000Z'))).afterAll(() => clock.restore())

0 commit comments

Comments
 (0)