Skip to content

Commit db2fe9e

Browse files
Strum355claude
andcommitted
feat(workspace): add uv workspace (pyproject.toml) discovery
Add discoverUvWorkspaceMembers() for discovering workspace members in uv/Python monorepos. Parses pyproject.toml for [tool.uv.workspace] member globs and resolves them via fast-glob. Requires both pyproject.toml with workspace config and uv.lock to be present. Handles virtual workspaces (no [project] section in root) by excluding the root pyproject.toml from discovered manifests. Respects uv's exclude patterns and the standard workspace discovery ignore mechanism. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 09597e1 commit db2fe9e

18 files changed

Lines changed: 208 additions & 3 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 {
1111
discoverGoWorkspaceModules,
1212
discoverGradleSubprojects,
1313
discoverMavenModules,
14+
discoverUvWorkspaceMembers,
1415
discoverWorkspaceCrates,
1516
discoverWorkspacePackages,
1617
filterManifestPathsByDiscoveryIgnore,
@@ -29,6 +30,7 @@ export {
2930
discoverGoWorkspaceModules,
3031
discoverGradleSubprojects,
3132
discoverMavenModules,
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' | 'maven' | 'gradle' | 'gomodules' | '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'))
@@ -519,7 +528,7 @@ async function stackAnalysisBatch(workspaceRoot, html = false, opts = {}) {
519528
}
520529

521530
if (manifestPaths.length === 0) {
522-
throw new Error(`No workspace manifests found at ${root}. Ensure a supported workspace root exists (Cargo.toml+Cargo.lock, pom.xml, settings.gradle[.kts], go.work, or package.json+lock file).`)
531+
throw new Error(`No workspace manifests found at ${root}. Ensure a supported workspace root exists (Cargo.toml+Cargo.lock, pom.xml, settings.gradle[.kts], go.work, pyproject.toml+uv.lock, or package.json+lock file).`)
523532
}
524533

525534
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
@@ -5,6 +5,7 @@ import path from 'node:path'
55
import fg from 'fast-glob'
66
import { load as yamlLoad } from 'js-yaml'
77
import micromatch from 'micromatch'
8+
import { parse as parseToml } from 'smol-toml'
89

910
import { getCustom, getCustomPath, getGitRootDir, getWrapperPreference, invokeCommand } from './tools.js'
1011

@@ -15,6 +16,8 @@ const DEFAULT_WORKSPACE_DISCOVERY_IGNORE = [
1516
'**/target/**',
1617
'**/build/**',
1718
'**/.gradle/**',
19+
'**/__pycache__/**',
20+
'**/.venv/**',
1821
]
1922

2023
/**
@@ -591,3 +594,71 @@ export async function discoverGoWorkspaceModules(workspaceRoot, opts = {}) {
591594
const ignorePatterns = resolveWorkspaceDiscoveryIgnore(opts)
592595
return filterManifestPathsByDiscoveryIgnore(manifestPaths, root, ignorePatterns)
593596
}
597+
598+
/**
599+
* Discover all pyproject.toml manifest paths in a uv workspace.
600+
* Parses `[tool.uv.workspace]` from root pyproject.toml and glob-expands member patterns.
601+
*
602+
* @param {string} workspaceRoot - Absolute or relative path to workspace root (must contain pyproject.toml and uv.lock)
603+
* @param {{ workspaceDiscoveryIgnore?: string[], TRUSTIFY_DA_WORKSPACE_DISCOVERY_IGNORE?: string, [key: string]: unknown }} [opts={}]
604+
* @returns {Promise<string[]>} Paths to pyproject.toml files (absolute)
605+
*/
606+
export async function discoverUvWorkspaceMembers(workspaceRoot, opts = {}) {
607+
const root = path.resolve(workspaceRoot)
608+
const rootPyproject = path.join(root, 'pyproject.toml')
609+
const uvLock = path.join(root, 'uv.lock')
610+
611+
if (!fs.existsSync(rootPyproject) || !fs.existsSync(uvLock)) {
612+
return []
613+
}
614+
615+
let parsed
616+
try {
617+
parsed = parseToml(fs.readFileSync(rootPyproject, 'utf-8'))
618+
} catch {
619+
return []
620+
}
621+
622+
const workspaceConfig = parsed?.tool?.uv?.workspace
623+
if (!workspaceConfig) {
624+
return []
625+
}
626+
627+
const memberPatterns = workspaceConfig.members
628+
if (!Array.isArray(memberPatterns) || memberPatterns.length === 0) {
629+
return []
630+
}
631+
632+
const excludePatterns = Array.isArray(workspaceConfig.exclude) ? workspaceConfig.exclude : []
633+
const excludeGlobs = excludePatterns
634+
.filter(p => typeof p === 'string' && p.trim())
635+
.map(p => `${p.trim()}/pyproject.toml`)
636+
637+
const ignorePatterns = resolveWorkspaceDiscoveryIgnore(opts)
638+
const globOpts = buildWorkspaceDiscoveryGlobOptions(root, [...ignorePatterns, ...excludeGlobs])
639+
640+
const patterns = toManifestGlobPatterns(
641+
memberPatterns.filter(p => typeof p === 'string'),
642+
'pyproject.toml',
643+
)
644+
const manifestPaths = await fg(patterns, globOpts)
645+
646+
if (!manifestPaths.includes(rootPyproject) && hasProjectMetadata(rootPyproject)) {
647+
manifestPaths.unshift(rootPyproject)
648+
}
649+
650+
return filterManifestPathsByDiscoveryIgnore(manifestPaths, root, ignorePatterns)
651+
}
652+
653+
/**
654+
* @param {string} pyprojectPath
655+
* @returns {boolean}
656+
*/
657+
function hasProjectMetadata(pyprojectPath) {
658+
try {
659+
const content = parseToml(fs.readFileSync(pyprojectPath, 'utf-8'))
660+
return typeof content?.project?.name === 'string' && content.project.name.trim() !== ''
661+
} catch {
662+
return false
663+
}
664+
}
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)