Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { resolveBatchMetadata, resolveContinueOnError } from './batch_opts.js'
import { discoverMavenModules } from './providers/java_maven.js'
import { discoverGradleSubprojects } from './providers/java_gradle.js'
import { discoverGoWorkspaceModules } from './providers/golang_gomodules.js'
import { discoverUvWorkspaceMembers } from './providers/python_uv.js'
import {
discoverWorkspaceCrates,
discoverWorkspacePackages,
Expand All @@ -29,6 +30,7 @@ export {
discoverMavenModules,
discoverGradleSubprojects,
discoverGoWorkspaceModules,
discoverUvWorkspaceMembers,
discoverWorkspacePackages,
discoverWorkspaceCrates,
validatePackageJson,
Expand Down Expand Up @@ -89,7 +91,7 @@ export {
/**
* @typedef {{
* workspaceRoot: string,
* ecosystem: 'javascript' | 'cargo' | 'unknown',
* ecosystem: 'javascript' | 'cargo' | 'pyproject' | 'unknown',
* total: number,
* successful: number,
* failed: number,
Expand Down Expand Up @@ -325,7 +327,7 @@ async function generateOneSbom(manifestPath, workspaceOpts) {
*
* @param {string} root - Resolved workspace root
* @param {Options} opts
* @returns {Promise<{ ecosystem: 'javascript' | 'cargo' | 'maven' | 'gradle' | 'gomodules' | 'unknown', manifestPaths: string[] }>}
* @returns {Promise<{ ecosystem: 'javascript' | 'cargo' | 'maven' | 'gradle' | 'gomodules' | 'pyproject' | 'unknown', manifestPaths: string[] }>}
* @private
*/
async function detectWorkspaceManifests(root, opts) {
Expand Down Expand Up @@ -361,6 +363,13 @@ async function detectWorkspaceManifests(root, opts) {
}
}

if (fs.existsSync(path.join(root, 'pyproject.toml')) && fs.existsSync(path.join(root, 'uv.lock'))) {
const manifestPaths = await discoverUvWorkspaceMembers(root, opts)
if (manifestPaths.length > 0) {
return { ecosystem: 'pyproject', manifestPaths }
}
}

const hasJsLock = fs.existsSync(path.join(root, 'pnpm-lock.yaml'))
|| fs.existsSync(path.join(root, 'yarn.lock'))
|| fs.existsSync(path.join(root, 'package-lock.json'))
Expand Down Expand Up @@ -556,7 +565,7 @@ async function stackAnalysisBatch(workspaceRoot, html = false, opts = {}) {
}

if (manifestPaths.length === 0) {
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).`)
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).`)
}

const workspaceOpts = { ...opts, TRUSTIFY_DA_WORKSPACE_DIR: root }
Expand Down
80 changes: 80 additions & 0 deletions src/providers/python_uv.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import fs from 'node:fs'
import path from 'node:path'

import fg from 'fast-glob'
import { parse as parseToml } from 'smol-toml'

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

import Base_pyproject from './base_pyproject.js'
import { evaluateMarker } from './marker_evaluator.js'
Expand Down Expand Up @@ -177,3 +179,81 @@ export default class Python_uv extends Base_pyproject {
return { directDeps, graph }
}
}

const DEFAULT_UV_DISCOVERY_IGNORE = [
'**/__pycache__/**',
'**/.venv/**',
]

