Skip to content

Commit 2cb2b5a

Browse files
ruromeroclaude
andcommitted
fix(python): filter packages by PEP 508 environment markers in uv and poetry providers
uv's universal resolver emits all platform variants with inline marker specs. poetry show --tree includes marker-conditional deps without marker info, requiring cross-reference with poetry.lock. Both providers now evaluate markers against the current environment and exclude non-matching packages from the SBOM. Closes: TC-4042 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent d9c1ae4 commit 2cb2b5a

8 files changed

Lines changed: 526 additions & 11 deletions

File tree

src/providers/marker_evaluator.js

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { execSync } from 'node:child_process'
2+
import os from 'node:os'
3+
4+
let cachedPythonVersion = undefined
5+
6+
function getPythonVersion() {
7+
if (cachedPythonVersion !== undefined) { return cachedPythonVersion }
8+
try {
9+
let out = execSync('python3 -c "import sys; print(f\'{sys.version_info.major}.{sys.version_info.minor}\')"',
10+
{ timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] }).toString().trim()
11+
cachedPythonVersion = out
12+
} catch {
13+
cachedPythonVersion = null
14+
}
15+
return cachedPythonVersion
16+
}
17+
18+
/** @returns {Record<string, string>} */
19+
export function getEnvironmentMarkers() {
20+
let platform = process.platform
21+
let systemMap = { win32: 'Windows', linux: 'Linux', darwin: 'Darwin' }
22+
let machine = typeof os.machine === 'function' ? os.machine() : process.arch
23+
let pyVer = getPythonVersion()
24+
return {
25+
sys_platform: platform,
26+
platform_system: systemMap[platform] || platform,
27+
os_name: platform === 'win32' ? 'nt' : 'posix',
28+
platform_machine: machine,
29+
platform_release: os.release(),
30+
platform_version: os.version?.() || '',
31+
python_version: pyVer || '',
32+
python_full_version: pyVer || '',
33+
implementation_name: 'cpython',
34+
}
35+
}
36+
37+
function compareVersions(left, right) {
38+
let lParts = left.split('.').map(Number)
39+
let rParts = right.split('.').map(Number)
40+
let len = Math.max(lParts.length, rParts.length)
41+
for (let i = 0; i < len; i++) {
42+
let l = lParts[i] || 0
43+
let r = rParts[i] || 0
44+
if (l < r) { return -1 }
45+
if (l > r) { return 1 }
46+
}
47+
return 0
48+
}
49+
50+
function evaluateComparison(variable, op, value, env) {
51+
let envVal = env[variable]
52+
if (envVal === undefined || envVal === '') {
53+
return variable.includes('version') ? true : false
54+
}
55+
56+
let isVersion = variable.includes('version')
57+
if (isVersion) {
58+
let cmp = compareVersions(envVal, value)
59+
switch (op) {
60+
case '==': return cmp === 0
61+
case '!=': return cmp !== 0
62+
case '>=': return cmp >= 0
63+
case '<=': return cmp <= 0
64+
case '>': return cmp > 0
65+
case '<': return cmp < 0
66+
case '~=': {
67+
let parts = value.split('.')
68+
parts.pop()
69+
let prefix = parts.join('.')
70+
return envVal.startsWith(prefix) && cmp >= 0
71+
}
72+
default: return true
73+
}
74+
}
75+
76+
switch (op) {
77+
case '==': return envVal === value
78+
case '!=': return envVal !== value
79+
case 'in': return value.split(',').map(s => s.trim()).includes(envVal)
80+
case 'not in': return !value.split(',').map(s => s.trim()).includes(envVal)
81+
default: return envVal === value
82+
}
83+
}
84+
85+
function parseAtom(expr) {
86+
let m = expr.match(/^\s*([\w.]+)\s*(~=|!=|==|>=|<=|>|<|not\s+in|in)\s*["']([^"']*)["']\s*$/)
87+
if (m) { return { variable: m[1], op: m[2].replace(/\s+/g, ' '), value: m[3] } }
88+
89+
let mReverse = expr.match(/^\s*["']([^"']*)['"]\s*(~=|!=|==|>=|<=|>|<|not\s+in|in)\s*([\w.]+)\s*$/)
90+
if (mReverse) { return { variable: mReverse[3], op: mReverse[2].replace(/\s+/g, ' '), value: mReverse[1] } }
91+
92+
return null
93+
}
94+
95+
/**
96+
* @param {string} markerExpr
97+
* @returns {boolean}
98+
*/
99+
export function evaluateMarker(markerExpr) {
100+
if (!markerExpr || !markerExpr.trim()) { return true }
101+
let env = getEnvironmentMarkers()
102+
return evaluateExpr(markerExpr.trim(), env)
103+
}
104+
105+
function evaluateExpr(expr, env) {
106+
let orParts = splitLogical(expr, ' or ')
107+
if (orParts.length > 1) {
108+
return orParts.some(part => evaluateExpr(part, env))
109+
}
110+
111+
let andParts = splitLogical(expr, ' and ')
112+
if (andParts.length > 1) {
113+
return andParts.every(part => evaluateExpr(part, env))
114+
}
115+
116+
let trimmed = expr.trim()
117+
if (trimmed.startsWith('(') && trimmed.endsWith(')')) {
118+
return evaluateExpr(trimmed.slice(1, -1), env)
119+
}
120+
121+
let atom = parseAtom(trimmed)
122+
if (!atom) { return true }
123+
124+
return evaluateComparison(atom.variable, atom.op, atom.value, env)
125+
}
126+
127+
function splitLogical(expr, sep) {
128+
let parts = []
129+
let depth = 0
130+
let current = ''
131+
let i = 0
132+
while (i < expr.length) {
133+
if (expr[i] === '(') { depth++ }
134+
if (expr[i] === ')') { depth-- }
135+
if (depth === 0 && expr.substring(i, i + sep.length) === sep) {
136+
parts.push(current)
137+
current = ''
138+
i += sep.length
139+
continue
140+
}
141+
current += expr[i]
142+
i++
143+
}
144+
parts.push(current)
145+
return parts.filter(p => p.trim())
146+
}

src/providers/python_poetry.js

Lines changed: 75 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import fs from 'node:fs'
22
import path from 'node:path'
33

4+
import { parse as parseToml } from 'smol-toml'
5+
46
import { environmentVariableIsPopulated, getCustom, getCustomPath, invokeCommand } from '../tools.js'
57

68
import Base_pyproject from './base_pyproject.js'
9+
import { evaluateMarker } from './marker_evaluator.js'
710

811
export default class Python_poetry extends Base_pyproject {
912

@@ -50,7 +53,9 @@ export default class Python_poetry extends Base_pyproject {
5053
let treeOutput = this._getPoetryShowTreeOutput(manifestDir, hasDevGroup, opts)
5154
let showAllOutput = this._getPoetryShowAllOutput(manifestDir, opts)
5255
let versionMap = this._parsePoetryShowAll(showAllOutput)
53-
return this._parsePoetryTree(treeOutput, versionMap)
56+
let lockDir = this._findLockFileDir(manifestDir, opts)
57+
let markerData = this._extractMarkerData(lockDir, parsed)
58+
return this._parsePoetryTree(treeOutput, versionMap, markerData)
5459
}
5560

5661
/**
@@ -107,16 +112,57 @@ export default class Python_poetry extends Base_pyproject {
107112
return versions
108113
}
109114

115+
/**
116+
* @param {string|null} lockDir
117+
* @param {object} parsed - parsed pyproject.toml
118+
* @returns {{directMarkers: Map<string, string>, transitiveMarkers: Map<string, Map<string, string>>}}
119+
*/
120+
_extractMarkerData(lockDir, parsed) {
121+
let directMarkers = new Map()
122+
let transitiveMarkers = new Map()
123+
124+
let deps = parsed.project?.dependencies || []
125+
for (let dep of deps) {
126+
let m = dep.match(/^([A-Za-z0-9][A-Za-z0-9._-]*)\s*[^;]*;\s*(.+)$/)
127+
if (m) {
128+
directMarkers.set(this._canonicalize(m[1]), m[2].trim())
129+
}
130+
}
131+
132+
if (lockDir) {
133+
let lockPath = path.join(lockDir, this._lockFileName())
134+
if (fs.existsSync(lockPath)) {
135+
let lockContent = fs.readFileSync(lockPath, 'utf-8')
136+
let lock = parseToml(lockContent)
137+
let packages = lock.package || []
138+
for (let pkg of packages) {
139+
let pkgKey = this._canonicalize(pkg.name)
140+
let pkgDeps = pkg.dependencies || {}
141+
for (let [depName, depSpec] of Object.entries(pkgDeps)) {
142+
let markers = typeof depSpec === 'object' && depSpec != null ? depSpec.markers : null
143+
if (markers) {
144+
if (!transitiveMarkers.has(pkgKey)) {
145+
transitiveMarkers.set(pkgKey, new Map())
146+
}
147+
transitiveMarkers.get(pkgKey).set(this._canonicalize(depName), markers)
148+
}
149+
}
150+
}
151+
}
152+
}
153+
154+
return { directMarkers, transitiveMarkers }
155+
}
156+
110157
/**
111158
* Parse poetry show --tree output into a dependency graph structure.
112-
* Top-level lines (no indentation/tree chars) are direct deps: "name version description"
113-
* Indented lines are transitive deps with tree chars: "├── name >=constraint"
114159
*
115160
* @param {string} treeOutput
116161
* @param {Map<string, string>} versionMap - canonical name -> resolved version
162+
* @param {{directMarkers: Map<string, string>, transitiveMarkers: Map<string, Map<string, string>>}} markerData
117163
* @returns {{directDeps: string[], graph: Map<string, {name: string, version: string, children: string[]}>}}
118164
*/
119-
_parsePoetryTree(treeOutput, versionMap) {
165+
_parsePoetryTree(treeOutput, versionMap, markerData) {
120166
let lines = treeOutput.split(/\r?\n/)
121167
let graph = new Map()
122168
let directDeps = []
@@ -133,6 +179,14 @@ export default class Python_poetry extends Base_pyproject {
133179
let name = topMatch[1]
134180
let version = topMatch[2]
135181
let key = this._canonicalize(name)
182+
183+
let marker = markerData.directMarkers.get(key)
184+
if (marker && !evaluateMarker(marker)) {
185+
currentDirectDep = null
186+
stack = []
187+
continue
188+
}
189+
136190
directDeps.push(key)
137191
if (!graph.has(key)) {
138192
graph.set(key, { name, version, children: [] })
@@ -159,6 +213,22 @@ export default class Python_poetry extends Base_pyproject {
159213
let prefix = line.substring(0, nameStart)
160214
let depth = (prefix.match(/(?:[ ][\s]{2} ?)/g) || []).length
161215

216+
// pop stack back to find the parent at depth-1
217+
while (stack.length > 0 && stack[stack.length - 1].depth >= depth) {
218+
stack.pop()
219+
}
220+
221+
let parentKey = stack.length > 0 ? stack[stack.length - 1].key : null
222+
if (parentKey) {
223+
let parentMarkers = markerData.transitiveMarkers.get(parentKey)
224+
if (parentMarkers) {
225+
let marker = parentMarkers.get(depKey)
226+
if (marker && !evaluateMarker(marker)) {
227+
continue
228+
}
229+
}
230+
}
231+
162232
// resolve version from the version map
163233
let version = versionMap.get(depKey) || null
164234
if (!version) {
@@ -169,13 +239,7 @@ export default class Python_poetry extends Base_pyproject {
169239
graph.set(depKey, { name: depName, version, children: [] })
170240
}
171241

172-
// pop stack back to find the parent at depth-1
173-
while (stack.length > 0 && stack[stack.length - 1].depth >= depth) {
174-
stack.pop()
175-
}
176-
177-
if (stack.length > 0) {
178-
let parentKey = stack[stack.length - 1].key
242+
if (parentKey) {
179243
let parentEntry = graph.get(parentKey)
180244
if (parentEntry && !parentEntry.children.includes(depKey)) {
181245
parentEntry.children.push(depKey)

src/providers/python_uv.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { parse as parseToml } from 'smol-toml'
66
import { environmentVariableIsPopulated, getCustomPath, invokeCommand } from '../tools.js'
77

88
import Base_pyproject from './base_pyproject.js'
9+
import { evaluateMarker } from './marker_evaluator.js'
910
import { getParser, getPinnedVersionQuery } from './requirements_parser.js'
1011

1112
export default class Python_uv extends Base_pyproject {
@@ -98,6 +99,16 @@ export default class Python_uv extends Base_pyproject {
9899
let nameNode = child.children.find(c => c.type === 'package')
99100
if (!nameNode) { continue }
100101

102+
let markerNode = child.children.find(c => c.type === 'marker_spec')
103+
if (markerNode) {
104+
let markerText = markerNode.text.replace(/^\s*;\s*/, '')
105+
if (!evaluateMarker(markerText)) {
106+
currentPkg = null
107+
collectingVia = false
108+
continue
109+
}
110+
}
111+
101112
let name = nameNode.text
102113
let version = null
103114
let versionMatches = pinnedVersionQuery.matches(child)

0 commit comments

Comments
 (0)