Skip to content

Commit c62aadf

Browse files
committed
feat(workspace): add uv workspace (pyproject.toml) discovery
1 parent 848421d commit c62aadf

18 files changed

Lines changed: 207 additions & 4 deletions

File tree

src/index.js

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { discoverMavenModules } from './providers/java_maven.js'
1111
import { discoverGradleSubprojects } from './providers/java_gradle.js'
1212
import { discoverGoWorkspaceModules } from './providers/golang_gomodules.js'
1313
import {
14+
discoverUvWorkspaceMembers,
1415
discoverWorkspaceCrates,
1516
discoverWorkspacePackages,
1617
filterManifestPathsByDiscoveryIgnore,
@@ -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/workspace.js

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,16 @@ import path from 'node:path'
44
import fg from 'fast-glob'
55
import { load as yamlLoad } from 'js-yaml'
66
import micromatch from 'micromatch'
7+
import { parse as parseToml } from 'smol-toml'
78

89
import { getCustom, getCustomPath, invokeCommand } from './tools.js'
910

1011
/** Default paths skipped during JS workspace discovery (merged with user patterns). */
1112
const DEFAULT_WORKSPACE_DISCOVERY_IGNORE = [
1213
'**/node_modules/**',
1314
'**/.git/**',
15+
'**/__pycache__/**',
16+
'**/.venv/**',
1417
]
1518

1619
/**
@@ -269,3 +272,71 @@ export async function discoverWorkspaceCrates(workspaceRoot, opts = {}) {
269272
const ignorePatterns = resolveWorkspaceDiscoveryIgnore(opts)
270273
return filterManifestPathsByDiscoveryIgnore(manifestPaths, root, ignorePatterns)
271274
}
275+
276+
/**
277+
* Discover all pyproject.toml manifest paths in a uv workspace.
278+
* Parses `[tool.uv.workspace]` from root pyproject.toml and glob-expands member patterns.
279+
*
280+
* @param {string} workspaceRoot - Absolute or relative path to workspace root (must contain pyproject.toml and uv.lock)
281+
* @param {{ workspaceDiscoveryIgnore?: string[], TRUSTIFY_DA_WORKSPACE_DISCOVERY_IGNORE?: string, [key: string]: unknown }} [opts={}]
282+
* @returns {Promise<string[]>} Paths to pyproject.toml files (absolute)
283+
*/
284+
export async function discoverUvWorkspaceMembers(workspaceRoot, opts = {}) {
285+
const root = path.resolve(workspaceRoot)
286+
const rootPyproject = path.join(root, 'pyproject.toml')
287+
const uvLock = path.join(root, 'uv.lock')
288+
289+
if (!fs.existsSync(rootPyproject) || !fs.existsSync(uvLock)) {
290+
return []
291+
}
292+
293+
let parsed
294+
try {
295+
parsed = parseToml(fs.readFileSync(rootPyproject, 'utf-8'))
296+
} catch {
297+
return []
298+
}
299+
300+
const workspaceConfig = parsed?.tool?.uv?.workspace
301+
if (!workspaceConfig) {
302+
return []
303+
}
304+
305+
const memberPatterns = workspaceConfig.members
306+
if (!Array.isArray(memberPatterns) || memberPatterns.length === 0) {
307+
return []
308+
}
309+
310+
const excludePatterns = Array.isArray(workspaceConfig.exclude) ? workspaceConfig.exclude : []
311+
const excludeGlobs = excludePatterns
312+
.filter(p => typeof p === 'string' && p.trim())
313+
.map(p => `${p.trim()}/pyproject.toml`)
314+
315+
const ignorePatterns = resolveWorkspaceDiscoveryIgnore(opts)
316+
const globOpts = buildWorkspaceDiscoveryGlobOptions(root, [...ignorePatterns, ...excludeGlobs])
317+
318+
const patterns = toManifestGlobPatterns(
319+
memberPatterns.filter(p => typeof p === 'string'),
320+
'pyproject.toml',
321+
)
322+
const manifestPaths = await fg(patterns, globOpts)
323+
324+
if (!manifestPaths.includes(rootPyproject) && hasProjectMetadata(rootPyproject)) {
325+
manifestPaths.unshift(rootPyproject)
326+
}
327+
328+
return filterManifestPathsByDiscoveryIgnore(manifestPaths, root, ignorePatterns)
329+
}
330+
331+
/**
332+
* @param {string} pyprojectPath
333+
* @returns {boolean}
334+
*/
335+
function hasProjectMetadata(pyprojectPath) {
336+
try {
337+
const content = parseToml(fs.readFileSync(pyprojectPath, 'utf-8'))
338+
return typeof content?.project?.name === 'string' && content.project.name.trim() !== ''
339+
} catch {
340+
return false
341+
}
342+
}
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/*"]

test/providers/tst_manifests/pyproject/uv_workspace_nested/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.

0 commit comments

Comments
 (0)