diff --git a/src/index.js b/src/index.js index 138157ef..a3449b76 100644 --- a/src/index.js +++ b/src/index.js @@ -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, @@ -29,6 +30,7 @@ export { discoverMavenModules, discoverGradleSubprojects, discoverGoWorkspaceModules, + discoverUvWorkspaceMembers, discoverWorkspacePackages, discoverWorkspaceCrates, validatePackageJson, @@ -89,7 +91,7 @@ export { /** * @typedef {{ * workspaceRoot: string, - * ecosystem: 'javascript' | 'cargo' | 'unknown', + * ecosystem: 'javascript' | 'cargo' | 'pyproject' | 'unknown', * total: number, * successful: number, * failed: number, @@ -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) { @@ -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')) @@ -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 } diff --git a/src/providers/python_uv.js b/src/providers/python_uv.js index bce7c9cf..c96b0956 100644 --- a/src/providers/python_uv.js +++ b/src/providers/python_uv.js @@ -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' @@ -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} 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 + } +} diff --git a/src/workspace.js b/src/workspace.js index f71d5e18..c4b43a0a 100644 --- a/src/workspace.js +++ b/src/workspace.js @@ -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}` @@ -269,3 +269,4 @@ export async function discoverWorkspaceCrates(workspaceRoot, opts = {}) { const ignorePatterns = resolveWorkspaceDiscoveryIgnore(opts) return filterManifestPathsByDiscoveryIgnore(manifestPaths, root, ignorePatterns) } + diff --git a/test/providers/tst_manifests/pyproject/uv_workspace_exclude/packages/core/pyproject.toml b/test/providers/tst_manifests/pyproject/uv_workspace_exclude/packages/core/pyproject.toml new file mode 100644 index 00000000..f6838f3e --- /dev/null +++ b/test/providers/tst_manifests/pyproject/uv_workspace_exclude/packages/core/pyproject.toml @@ -0,0 +1,4 @@ +[project] +name = "core" +version = "0.1.0" +dependencies = [] diff --git a/test/providers/tst_manifests/pyproject/uv_workspace_exclude/packages/internal/pyproject.toml b/test/providers/tst_manifests/pyproject/uv_workspace_exclude/packages/internal/pyproject.toml new file mode 100644 index 00000000..97bbdd44 --- /dev/null +++ b/test/providers/tst_manifests/pyproject/uv_workspace_exclude/packages/internal/pyproject.toml @@ -0,0 +1,4 @@ +[project] +name = "internal" +version = "0.1.0" +dependencies = [] diff --git a/test/providers/tst_manifests/pyproject/uv_workspace_exclude/pyproject.toml b/test/providers/tst_manifests/pyproject/uv_workspace_exclude/pyproject.toml new file mode 100644 index 00000000..f7ae8e5a --- /dev/null +++ b/test/providers/tst_manifests/pyproject/uv_workspace_exclude/pyproject.toml @@ -0,0 +1,8 @@ +[project] +name = "exclude-workspace" +version = "0.1.0" +dependencies = [] + +[tool.uv.workspace] +members = ["packages/*"] +exclude = ["packages/internal"] diff --git a/test/providers/tst_manifests/pyproject/uv_workspace_exclude/uv.lock b/test/providers/tst_manifests/pyproject/uv_workspace_exclude/uv.lock new file mode 100644 index 00000000..d9914dfa --- /dev/null +++ b/test/providers/tst_manifests/pyproject/uv_workspace_exclude/uv.lock @@ -0,0 +1 @@ +version = 1 diff --git a/test/providers/tst_manifests/pyproject/uv_workspace_nested/apps/backend/pyproject.toml b/test/providers/tst_manifests/pyproject/uv_workspace_nested/apps/backend/pyproject.toml new file mode 100644 index 00000000..f11cb651 --- /dev/null +++ b/test/providers/tst_manifests/pyproject/uv_workspace_nested/apps/backend/pyproject.toml @@ -0,0 +1,4 @@ +[project] +name = "backend" +version = "0.1.0" +dependencies = [] diff --git a/test/providers/tst_manifests/pyproject/uv_workspace_nested/libs/core/pyproject.toml b/test/providers/tst_manifests/pyproject/uv_workspace_nested/libs/core/pyproject.toml new file mode 100644 index 00000000..f6838f3e --- /dev/null +++ b/test/providers/tst_manifests/pyproject/uv_workspace_nested/libs/core/pyproject.toml @@ -0,0 +1,4 @@ +[project] +name = "core" +version = "0.1.0" +dependencies = [] diff --git a/test/providers/tst_manifests/pyproject/uv_workspace_nested/pyproject.toml b/test/providers/tst_manifests/pyproject/uv_workspace_nested/pyproject.toml new file mode 100644 index 00000000..e3dce5c4 --- /dev/null +++ b/test/providers/tst_manifests/pyproject/uv_workspace_nested/pyproject.toml @@ -0,0 +1,7 @@ +[project] +name = "nested-workspace" +version = "0.1.0" +dependencies = [] + +[tool.uv.workspace] +members = ["apps/*", "libs/*"] diff --git a/test/providers/tst_manifests/pyproject/uv_workspace_nested/uv.lock b/test/providers/tst_manifests/pyproject/uv_workspace_nested/uv.lock new file mode 100644 index 00000000..d9914dfa --- /dev/null +++ b/test/providers/tst_manifests/pyproject/uv_workspace_nested/uv.lock @@ -0,0 +1 @@ +version = 1 diff --git a/test/providers/tst_manifests/pyproject/uv_workspace_no_config/pyproject.toml b/test/providers/tst_manifests/pyproject/uv_workspace_no_config/pyproject.toml new file mode 100644 index 00000000..8e7fb78a --- /dev/null +++ b/test/providers/tst_manifests/pyproject/uv_workspace_no_config/pyproject.toml @@ -0,0 +1,4 @@ +[project] +name = "no-config" +version = "0.1.0" +dependencies = ["requests>=2.0"] diff --git a/test/providers/tst_manifests/pyproject/uv_workspace_no_config/uv.lock b/test/providers/tst_manifests/pyproject/uv_workspace_no_config/uv.lock new file mode 100644 index 00000000..d9914dfa --- /dev/null +++ b/test/providers/tst_manifests/pyproject/uv_workspace_no_config/uv.lock @@ -0,0 +1 @@ +version = 1 diff --git a/test/providers/tst_manifests/pyproject/uv_workspace_no_lock/pyproject.toml b/test/providers/tst_manifests/pyproject/uv_workspace_no_lock/pyproject.toml new file mode 100644 index 00000000..a44ce805 --- /dev/null +++ b/test/providers/tst_manifests/pyproject/uv_workspace_no_lock/pyproject.toml @@ -0,0 +1,7 @@ +[project] +name = "no-lock-workspace" +version = "0.1.0" +dependencies = [] + +[tool.uv.workspace] +members = ["packages/*"] diff --git a/test/providers/tst_manifests/pyproject/uv_workspace_virtual/packages/pkg-a/pyproject.toml b/test/providers/tst_manifests/pyproject/uv_workspace_virtual/packages/pkg-a/pyproject.toml new file mode 100644 index 00000000..840b5efd --- /dev/null +++ b/test/providers/tst_manifests/pyproject/uv_workspace_virtual/packages/pkg-a/pyproject.toml @@ -0,0 +1,4 @@ +[project] +name = "pkg-a" +version = "0.1.0" +dependencies = [] diff --git a/test/providers/tst_manifests/pyproject/uv_workspace_virtual/packages/pkg-b/pyproject.toml b/test/providers/tst_manifests/pyproject/uv_workspace_virtual/packages/pkg-b/pyproject.toml new file mode 100644 index 00000000..f1e7ed78 --- /dev/null +++ b/test/providers/tst_manifests/pyproject/uv_workspace_virtual/packages/pkg-b/pyproject.toml @@ -0,0 +1,4 @@ +[project] +name = "pkg-b" +version = "0.1.0" +dependencies = [] diff --git a/test/providers/tst_manifests/pyproject/uv_workspace_virtual/pyproject.toml b/test/providers/tst_manifests/pyproject/uv_workspace_virtual/pyproject.toml new file mode 100644 index 00000000..2b03125d --- /dev/null +++ b/test/providers/tst_manifests/pyproject/uv_workspace_virtual/pyproject.toml @@ -0,0 +1,2 @@ +[tool.uv.workspace] +members = ["packages/*"] diff --git a/test/providers/tst_manifests/pyproject/uv_workspace_virtual/uv.lock b/test/providers/tst_manifests/pyproject/uv_workspace_virtual/uv.lock new file mode 100644 index 00000000..d9914dfa --- /dev/null +++ b/test/providers/tst_manifests/pyproject/uv_workspace_virtual/uv.lock @@ -0,0 +1 @@ +version = 1 diff --git a/test/providers/workspace.test.js b/test/providers/workspace.test.js index 673111a6..17f40bc1 100644 --- a/test/providers/workspace.test.js +++ b/test/providers/workspace.test.js @@ -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, @@ -191,6 +192,73 @@ 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 () => { @@ -198,7 +266,6 @@ suite('discoverGradleSubprojects', () => { 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)