Skip to content

Commit 76e1fef

Browse files
authored
feat(workspace): add uv workspace (pyproject.toml) discovery (#496)
1 parent f501753 commit 76e1fef

19 files changed

Lines changed: 218 additions & 5 deletions

File tree

src/index.js

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { resolveBatchMetadata, resolveContinueOnError } from './batch_opts.js'
1010
import { discoverMavenModules } from './providers/java_maven.js'
1111
import { discoverGradleSubprojects } from './providers/java_gradle.js'
1212
import { discoverGoWorkspaceModules } from './providers/golang_gomodules.js'
13+
import { discoverUvWorkspaceMembers } from './providers/python_uv.js'
1314
import {
1415
discoverWorkspaceCrates,
1516
discoverWorkspacePackages,
@@ -29,6 +30,7 @@ export {
2930
discoverMavenModules,
3031
discoverGradleSubprojects,
3132
discoverGoWorkspaceModules,
33+
discoverUvWorkspaceMembers,
3234
discoverWorkspacePackages,
3335
discoverWorkspaceCrates,
3436
validatePackageJson,
@@ -89,7 +91,7 @@ export {
8991
/**
9092
* @typedef {{
9193
* workspaceRoot: string,
92-
* ecosystem: 'javascript' | 'cargo' | 'unknown',
94+
* ecosystem: 'javascript' | 'cargo' | 'pyproject' | 'unknown',
9395
* total: number,
9496
* successful: number,
9597
* failed: number,
@@ -325,7 +327,7 @@ async function generateOneSbom(manifestPath, workspaceOpts) {
325327
*
326328
* @param {string} root - Resolved workspace root
327329
* @param {Options} opts
328-
* @returns {Promise<{ ecosystem: 'javascript' | 'cargo' | 'maven' | 'gradle' | 'gomodules' | 'unknown', manifestPaths: string[] }>}
330+
* @returns {Promise<{ ecosystem: 'javascript' | 'cargo' | 'maven' | 'gradle' | 'gomodules' | 'pyproject' | 'unknown', manifestPaths: string[] }>}
329331
* @private
330332
*/
331333
async function detectWorkspaceManifests(root, opts) {
@@ -361,6 +363,13 @@ async function detectWorkspaceManifests(root, opts) {
361363
}
362364
}
363365

366+
if (fs.existsSync(path.join(root, 'pyproject.toml')) && fs.existsSync(path.join(root, 'uv.lock'))) {
367+
const manifestPaths = await discoverUvWorkspaceMembers(root, opts)
368+
if (manifestPaths.length > 0) {
369+
return { ecosystem: 'pyproject', manifestPaths }
370+
}
371+
}
372+
364373
const hasJsLock = fs.existsSync(path.join(root, 'pnpm-lock.yaml'))
365374
|| fs.existsSync(path.join(root, 'yarn.lock'))
366375
|| fs.existsSync(path.join(root, 'package-lock.json'))
@@ -556,7 +565,7 @@ async function stackAnalysisBatch(workspaceRoot, html = false, opts = {}) {
556565
}
557566

558567
if (manifestPaths.length === 0) {
559-
throw new Error(`No workspace manifests found at ${root}. Ensure a supported workspace root exists (Cargo.toml+Cargo.lock, go.work, or package.json+lock file).`)
568+
throw new Error(`No workspace manifests found at ${root}. Ensure a supported workspace root exists (Cargo.toml+Cargo.lock, pom.xml, build.gradle, go.work, pyproject.toml+uv.lock, or package.json+lock file).`)
560569
}
561570

562571
const workspaceOpts = { ...opts, TRUSTIFY_DA_WORKSPACE_DIR: root }

src/providers/python_uv.js

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import fs from 'node:fs'
22
import path from 'node:path'
33

4+
import fg from 'fast-glob'
45
import { parse as parseToml } from 'smol-toml'
56

67
import { environmentVariableIsPopulated, getCustomPath, invokeCommand } from '../tools.js'
8+
import { filterManifestPathsByDiscoveryIgnore, resolveWorkspaceDiscoveryIgnore, toManifestGlobPatterns } from '../workspace.js'
79

810
import Base_pyproject from './base_pyproject.js'
911
import { evaluateMarker } from './marker_evaluator.js'
@@ -177,3 +179,81 @@ export default class Python_uv extends Base_pyproject {
177179
return { directDeps, graph }
178180
}
179181
}
182+
183+
const DEFAULT_UV_DISCOVERY_IGNORE = [
184+
'**/__pycache__/**',
185+
'**/.venv/**',
186+
]
187+
188+
/**
189+
* Discover all pyproject.toml manifest paths in a uv workspace.
190+
* Parses `[tool.uv.workspace]` from root pyproject.toml and glob-expands member patterns.
191+
*
192+
* @param {string} workspaceRoot - Absolute or relative path to workspace root (must contain pyproject.toml and uv.lock)
193+
* @param {{ workspaceDiscoveryIgnore?: string[], TRUSTIFY_DA_WORKSPACE_DISCOVERY_IGNORE?: string, [key: string]: unknown }} [opts={}]
194+
* @returns {Promise<string[]>} Paths to pyproject.toml files (absolute)
195+
*/
196+
export async function discoverUvWorkspaceMembers(workspaceRoot, opts = {}) {
197+
const root = path.resolve(workspaceRoot)
198+
const rootPyproject = path.join(root, 'pyproject.toml')
199+
const uvLock = path.join(root, 'uv.lock')
200+
201+
if (!fs.existsSync(rootPyproject) || !fs.existsSync(uvLock)) {
202+
return []
203+
}
204+
205+
let parsed
206+
try {
207+
parsed = parseToml(fs.readFileSync(rootPyproject, 'utf-8'))
208+
} catch {
209+
return []
210+
}
211+
212+
const workspaceConfig = parsed?.tool?.uv?.workspace
213+
if (!workspaceConfig) {
214+
return []
215+
}
216+
217+
const memberPatterns = workspaceConfig.members
218+
if (!Array.isArray(memberPatterns) || memberPatterns.length === 0) {
219+
return []
220+
}
221+
222+
const excludePatterns = Array.isArray(workspaceConfig.exclude) ? workspaceConfig.exclude : []
223+
const excludeGlobs = excludePatterns
224+
.filter(p => typeof p === 'string' && p.trim())
225+
.map(p => `${p.trim()}/pyproject.toml`)
226+
227+
const ignorePatterns = [...resolveWorkspaceDiscoveryIgnore(opts), ...DEFAULT_UV_DISCOVERY_IGNORE]
228+
const globOpts = {
229+
cwd: root,
230+
absolute: true,
231+
onlyFiles: true,
232+
ignore: [...ignorePatterns, ...excludeGlobs],
233+
followSymbolicLinks: false,
234+
}
235+
236+
const patterns = toManifestGlobPatterns(
237+
memberPatterns.filter(p => typeof p === 'string'),
238+
'pyproject.toml',
239+
)
240+
const manifestPaths = await fg(patterns, globOpts)
241+
242+
if (!manifestPaths.includes(rootPyproject) && hasProjectMetadata(parsed)) {
243+
manifestPaths.unshift(rootPyproject)
244+
}
245+
246+
return filterManifestPathsByDiscoveryIgnore(manifestPaths, root, ignorePatterns)
247+
}
248+
249+
/**
250+
* @param {import('smol-toml').TomlTable} parsedPyProject
251+
* @returns {boolean}
252+
*/
253+
function hasProjectMetadata(parsedPyProject) {
254+
try {
255+
return typeof parsedPyProject?.project?.name === 'string' && parsedPyProject.project.name.trim() !== ''
256+
} catch {
257+
return false
258+
}
259+
}

src/workspace.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ function parsePnpmPackages(content) {
184184
* @param {string} manifestFileName - e.g. 'package.json' or 'Cargo.toml'
185185
* @returns {string[]}
186186
*/
187-
function toManifestGlobPatterns(patterns, manifestFileName) {
187+
export function toManifestGlobPatterns(patterns, manifestFileName) {
188188
return patterns.map(p => {
189189
if (p.startsWith('!')) {
190190
return `!${p.slice(1)}/${manifestFileName}`
@@ -269,3 +269,4 @@ export async function discoverWorkspaceCrates(workspaceRoot, opts = {}) {
269269
const ignorePatterns = resolveWorkspaceDiscoveryIgnore(opts)
270270
return filterManifestPathsByDiscoveryIgnore(manifestPaths, root, ignorePatterns)
271271
}
272+
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
[project]
2+
name = "core"
3+
version = "0.1.0"
4+
dependencies = []
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
[project]
2+
name = "internal"
3+
version = "0.1.0"
4+
dependencies = []
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
[project]
2+
name = "exclude-workspace"
3+
version = "0.1.0"
4+
dependencies = []
5+
6+
[tool.uv.workspace]
7+
members = ["packages/*"]
8+
exclude = ["packages/internal"]

test/providers/tst_manifests/pyproject/uv_workspace_exclude/uv.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
[project]
2+
name = "backend"
3+
version = "0.1.0"
4+
dependencies = []
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
[project]
2+
name = "core"
3+
version = "0.1.0"
4+
dependencies = []
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
[project]
2+
name = "nested-workspace"
3+
version = "0.1.0"
4+
dependencies = []
5+
6+
[tool.uv.workspace]
7+
members = ["apps/*", "libs/*"]

0 commit comments

Comments
 (0)