Skip to content

Commit b0f7e04

Browse files
committed
feat(workspace): add uv workspace (pyproject.toml) discovery
1 parent 58200d2 commit b0f7e04

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
@@ -8,6 +8,7 @@ import fs from 'node:fs'
88
import { getCustom } from "./tools.js";
99
import { resolveBatchMetadata, resolveContinueOnError } from './batch_opts.js'
1010
import {
11+
discoverUvWorkspaceMembers,
1112
discoverWorkspaceCrates,
1213
discoverWorkspacePackages,
1314
filterManifestPathsByDiscoveryIgnore,
@@ -23,6 +24,7 @@ export { getProjectLicense, findLicenseFilePath, identifyLicense, getLicenseDeta
2324

2425
export default { componentAnalysis, stackAnalysis, stackAnalysisBatch, imageAnalysis, validateToken, generateSbom }
2526
export {
27+
discoverUvWorkspaceMembers,
2628
discoverWorkspacePackages,
2729
discoverWorkspaceCrates,
2830
validatePackageJson,
@@ -83,7 +85,7 @@ export {
8385
/**
8486
* @typedef {{
8587
* workspaceRoot: string,
86-
* ecosystem: 'javascript' | 'cargo' | 'unknown',
88+
* ecosystem: 'javascript' | 'cargo' | 'pyproject' | 'unknown',
8789
* total: number,
8890
* successful: number,
8991
* failed: number,
@@ -319,7 +321,7 @@ async function generateOneSbom(manifestPath, workspaceOpts) {
319321
*
320322
* @param {string} root - Resolved workspace root
321323
* @param {Options} opts
322-
* @returns {Promise<{ ecosystem: 'javascript' | 'cargo' | 'unknown', manifestPaths: string[] }>}
324+
* @returns {Promise<{ ecosystem: 'javascript' | 'cargo' | 'pyproject' | 'unknown', manifestPaths: string[] }>}
323325
* @private
324326
*/
325327
async function detectWorkspaceManifests(root, opts) {
@@ -331,6 +333,13 @@ async function detectWorkspaceManifests(root, opts) {
331333
return { ecosystem: 'cargo', manifestPaths: await discoverWorkspaceCrates(root, opts) }
332334
}
333335

336+
if (fs.existsSync(path.join(root, 'pyproject.toml')) && fs.existsSync(path.join(root, 'uv.lock'))) {
337+
const manifestPaths = await discoverUvWorkspaceMembers(root, opts)
338+
if (manifestPaths.length > 0) {
339+
return { ecosystem: 'pyproject', manifestPaths }
340+
}
341+
}
342+
334343
const hasJsLock = fs.existsSync(path.join(root, 'pnpm-lock.yaml'))
335344
|| fs.existsSync(path.join(root, 'yarn.lock'))
336345
|| fs.existsSync(path.join(root, 'package-lock.json'))
@@ -489,7 +498,7 @@ async function stackAnalysisBatch(workspaceRoot, html = false, opts = {}) {
489498
}
490499

491500
if (manifestPaths.length === 0) {
492-
throw new Error(`No workspace manifests found at ${root}. Ensure Cargo.toml+Cargo.lock or package.json+lock file exist.`)
501+
throw new Error(`No workspace manifests found at ${root}. Ensure a supported workspace root exists (Cargo.toml+Cargo.lock, pyproject.toml+uv.lock, or package.json+lock file).`)
493502
}
494503

495504
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)