Skip to content

Commit 48b927e

Browse files
ruromeroclaude
andcommitted
feat(python): filter PEP 508 environment markers in uv and poetry providers
Add a PEP 508 marker evaluator that maps Node.js platform values to Python environment markers (sys_platform, platform_system, os_name, python_version, etc.) and evaluates marker expressions with support for version comparison, logical operators, parentheses, reversed operands, and in/not in membership tests. The uv provider skips packages whose marker_spec nodes do not match the current platform. The poetry provider cross-references markers from both pyproject.toml PEP 621 dependency strings (direct deps) and poetry.lock package.dependencies entries (transitive deps). Windows-only packages like pywin32, colorama, and pysocks are now correctly excluded from SBOMs generated on Linux/macOS. Tests mock process.platform to ensure deterministic results on any OS. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent d9c1ae4 commit 48b927e

18 files changed

Lines changed: 1803 additions & 1334 deletions

src/providers/marker_evaluator.js

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
// PEP 508 environment marker evaluator.
2+
// Filters Python dependencies by platform/version markers so that e.g.
3+
// "pywin32 ; sys_platform == 'win32'" is excluded on Linux/macOS.
4+
// See https://peps.python.org/pep-0508/#environment-markers
5+
6+
import { execSync } from 'node:child_process'
7+
import os from 'node:os'
8+
9+
let cachedPythonVersion = undefined
10+
11+
function getPythonVersion() {
12+
if (cachedPythonVersion !== undefined) { return cachedPythonVersion }
13+
try {
14+
let out = execSync('python3 -c "import sys; print(f\'{sys.version_info.major}.{sys.version_info.minor}\')"',
15+
{ timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] }).toString().trim()
16+
cachedPythonVersion = out
17+
} catch {
18+
cachedPythonVersion = null
19+
}
20+
return cachedPythonVersion
21+
}
22+
23+
/**
24+
* Maps Node.js/OS values to PEP 508 marker variables.
25+
* Example: on Linux, sys_platform='linux', platform_system='Linux', os_name='posix'
26+
* @returns {Record<string, string>}
27+
*/
28+
export function getEnvironmentMarkers() {
29+
let platform = process.platform
30+
let systemMap = { win32: 'Windows', linux: 'Linux', darwin: 'Darwin' }
31+
let machine = typeof os.machine === 'function' ? os.machine() : process.arch
32+
let pyVer = getPythonVersion()
33+
return {
34+
sys_platform: platform,
35+
platform_system: systemMap[platform] || platform,
36+
os_name: platform === 'win32' ? 'nt' : 'posix',
37+
platform_machine: machine,
38+
platform_release: os.release(),
39+
platform_version: os.version?.() || '',
40+
python_version: pyVer || '',
41+
python_full_version: pyVer || '',
42+
implementation_name: 'cpython',
43+
}
44+
}
45+
46+
function compareVersions(left, right) {
47+
let lParts = left.split('.').map(Number)
48+
let rParts = right.split('.').map(Number)
49+
let len = Math.max(lParts.length, rParts.length)
50+
for (let i = 0; i < len; i++) {
51+
let l = lParts[i] || 0
52+
let r = rParts[i] || 0
53+
if (l < r) { return -1 }
54+
if (l > r) { return 1 }
55+
}
56+
return 0
57+
}
58+
59+
// Evaluates a single comparison like sys_platform == 'win32' or python_version >= '3.8'.
60+
// Version-bearing variables (python_version, python_full_version) use numeric comparison;
61+
// all others use string equality. Returns false when the env value is missing.
62+
function evaluateComparison(variable, op, value, env) {
63+
let envVal = env[variable]
64+
if (envVal === undefined || envVal === '') {
65+
return false
66+
}
67+
68+
let isVersion = variable.includes('version')
69+
if (isVersion) {
70+
let cmp = compareVersions(envVal, value)
71+
switch (op) {
72+
case '==': return cmp === 0
73+
case '!=': return cmp !== 0
74+
case '>=': return cmp >= 0
75+
case '<=': return cmp <= 0
76+
case '>': return cmp > 0
77+
case '<': return cmp < 0
78+
case '~=': {
79+
let parts = value.split('.')
80+
parts.pop()
81+
let prefix = parts.join('.')
82+
return envVal.startsWith(prefix) && cmp >= 0
83+
}
84+
default: return true
85+
}
86+
}
87+
88+
switch (op) {
89+
case '==': return envVal === value
90+
case '!=': return envVal !== value
91+
case 'in': return value.split(',').map(s => s.trim()).includes(envVal)
92+
case 'not in': return !value.split(',').map(s => s.trim()).includes(envVal)
93+
default: return envVal === value
94+
}
95+
}
96+
97+
// Parses a single marker comparison into {variable, op, value}.
98+
// Handles both normal and reversed forms:
99+
// "sys_platform == 'linux'" → { variable: 'sys_platform', op: '==', value: 'linux' }
100+
// "'linux' == sys_platform" → { variable: 'sys_platform', op: '==', value: 'linux' }
101+
function parseAtom(expr) {
102+
// Normal form: variable op 'value'
103+
let m = expr.match(/^\s*([\w.]+)\s*(~=|!=|==|>=|<=|>|<|not\s+in|in)\s*["']([^"']*)["']\s*$/)
104+
if (m) { return { variable: m[1], op: m[2].replace(/\s+/g, ' '), value: m[3] } }
105+
106+
// Reversed form: 'value' op variable
107+
let mReverse = expr.match(/^\s*["']([^"']*)['"]\s*(~=|!=|==|>=|<=|>|<|not\s+in|in)\s*([\w.]+)\s*$/)
108+
if (mReverse) { return { variable: mReverse[3], op: mReverse[2].replace(/\s+/g, ' '), value: mReverse[1] } }
109+
110+
return null
111+
}
112+
113+
/**
114+
* Evaluates a full PEP 508 marker expression against the current platform.
115+
* Example: "sys_platform == 'win32' and python_version >= '3.8'" → false on Linux
116+
* Empty/missing markers return true (unconditional dependency).
117+
* @param {string} markerExpr
118+
* @returns {boolean}
119+
*/
120+
export function evaluateMarker(markerExpr) {
121+
if (!markerExpr || !markerExpr.trim()) { return true }
122+
let env = getEnvironmentMarkers()
123+
return evaluateExpr(markerExpr.trim(), env)
124+
}
125+
126+
function evaluateExpr(expr, env) {
127+
let orParts = splitLogical(expr, ' or ')
128+
if (orParts.length > 1) {
129+
return orParts.some(part => evaluateExpr(part, env))
130+
}
131+
132+
let andParts = splitLogical(expr, ' and ')
133+
if (andParts.length > 1) {
134+
return andParts.every(part => evaluateExpr(part, env))
135+
}
136+
137+
let trimmed = expr.trim()
138+
if (trimmed.startsWith('(') && trimmed.endsWith(')')) {
139+
return evaluateExpr(trimmed.slice(1, -1), env)
140+
}
141+
142+
let atom = parseAtom(trimmed)
143+
if (!atom) { return true }
144+
145+
return evaluateComparison(atom.variable, atom.op, atom.value, env)
146+
}
147+
148+
// Splits an expression by " and " or " or " at the top level, skipping
149+
// separators inside parentheses or quoted strings.
150+
// Example: splitLogical("a == 'x' and (b == 'y' or c == 'z')", " and ")
151+
// → ["a == 'x'", "(b == 'y' or c == 'z')"]
152+
function splitLogical(expr, sep) {
153+
let parts = []
154+
let depth = 0
155+
let current = ''
156+
let i = 0
157+
let quoteChar = null
158+
while (i < expr.length) {
159+
let ch = expr[i]
160+
if (quoteChar) {
161+
if (ch === quoteChar) { quoteChar = null }
162+
current += ch
163+
i++
164+
continue
165+
}
166+
if (ch === '"' || ch === "'") {
167+
quoteChar = ch
168+
current += ch
169+
i++
170+
continue
171+
}
172+
if (ch === '(') { depth++ }
173+
if (ch === ')') { depth-- }
174+
if (depth === 0 && expr.substring(i, i + sep.length) === sep) {
175+
parts.push(current)
176+
current = ''
177+
i += sep.length
178+
continue
179+
}
180+
current += ch
181+
i++
182+
}
183+
parts.push(current)
184+
return parts.filter(p => p.trim())
185+
}

src/providers/python_poetry.js

Lines changed: 82 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,64 @@ export default class Python_poetry extends Base_pyproject {
107112
return versions
108113
}
109114

115+
/**
116+
* Collects PEP 508 marker expressions for direct and transitive deps.
117+
* Direct markers come from pyproject.toml dependency strings, e.g.:
118+
* "pywin32>=311 ; sys_platform == 'win32'" → directMarkers['pywin32'] = "sys_platform == 'win32'"
119+
* Transitive markers come from poetry.lock [package.dependencies] entries, e.g.:
120+
* colorama = {version = "*", markers = "sys_platform == 'win32'"}
121+
* → transitiveMarkers['click']['colorama'] = "sys_platform == 'win32'"
122+
* @param {string|null} lockDir
123+
* @param {object} parsed - parsed pyproject.toml
124+
* @returns {{directMarkers: Map<string, string>, transitiveMarkers: Map<string, Map<string, string>>}}
125+
*/
126+
_extractMarkerData(lockDir, parsed) {
127+
let directMarkers = new Map()
128+
let transitiveMarkers = new Map()
129+
130+
// Extract markers from PEP 621 dependency strings: "name[extras]>=ver ; marker"
131+
let deps = parsed.project?.dependencies || []
132+
for (let dep of deps) {
133+
let m = dep.match(/^([A-Za-z0-9][A-Za-z0-9._-]*)\s*[^;]*;\s*(.+)$/)
134+
if (m) {
135+
directMarkers.set(this._canonicalize(m[1]), m[2].trim())
136+
}
137+
}
138+
139+
if (lockDir) {
140+
let lockPath = path.join(lockDir, this._lockFileName())
141+
if (fs.existsSync(lockPath)) {
142+
let lockContent = fs.readFileSync(lockPath, 'utf-8')
143+
let lock = parseToml(lockContent)
144+
let packages = lock.package || []
145+
for (let pkg of packages) {
146+
let pkgKey = this._canonicalize(pkg.name)
147+
let pkgDeps = pkg.dependencies || {}
148+
for (let [depName, depSpec] of Object.entries(pkgDeps)) {
149+
let markers = typeof depSpec === 'object' && depSpec != null ? depSpec.markers : null
150+
if (markers) {
151+
if (!transitiveMarkers.has(pkgKey)) {
152+
transitiveMarkers.set(pkgKey, new Map())
153+
}
154+
transitiveMarkers.get(pkgKey).set(this._canonicalize(depName), markers)
155+
}
156+
}
157+
}
158+
}
159+
}
160+
161+
return { directMarkers, transitiveMarkers }
162+
}
163+
110164
/**
111165
* 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"
114166
*
115167
* @param {string} treeOutput
116168
* @param {Map<string, string>} versionMap - canonical name -> resolved version
169+
* @param {{directMarkers: Map<string, string>, transitiveMarkers: Map<string, Map<string, string>>}} markerData
117170
* @returns {{directDeps: string[], graph: Map<string, {name: string, version: string, children: string[]}>}}
118171
*/
119-
_parsePoetryTree(treeOutput, versionMap) {
172+
_parsePoetryTree(treeOutput, versionMap, markerData) {
120173
let lines = treeOutput.split(/\r?\n/)
121174
let graph = new Map()
122175
let directDeps = []
@@ -133,6 +186,14 @@ export default class Python_poetry extends Base_pyproject {
133186
let name = topMatch[1]
134187
let version = topMatch[2]
135188
let key = this._canonicalize(name)
189+
190+
let marker = markerData.directMarkers.get(key)
191+
if (marker && !evaluateMarker(marker)) {
192+
currentDirectDep = null
193+
stack = []
194+
continue
195+
}
196+
136197
directDeps.push(key)
137198
if (!graph.has(key)) {
138199
graph.set(key, { name, version, children: [] })
@@ -159,6 +220,22 @@ export default class Python_poetry extends Base_pyproject {
159220
let prefix = line.substring(0, nameStart)
160221
let depth = (prefix.match(/(?:[ ][\s]{2} ?)/g) || []).length
161222

223+
// pop stack back to find the parent at depth-1
224+
while (stack.length > 0 && stack[stack.length - 1].depth >= depth) {
225+
stack.pop()
226+
}
227+
228+
let parentKey = stack.length > 0 ? stack[stack.length - 1].key : null
229+
if (parentKey) {
230+
let parentMarkers = markerData.transitiveMarkers.get(parentKey)
231+
if (parentMarkers) {
232+
let marker = parentMarkers.get(depKey)
233+
if (marker && !evaluateMarker(marker)) {
234+
continue
235+
}
236+
}
237+
}
238+
162239
// resolve version from the version map
163240
let version = versionMap.get(depKey) || null
164241
if (!version) {
@@ -169,13 +246,7 @@ export default class Python_poetry extends Base_pyproject {
169246
graph.set(depKey, { name: depName, version, children: [] })
170247
}
171248

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
249+
if (parentKey) {
179250
let parentEntry = graph.get(parentKey)
180251
if (parentEntry && !parentEntry.children.includes(depKey)) {
181252
parentEntry.children.push(depKey)

src/providers/python_uv.js

Lines changed: 12 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,17 @@ export default class Python_uv extends Base_pyproject {
9899
let nameNode = child.children.find(c => c.type === 'package')
99100
if (!nameNode) { continue }
100101

102+
// Skip packages with non-matching PEP 508 markers, e.g. "pywin32==311 ; sys_platform == 'win32'"
103+
let markerNode = child.children.find(c => c.type === 'marker_spec')
104+
if (markerNode) {
105+
let markerText = markerNode.text.replace(/^\s*;\s*/, '')
106+
if (!evaluateMarker(markerText)) {
107+
currentPkg = null
108+
collectingVia = false
109+
continue
110+
}
111+
}
112+
101113
let name = nameNode.text
102114
let version = null
103115
let versionMatches = pinnedVersionQuery.matches(child)

0 commit comments

Comments
 (0)