/**
* Discover all pyproject.toml manifest paths in a uv workspace.
* Parses `[tool.uv.workspace]` from root pyproject.toml and glob-expands member patterns.
*
* @param {string} workspaceRoot - Absolute or relative path to workspace root (must contain pyproject.toml and uv.lock)
* @param {{ workspaceDiscoveryIgnore?: string[], TRUSTIFY_DA_WORKSPACE_DISCOVERY_IGNORE?: string, [key: string]: unknown }} [opts={}]
* @returns {Promise<string[]>} Paths to pyproject.toml files (absolute)
*/
export async function discoverUvWorkspaceMembers(workspaceRoot, opts = {}) {
const root = path.resolve(workspaceRoot)
const rootPyproject = path.join(root, 'pyproject.toml')
const uvLock = path.join(root, 'uv.lock')

if (!fs.existsSync(rootPyproject) || !fs.existsSync(uvLock)) {
return []
}

let parsed
try {
parsed = parseToml(fs.readFileSync(rootPyproject, 'utf-8'))
} catch {
return []
}

const workspaceConfig = parsed?.tool?.uv?.workspace
if (!workspaceConfig) {
return []
}

const memberPatterns = workspaceConfig.members
if (!Array.isArray(memberPatterns) || memberPatterns.length === 0) {
return []
}

const excludePatterns = Array.isArray(workspaceConfig.exclude) ? workspaceConfig.exclude : []
const excludeGlobs = excludePatterns
.filter(p => typeof p === 'string' && p.trim())
.map(p => `${p.trim()}/pyproject.toml`)

const ignorePatterns = [...resolveWorkspaceDiscoveryIgnore(opts), ...DEFAULT_UV_DISCOVERY_IGNORE]
const globOpts = {
cwd: root,
absolute: true,
onlyFiles: true,
ignore: [...ignorePatterns, ...excludeGlobs],
followSymbolicLinks: false,
}

const patterns = toManifestGlobPatterns(
memberPatterns.filter(p => typeof p === 'string'),
'pyproject.toml',
)
const manifestPaths = await fg(patterns, globOpts)

if (!manifestPaths.includes(rootPyproject) && hasProjectMetadata(parsed)) {
manifestPaths.unshift(rootPyproject)
}

return filterManifestPathsByDiscoveryIgnore(manifestPaths, root, ignorePatterns)
}

/**
* @param {import('smol-toml').TomlTable} parsedPyProject
* @returns {boolean}
*/
function hasProjectMetadata(parsedPyProject) {
try {
return typeof parsedPyProject?.project?.name === 'string' && parsedPyProject.project.name.trim() !== ''
} catch {
return false
}
}
3 changes: 2 additions & 1 deletion src/workspace.js
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ function parsePnpmPackages(content) {
* @param {string} manifestFileName - e.g. 'package.json' or 'Cargo.toml'
* @returns {string[]}
*/
function toManifestGlobPatterns(patterns, manifestFileName) {
export function toManifestGlobPatterns(patterns, manifestFileName) {
return patterns.map(p => {
if (p.startsWith('!')) {
return `!${p.slice(1)}/${manifestFileName}`
Expand Down Expand Up @@ -269,3 +269,4 @@ export async function discoverWorkspaceCrates(workspaceRoot, opts = {}) {
const ignorePatterns = resolveWorkspaceDiscoveryIgnore(opts)
return filterManifestPathsByDiscoveryIgnore(manifestPaths, root, ignorePatterns)
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
[project]
name = "core"
version = "0.1.0"
dependencies = []
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
[project]
name = "internal"
version = "0.1.0"
dependencies = []
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
[project]
name = "exclude-workspace"
version = "0.1.0"
dependencies = []

[tool.uv.workspace]
members = ["packages/*"]
exclude = ["packages/internal"]

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
[project]
name = "backend"
version = "0.1.0"
dependencies = []
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
[project]
name = "core"
version = "0.1.0"
dependencies = []
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
[project]
name = "nested-workspace"
version = "0.1.0"
dependencies = []

[tool.uv.workspace]
members = ["apps/*", "libs/*"]

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
[project]
name = "no-config"
version = "0.1.0"
dependencies = ["requests>=2.0"]

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
[project]
name = "no-lock-workspace"
version = "0.1.0"
dependencies = []

[tool.uv.workspace]
members = ["packages/*"]
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
[project]
name = "pkg-a"
version = "0.1.0"
dependencies = []
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
[project]
name = "pkg-b"
version = "0.1.0"
dependencies = []
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[tool.uv.workspace]
members = ["packages/*"]

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

69 changes: 68 additions & 1 deletion test/providers/workspace.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import esmock from 'esmock'
import { discoverGoWorkspaceModules } from '../../src/providers/golang_gomodules.js'
import { discoverGradleSubprojects } from '../../src/providers/java_gradle.js'
import { discoverMavenModules } from '../../src/providers/java_maven.js'
import { discoverUvWorkspaceMembers } from '../../src/providers/python_uv.js'
import {
discoverWorkspaceCrates,
discoverWorkspacePackages,
Expand Down Expand Up @@ -191,14 +192,80 @@ suite('discoverWorkspaceCrates', () => {
})
})

suite('discoverUvWorkspaceMembers', () => {
test('returns empty when no pyproject.toml at root', async () => {
const result = await discoverUvWorkspaceMembers('test/providers/tst_manifests/npm')
expect(result).to.be.an('array')
expect(result).to.have.lengthOf(0)
})

test('returns empty when pyproject.toml exists but no uv.lock', async () => {
const root = path.resolve('test/providers/tst_manifests/pyproject/uv_workspace_no_lock')
const result = await discoverUvWorkspaceMembers(root)
expect(result).to.be.an('array')
expect(result).to.have.lengthOf(0)
})

test('returns empty when pyproject.toml has no [tool.uv.workspace]', async () => {
const root = path.resolve('test/providers/tst_manifests/pyproject/uv_workspace_no_config')
const result = await discoverUvWorkspaceMembers(root)
expect(result).to.be.an('array')
expect(result).to.have.lengthOf(0)
})

test('discovers members from root-package workspace (includes root)', async () => {
const root = path.resolve('test/providers/tst_manifests/pyproject/uv_workspace')
const result = await discoverUvWorkspaceMembers(root)
expect(result).to.be.an('array')
expect(result).to.have.lengthOf(3)
expect(result.every(p => p.endsWith('pyproject.toml'))).to.be.true
expect(result[0]).to.equal(path.join(root, 'pyproject.toml'))
expect(result.some(p => p.includes(path.join('packages', 'mid-pkg')))).to.be.true
expect(result.some(p => p.includes(path.join('packages', 'sub-pkg')))).to.be.true
})

test('discovers members from virtual workspace (excludes root)', async () => {
const root = path.resolve('test/providers/tst_manifests/pyproject/uv_workspace_virtual')
const result = await discoverUvWorkspaceMembers(root)
expect(result).to.be.an('array')
expect(result).to.have.lengthOf(2)
expect(result.every(p => p.endsWith('pyproject.toml'))).to.be.true
expect(result.some(p => p.includes(path.join('packages', 'pkg-a')))).to.be.true
expect(result.some(p => p.includes(path.join('packages', 'pkg-b')))).to.be.true
expect(result.every(p => p !== path.join(root, 'pyproject.toml'))).to.be.true
})

test('respects exclude patterns', async () => {
const root = path.resolve('test/providers/tst_manifests/pyproject/uv_workspace_exclude')
const result = await discoverUvWorkspaceMembers(root)
expect(result.some(p => p.includes(path.join('packages', 'core')))).to.be.true
expect(result.some(p => p.includes(path.join('packages', 'internal')))).to.be.false
})

test('discovers members from multiple glob patterns', async () => {
const root = path.resolve('test/providers/tst_manifests/pyproject/uv_workspace_nested')
const result = await discoverUvWorkspaceMembers(root)
expect(result).to.be.an('array')
expect(result.some(p => p.includes(path.join('apps', 'backend')))).to.be.true
expect(result.some(p => p.includes(path.join('libs', 'core')))).to.be.true
})

test('applies workspaceDiscoveryIgnore patterns', async () => {
const root = path.resolve('test/providers/tst_manifests/pyproject/uv_workspace_nested')
const result = await discoverUvWorkspaceMembers(root, {
workspaceDiscoveryIgnore: ['**/libs/**'],
})
expect(result.some(p => p.includes(path.join('apps', 'backend')))).to.be.true
expect(result.some(p => p.includes(path.join('libs', 'core')))).to.be.false
})
})

suite('discoverGradleSubprojects', () => {
test('returns empty when no settings.gradle at root', async () => {
const result = await discoverGradleSubprojects('test/providers/tst_manifests/npm')
expect(result).to.be.an('array')
expect(result).to.have.lengthOf(0)
})

test('discovers multi-project build', async () => {
const root = path.resolve('test/providers/tst_manifests/gradle/gradle_multi_project')
const result = await discoverGradleSubprojects(root)
Expand Down
Loading