Skip to content

fix(python): filter packages by PEP 508 environment markers#491

Open
ruromero wants to merge 1 commit intoguacsec:mainfrom
ruromero:TC-4240
Open

fix(python): filter packages by PEP 508 environment markers#491
ruromero wants to merge 1 commit intoguacsec:mainfrom
ruromero:TC-4240

Conversation

@ruromero
Copy link
Copy Markdown
Collaborator

@ruromero ruromero commented Apr 24, 2026

Summary

  • Add shared PEP 508 environment marker evaluator (marker_evaluator.js) that maps Node.js platform values to Python marker variables and evaluates simple/compound marker expressions
  • Fix uv provider to check marker_spec nodes from tree-sitter parse and skip packages with non-matching platform markers (e.g., sys_platform == 'win32' on Linux/macOS)
  • Fix poetry provider to cross-reference poetry.lock dependency markers and pyproject.toml PEP 621 dependency strings, filtering out packages whose markers don't match the current environment

Test plan

  • 7 uv marker filtering tests (4 exclusion cases, 2 inclusion cases, 1 tree pruning)
  • 4 poetry marker filtering tests (3 exclusion cases including transitive + direct deps, 1 tree pruning)
  • All 11 new tests passing
  • No regressions in existing pyproject tests
  • Lint clean

Fixes: TC-4042 / TC-4240

🤖 Generated with Claude Code

Summary by Sourcery

Implement environment marker-aware dependency filtering for Python uv and Poetry providers to only include packages applicable to the current platform.

Bug Fixes:

  • Skip uv-export dependencies whose PEP 508 environment markers do not match the current runtime platform or Python version.
  • Filter Poetry direct and transitive dependencies based on PEP 508 markers derived from pyproject.toml and poetry.lock metadata so platform-specific packages are excluded when not applicable.

Enhancements:

  • Introduce a shared PEP 508 marker evaluator utility that maps Node.js environment data to Python marker variables and evaluates marker expressions.
  • Extend Python provider test coverage with new fixtures and cases validating inclusion/exclusion of dependencies and pruning of dependency trees based on environment markers.

Tests:

  • Add uv provider marker-filtering tests covering inclusion, exclusion, and dependency tree pruning scenarios.
  • Add Poetry provider marker-filtering tests for direct and transitive dependencies and tree pruning on non-matching markers.

… 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>
@sourcery-ai
Copy link
Copy Markdown

sourcery-ai Bot commented Apr 24, 2026

Reviewer's Guide

Implements PEP 508 environment marker evaluation shared utility and wires it into the uv and poetry Python providers so that dependencies whose markers do not match the current platform/python environment are excluded from the SBOM graph, with new fixtures and tests covering direct and transitive dependency filtering and tree pruning.

Sequence diagram for Poetry provider dependency resolution with marker filtering (sequence diagram)

sequenceDiagram
  participant Caller
  participant Python_poetry
  participant PoetryCLI as Poetry_CLI
  participant FileSystem
  participant MarkerEvaluator

  Caller->>Python_poetry: getDeps(manifestDir, opts)
  Python_poetry->>Python_poetry: _getPyproject(manifestDir)
  Python_poetry->>Python_poetry: _hasDevGroup(parsed, opts)

  Python_poetry->>Python_poetry: _getPoetryShowTreeOutput(manifestDir, hasDevGroup, opts)
  Python_poetry->>Python_poetry: _getPoetryShowAllOutput(manifestDir, opts)
  Python_poetry->>Python_poetry: _parsePoetryShowAll(showAllOutput) = versionMap

  Python_poetry->>Python_poetry: _findLockFileDir(manifestDir, opts) = lockDir
  Python_poetry->>Python_poetry: _extractMarkerData(lockDir, parsed)
  activate Python_poetry
  Python_poetry->>FileSystem: read pyproject.toml
  FileSystem-->>Python_poetry: dependencies list
  Python_poetry->>Python_poetry: collect directMarkers
  Python_poetry->>FileSystem: read poetry.lock (if present)
  FileSystem-->>Python_poetry: lock content
  Python_poetry->>Python_poetry: parseToml(lockContent)
  Python_poetry->>Python_poetry: collect transitiveMarkers
  deactivate Python_poetry

  Python_poetry->>Python_poetry: _parsePoetryTree(treeOutput, versionMap, markerData)
  activate Python_poetry
  loop each direct dependency line
    Python_poetry->>Python_poetry: lookup directMarkers[name]
    alt marker exists
      Python_poetry->>MarkerEvaluator: evaluateMarker(marker)
      MarkerEvaluator-->>Python_poetry: boolean
      alt evaluates to false
        Python_poetry->>Python_poetry: skip direct dependency
      else evaluates to true
        Python_poetry->>Python_poetry: add to graph as directDep
      end
    else no marker
      Python_poetry->>Python_poetry: add to graph as directDep
    end
  end

  loop each transitive dependency line
    Python_poetry->>Python_poetry: determine parentKey from stack
    Python_poetry->>Python_poetry: lookup transitiveMarkers[parentKey][depKey]
    alt marker exists
      Python_poetry->>MarkerEvaluator: evaluateMarker(marker)
      MarkerEvaluator-->>Python_poetry: boolean
      alt evaluates to false
        Python_poetry->>Python_poetry: continue (do not add child)
      else evaluates to true
        Python_poetry->>Python_poetry: add node and parentEntry.children
      end
    else no marker
      Python_poetry->>Python_poetry: add node and parentEntry.children
    end
  end
  deactivate Python_poetry

  Python_poetry-->>Caller: {directDeps, graph} (marker-filtered)
Loading

Class diagram for Python providers using marker_evaluator (class diagram)

classDiagram
class Base_pyproject {
  <<abstract>>
}

class Python_poetry {
  +getDeps(manifestDir, opts)
  -_extractMarkerData(lockDir, parsed)
  -_parsePoetryTree(treeOutput, versionMap, markerData)
  -_canonicalize(name)
  -_getPoetryShowTreeOutput(manifestDir, hasDevGroup, opts)
  -_getPoetryShowAllOutput(manifestDir, opts)
  -_findLockFileDir(manifestDir, opts)
  -_lockFileName()
}

class Python_uv {
  +getDeps(manifestDir, opts)
}

class MarkerEvaluator {
  +getEnvironmentMarkers() Record
  +evaluateMarker(markerExpr) boolean
  -getPythonVersion() string
  -compareVersions(left, right) number
  -evaluateComparison(variable, op, value, env) boolean
  -parseAtom(expr) Atom
  -evaluateExpr(expr, env) boolean
  -splitLogical(expr, sep) string[]
}

Base_pyproject <|-- Python_poetry
Base_pyproject <|-- Python_uv

Python_poetry ..> MarkerEvaluator : uses
Python_uv ..> MarkerEvaluator : uses

class Atom {
  +variable string
  +op string
  +value string
}
Loading

Flow diagram for PEP 508 marker evaluation logic (flow diagram)

flowchart TD
  A["start evaluateMarker(markerExpr)"] --> B{"markerExpr is empty or whitespace?"}
  B -->|yes| C["return true"]
  B -->|no| D["getEnvironmentMarkers()"]
  D --> E["evaluateExpr(expr, env)"]
  E --> F["return boolean"]

  subgraph EvaluateExpr
    E1["receive expr, env"] --> E2["expr contains ' or ' at top level?"]
    E2 -->|yes| E3["splitLogical(expr, ' or ')"]
    E3 --> E4["recursively evaluateExpr on each part"]
    E4 --> E5["return any(part is true)"]

    E2 -->|no| E6["expr contains ' and ' at top level?"]
    E6 -->|yes| E7["splitLogical(expr, ' and ')"]
    E7 --> E8["recursively evaluateExpr on each part"]
    E8 --> E9["return all(parts are true)"]

    E6 -->|no| E10["expr wrapped in parentheses?"]
    E10 -->|yes| E11["strip outer parentheses"]
    E11 --> E1

    E10 -->|no| E12["atom = parseAtom(expr)"]
    E12 --> E13["atom parsed?"]
    E13 -->|no| E14["return true"]
    E13 -->|yes| E15["evaluateComparison(atom.variable, atom.op, atom.value, env)"]
    E15 --> E16["return result"]
  end

  subgraph EvaluateComparison
    C1["lookup envVal = env&#91;variable&#93;"] --> C2["envVal is undefined or empty?"]
    C2 -->|yes and variable includes 'version'| C3["return true"]
    C2 -->|yes and not version variable| C4["return false"]
    C2 -->|no| C5["variable includes 'version'?"]

    C5 -->|yes| C6["cmp = compareVersions(envVal, value)"]
    C6 --> C7["op"]
    C7 -->|==| C8["return cmp == 0"]
    C7 -->|!=| C9["return cmp != 0"]
    C7 -->|>=| C10["return cmp >= 0"]
    C7 -->|<=| C11["return cmp <= 0"]
    C7 -->|>| C12["return cmp > 0"]
    C7 -->|<| C13["return cmp < 0"]
    C7 -->|~=| C14["check prefix match and cmp >= 0"]

    C5 -->|no| C15["op"]
    C15 -->|==| C16["return envVal == value"]
    C15 -->|!=| C17["return envVal != value"]
    C15 -->|in| C18["return envVal in value list"]
    C15 -->|not in| C19["return envVal not in value list"]
    C15 -->|other| C20["return envVal == value"]
  end
Loading

File-Level Changes

Change Details Files
Introduce shared PEP 508 environment marker evaluation utility and environment mapping used by Python providers.
  • Add marker_evaluator module that maps Node.js/os values to PEP 508 marker variables and computes the current marker environment.
  • Implement comparison and boolean-expression evaluation for simple and compound marker expressions (version-aware operators, equality, inequality, in/not in, and, or, parentheses).
  • Integrate marker evaluator with existing codebase via exported evaluateMarker and getEnvironmentMarkers functions.
src/providers/marker_evaluator.js
Update uv provider to skip packages whose PEP 508 marker expressions do not match the current environment when parsing uv export output.
  • Import evaluateMarker into the uv provider.
  • Detect marker_spec nodes from the tree-sitter parse of uv export lines and extract the marker expression text.
  • Evaluate marker expressions and, when they do not match, skip creating the corresponding package entry and stop collecting its transitive "via" relationships so it does not appear in the SBOM or dependency tree.
src/providers/python_uv.js
Update poetry provider to use pyproject and poetry.lock marker data to filter direct and transitive dependencies based on PEP 508 markers when building the dependency graph.
  • Import TOML parser and marker evaluator into the poetry provider.
  • Extract direct dependency marker expressions from PEP 621-style dependency strings in pyproject and store a canonicalized name → marker map.
  • Parse poetry.lock to build a map of parent-package → (child-package → marker) for transitive dependency markers.
  • Extend the poetry tree parser to accept marker metadata and skip direct dependencies whose markers do not match, pruning their subtrees, and to skip transitive children whose markers do not match, so parent nodes have no children where all are filtered out.
src/providers/python_poetry.js
Add focused tests and fixtures to validate uv and poetry marker-based dependency filtering and tree pruning behavior.
  • Add uv marker-filtering tests that inject various uv export contents via environment, asserting exclusion for non-matching markers, inclusion for matching markers, and that parents have no children when all are marker-filtered.
  • Add poetry marker-filtering tests that inject poetry show --tree and --all contents via environment, asserting correct inclusion/exclusion and tree pruning for both direct and transitive dependencies.
  • Introduce pyproject.toml and associated lockfile fixtures for uv_marker_filtering and poetry_marker_filtering test projects to drive the new tests.
test/providers/python_pyproject.test.js
test/providers/tst_manifests/pyproject/uv_marker_filtering/pyproject.toml
test/providers/tst_manifests/pyproject/poetry_marker_filtering/pyproject.toml
test/providers/tst_manifests/pyproject/poetry_marker_filtering/poetry.lock
test/providers/tst_manifests/pyproject/uv_marker_filtering/uv.lock

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link
Copy Markdown

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 3 issues, and left some high level feedback:

  • The marker evaluator silently returns true for unparseable atoms and unknown/empty version variables, which can hide incorrect marker expressions or environment detection issues; consider logging or failing closed in these cases to avoid unintentionally including dependencies.
  • The getPythonVersion helper runs python3 via execSync with each process start and assumes python3 is available; if this is only used for marker gating, you might want a configuration/override path or a cheaper, non-blocking default for environments without Python.
  • The regex used in _extractMarkerData to pull markers from project.dependencies is quite narrow (no extras, limited name charset) and may miss valid PEP 508 strings, so it could be worth broadening this parsing or reusing an existing marker/dependency parser to avoid silently ignoring markers.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The marker evaluator silently returns `true` for unparseable atoms and unknown/empty version variables, which can hide incorrect marker expressions or environment detection issues; consider logging or failing closed in these cases to avoid unintentionally including dependencies.
- The `getPythonVersion` helper runs `python3` via `execSync` with each process start and assumes `python3` is available; if this is only used for marker gating, you might want a configuration/override path or a cheaper, non-blocking default for environments without Python.
- The regex used in `_extractMarkerData` to pull markers from `project.dependencies` is quite narrow (no extras, limited name charset) and may miss valid PEP 508 strings, so it could be worth broadening this parsing or reusing an existing marker/dependency parser to avoid silently ignoring markers.

## Individual Comments

### Comment 1
<location path="src/providers/marker_evaluator.js" line_range="52-53" />
<code_context>
+
+function evaluateComparison(variable, op, value, env) {
+	let envVal = env[variable]
+	if (envVal === undefined || envVal === '') {
+		return variable.includes('version') ? true : false
+	}
+
</code_context>
<issue_to_address>
**issue (bug_risk):** Treating unknown version markers as `true` can incorrectly keep conditional deps enabled when the environment is unknown.

In this branch, `envVal` being `undefined`/empty for version-based markers always returns `true`, so if version detection fails (e.g., `python3` missing) `python_version < "3.8"` is treated as satisfied and its dependencies are always included. It would be safer to return `false` here (or at least surface the uncertainty) so version-guarded dependencies don’t silently become unconditional when environment detection fails.
</issue_to_address>

### Comment 2
<location path="src/providers/marker_evaluator.js" line_range="127-136" />
<code_context>
+	return evaluateComparison(atom.variable, atom.op, atom.value, env)
+}
+
+function splitLogical(expr, sep) {
+	let parts = []
+	let depth = 0
+	let current = ''
+	let i = 0
+	while (i < expr.length) {
+		if (expr[i] === '(') { depth++ }
+		if (expr[i] === ')') { depth-- }
+		if (depth === 0 && expr.substring(i, i + sep.length) === sep) {
+			parts.push(current)
+			current = ''
+			i += sep.length
+			continue
+		}
+		current += expr[i]
+		i++
+	}
+	parts.push(current)
+	return parts.filter(p => p.trim())
+}
</code_context>
<issue_to_address>
**issue:** Logical splitting doesn’t account for parentheses inside quoted strings, which can mis-parse valid marker expressions.

The helper tracks only parenthesis depth and doesn’t know when it’s inside a quoted string. Markers like `platform_version == "10.0 (Build 19045)"` will cause `depth` to change on the string’s parentheses and can trigger splits on `and`/`or` that are actually inside the string. This can mis-evaluate valid marker expressions. Consider also tracking single/double-quote state and ignoring parentheses and logical operators while inside a string literal.
</issue_to_address>

### Comment 3
<location path="test/providers/python_pyproject.test.js" line_range="346-355" />
<code_context>
+	suite('uv projects - PEP 508 marker filtering (TC-4042 reproducer)', () => {
</code_context>
<issue_to_address>
**suggestion (testing):** There are no direct unit tests for the generic marker evaluator; consider adding a focused test suite for it.

The uv and poetry suites exercise `evaluateMarker` only indirectly through provider behavior, but the shared `marker_evaluator` module has substantial logic (version comparisons, boolean ops, parentheses, `in`/`not in`, reversed operands, missing `python_version`) that isn’t explicitly tested.

To better document the intended PEP 508 semantics and catch regressions, consider a dedicated test file for the marker evaluator itself (e.g. `test/providers/marker_evaluator.test.js`) with targeted cases covering:
- Basic marker comparisons (e.g. `sys_platform`, `platform_system`, `os_name`).
- Version comparisons (`>=`, `<`, `~=`, and behavior when `python3` is missing).
- Logical combinations with `and`/`or` and parentheses.
- `in`/`not in` for non-version markers.
- Reversed expressions (e.g. `'linux' == sys_platform`).

These unit-style tests would complement the provider-level integration tests and increase confidence in marker parsing and evaluation changes.

Suggested implementation:

```javascript
	suite('uv projects - PEP 508 marker filtering (TC-4042 reproducer)', () => {
		const fixtureDir = `${MANIFESTS}/uv_marker_filtering`

		function makeUvExport(markerLines) {
			let lines = [
				'click==8.3.1',
				'    # via marker-test',
				'six==1.16.0',
				'    # via marker-test',
			]
			for (let ml of markerLines) {
			}
		}

		// existing uv marker filtering tests...
	})

	suite('marker evaluator - PEP 508 semantics', () => {
		// Helper to evaluate a marker with a default environment, allowing overrides per test
		function evalMarker(marker, overrides = {}) {
			const baseEnv = {
				python_version: '3.11',
				python_full_version: '3.11.2',
				platform_system: 'Linux',
				sys_platform: 'linux',
				os_name: 'posix',
			}

			return evaluateMarker(marker, { ...baseEnv, ...overrides })
		}

		test('basic non-version comparisons (sys_platform, platform_system, os_name)', () => {
			// Positive cases
			assert.strictEqual(evalMarker("sys_platform == 'linux'"), true)
			assert.strictEqual(evalMarker("platform_system == 'Linux'"), true)
			assert.strictEqual(evalMarker("os_name == 'posix'"), true)

			// Negative cases
			assert.strictEqual(evalMarker("sys_platform == 'win32'"), false)
			assert.strictEqual(evalMarker("platform_system != 'Linux'"), false)
			assert.strictEqual(evalMarker("os_name != 'posix'"), false)
		})

		test('version comparisons with python_version and python_full_version', () => {
			// python_version uses PEP 440 style normalization
			assert.strictEqual(evalMarker("python_version >= '3.10'"), true)
			assert.strictEqual(evalMarker("python_version < '3.12'"), true)
			assert.strictEqual(evalMarker("python_version < '3.11'"), false)

			// python_full_version comparisons
			assert.strictEqual(evalMarker("python_full_version == '3.11.2'"), true)
			assert.strictEqual(evalMarker("python_full_version != '3.11.2'"), false)
			assert.strictEqual(evalMarker("python_full_version >= '3.11.0'"), true)
		})

		test('version compatible release operator (~=)', () => {
			// 3.11.2 ~= 3.11 matches
			assert.strictEqual(evalMarker("python_version ~= '3.11'"), true)

			// 3.11.2 should not be compatible with 3.10
			assert.strictEqual(evalMarker("python_version ~= '3.10'"), false)
		})

		test('behavior when python_version is missing from environment', () => {
			// When python_version is missing, comparisons should generally be false
			const envWithoutPython = {
				python_version: undefined,
				python_full_version: undefined,
			}

			assert.strictEqual(
				evalMarker("python_version >= '3.10'", envWithoutPython),
				false
			)
			assert.strictEqual(
				evalMarker("python_full_version == '3.11.2'", envWithoutPython),
				false
			)
		})

		test('logical combinations with and/or and parentheses', () => {
			// (linux or darwin) and CPython 3.11
			assert.strictEqual(
				evalMarker(
					"(sys_platform == 'linux' or sys_platform == 'darwin') and python_version == '3.11'"
				),
				true
			)

			// change platform to win32 -> whole expression becomes false
			assert.strictEqual(
				evalMarker(
					"(sys_platform == 'linux' or sys_platform == 'darwin') and python_version == '3.11'",
					{ sys_platform: 'win32' }
				),
				false
			)

			// or has lower precedence than and; parentheses change meaning
			assert.strictEqual(
				evalMarker(
					"sys_platform == 'linux' and python_version == '3.11' or python_version == '3.10'"
				),
				true
			)
			assert.strictEqual(
				evalMarker(
					"(sys_platform == 'linux' and python_version == '3.10') or python_version == '3.11'"
				),
				true
			)
			assert.strictEqual(
				evalMarker(
					"(sys_platform == 'linux' and python_version == '3.10') or python_version == '3.10'"
				),
				false
			)
		})

		test("'in' and 'not in' for non-version markers', () => {
			// membership checks
			assert.strictEqual(
				evalMarker("sys_platform in 'linux darwin'"),
				true
			)
			assert.strictEqual(
				evalMarker("sys_platform in 'win32 cygwin'"),
				false
			)

			assert.strictEqual(
				evalMarker("platform_system not in 'Windows Darwin'"),
				true
			)
			assert.strictEqual(
				evalMarker("platform_system not in 'Linux Darwin'"),
				false
			)
		})

		test('reversed comparison operands', () => {
			// String literal on the left-hand side
			assert.strictEqual(evalMarker("'linux' == sys_platform"), true)
			assert.strictEqual(evalMarker("'Linux' == platform_system"), true)
			assert.strictEqual(evalMarker("'posix' == os_name"), true)

			assert.strictEqual(evalMarker("'win32' == sys_platform"), false)
			assert.strictEqual(evalMarker("'Windows' == platform_system"), false)

			// Reversed version comparisons
			assert.strictEqual(evalMarker("'3.11' <= python_version"), true)
			assert.strictEqual(evalMarker("'3.12' <= python_version"), false)
		})

```

1. Ensure that `evaluateMarker` and `assert` are imported/available in this test file. For example, near the top of `test/providers/python_pyproject.test.js`, add something like:
   - `const assert = require('assert')` (if not already present).
   - `const { evaluateMarker } = require('../../../src/providers/python/marker_evaluator')` (adjust the relative path to match your project layout and how other provider utilities are imported).
2. The base environment keys in `evalMarker` (`python_version`, `python_full_version`, `platform_system`, `sys_platform`, `os_name`) must match what `marker_evaluator` actually expects. If the module uses different variable names (e.g. `platform_system` vs `platform_system_name`), update the keys accordingly.
3. If your test runner uses a different global (`describe`/`it` instead of `suite`/`test`), adapt the new suite and test calls to be consistent with the rest of this file.
4. If your marker evaluator has a different signature (e.g. `evaluateMarker(marker, context, opts)`), pass any additional required parameters in `evalMarker` so the tests exercise the real runtime behavior.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +52 to +53
if (envVal === undefined || envVal === '') {
return variable.includes('version') ? true : false
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): Treating unknown version markers as true can incorrectly keep conditional deps enabled when the environment is unknown.

In this branch, envVal being undefined/empty for version-based markers always returns true, so if version detection fails (e.g., python3 missing) python_version < "3.8" is treated as satisfied and its dependencies are always included. It would be safer to return false here (or at least surface the uncertainty) so version-guarded dependencies don’t silently become unconditional when environment detection fails.

Comment on lines +127 to +136
function splitLogical(expr, sep) {
let parts = []
let depth = 0
let current = ''
let i = 0
while (i < expr.length) {
if (expr[i] === '(') { depth++ }
if (expr[i] === ')') { depth-- }
if (depth === 0 && expr.substring(i, i + sep.length) === sep) {
parts.push(current)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: Logical splitting doesn’t account for parentheses inside quoted strings, which can mis-parse valid marker expressions.

The helper tracks only parenthesis depth and doesn’t know when it’s inside a quoted string. Markers like platform_version == "10.0 (Build 19045)" will cause depth to change on the string’s parentheses and can trigger splits on and/or that are actually inside the string. This can mis-evaluate valid marker expressions. Consider also tracking single/double-quote state and ignoring parentheses and logical operators while inside a string literal.

Comment on lines +346 to +355
suite('uv projects - PEP 508 marker filtering (TC-4042 reproducer)', () => {
const fixtureDir = `${MANIFESTS}/uv_marker_filtering`

function makeUvExport(markerLines) {
let lines = [
'click==8.3.1',
' # via marker-test',
'six==1.16.0',
' # via marker-test',
]
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): There are no direct unit tests for the generic marker evaluator; consider adding a focused test suite for it.

The uv and poetry suites exercise evaluateMarker only indirectly through provider behavior, but the shared marker_evaluator module has substantial logic (version comparisons, boolean ops, parentheses, in/not in, reversed operands, missing python_version) that isn’t explicitly tested.

To better document the intended PEP 508 semantics and catch regressions, consider a dedicated test file for the marker evaluator itself (e.g. test/providers/marker_evaluator.test.js) with targeted cases covering:

  • Basic marker comparisons (e.g. sys_platform, platform_system, os_name).
  • Version comparisons (>=, <, ~=, and behavior when python3 is missing).
  • Logical combinations with and/or and parentheses.
  • in/not in for non-version markers.
  • Reversed expressions (e.g. 'linux' == sys_platform).

These unit-style tests would complement the provider-level integration tests and increase confidence in marker parsing and evaluation changes.

Suggested implementation:

	suite('uv projects - PEP 508 marker filtering (TC-4042 reproducer)', () => {
		const fixtureDir = `${MANIFESTS}/uv_marker_filtering`

		function makeUvExport(markerLines) {
			let lines = [
				'click==8.3.1',
				'    # via marker-test',
				'six==1.16.0',
				'    # via marker-test',
			]
			for (let ml of markerLines) {
			}
		}

		// existing uv marker filtering tests...
	})

	suite('marker evaluator - PEP 508 semantics', () => {
		// Helper to evaluate a marker with a default environment, allowing overrides per test
		function evalMarker(marker, overrides = {}) {
			const baseEnv = {
				python_version: '3.11',
				python_full_version: '3.11.2',
				platform_system: 'Linux',
				sys_platform: 'linux',
				os_name: 'posix',
			}

			return evaluateMarker(marker, { ...baseEnv, ...overrides })
		}

		test('basic non-version comparisons (sys_platform, platform_system, os_name)', () => {
			// Positive cases
			assert.strictEqual(evalMarker("sys_platform == 'linux'"), true)
			assert.strictEqual(evalMarker("platform_system == 'Linux'"), true)
			assert.strictEqual(evalMarker("os_name == 'posix'"), true)

			// Negative cases
			assert.strictEqual(evalMarker("sys_platform == 'win32'"), false)
			assert.strictEqual(evalMarker("platform_system != 'Linux'"), false)
			assert.strictEqual(evalMarker("os_name != 'posix'"), false)
		})

		test('version comparisons with python_version and python_full_version', () => {
			// python_version uses PEP 440 style normalization
			assert.strictEqual(evalMarker("python_version >= '3.10'"), true)
			assert.strictEqual(evalMarker("python_version < '3.12'"), true)
			assert.strictEqual(evalMarker("python_version < '3.11'"), false)

			// python_full_version comparisons
			assert.strictEqual(evalMarker("python_full_version == '3.11.2'"), true)
			assert.strictEqual(evalMarker("python_full_version != '3.11.2'"), false)
			assert.strictEqual(evalMarker("python_full_version >= '3.11.0'"), true)
		})

		test('version compatible release operator (~=)', () => {
			// 3.11.2 ~= 3.11 matches
			assert.strictEqual(evalMarker("python_version ~= '3.11'"), true)

			// 3.11.2 should not be compatible with 3.10
			assert.strictEqual(evalMarker("python_version ~= '3.10'"), false)
		})

		test('behavior when python_version is missing from environment', () => {
			// When python_version is missing, comparisons should generally be false
			const envWithoutPython = {
				python_version: undefined,
				python_full_version: undefined,
			}

			assert.strictEqual(
				evalMarker("python_version >= '3.10'", envWithoutPython),
				false
			)
			assert.strictEqual(
				evalMarker("python_full_version == '3.11.2'", envWithoutPython),
				false
			)
		})

		test('logical combinations with and/or and parentheses', () => {
			// (linux or darwin) and CPython 3.11
			assert.strictEqual(
				evalMarker(
					"(sys_platform == 'linux' or sys_platform == 'darwin') and python_version == '3.11'"
				),
				true
			)

			// change platform to win32 -> whole expression becomes false
			assert.strictEqual(
				evalMarker(
					"(sys_platform == 'linux' or sys_platform == 'darwin') and python_version == '3.11'",
					{ sys_platform: 'win32' }
				),
				false
			)

			// or has lower precedence than and; parentheses change meaning
			assert.strictEqual(
				evalMarker(
					"sys_platform == 'linux' and python_version == '3.11' or python_version == '3.10'"
				),
				true
			)
			assert.strictEqual(
				evalMarker(
					"(sys_platform == 'linux' and python_version == '3.10') or python_version == '3.11'"
				),
				true
			)
			assert.strictEqual(
				evalMarker(
					"(sys_platform == 'linux' and python_version == '3.10') or python_version == '3.10'"
				),
				false
			)
		})

		test("'in' and 'not in' for non-version markers', () => {
			// membership checks
			assert.strictEqual(
				evalMarker("sys_platform in 'linux darwin'"),
				true
			)
			assert.strictEqual(
				evalMarker("sys_platform in 'win32 cygwin'"),
				false
			)

			assert.strictEqual(
				evalMarker("platform_system not in 'Windows Darwin'"),
				true
			)
			assert.strictEqual(
				evalMarker("platform_system not in 'Linux Darwin'"),
				false
			)
		})

		test('reversed comparison operands', () => {
			// String literal on the left-hand side
			assert.strictEqual(evalMarker("'linux' == sys_platform"), true)
			assert.strictEqual(evalMarker("'Linux' == platform_system"), true)
			assert.strictEqual(evalMarker("'posix' == os_name"), true)

			assert.strictEqual(evalMarker("'win32' == sys_platform"), false)
			assert.strictEqual(evalMarker("'Windows' == platform_system"), false)

			// Reversed version comparisons
			assert.strictEqual(evalMarker("'3.11' <= python_version"), true)
			assert.strictEqual(evalMarker("'3.12' <= python_version"), false)
		})
  1. Ensure that evaluateMarker and assert are imported/available in this test file. For example, near the top of test/providers/python_pyproject.test.js, add something like:
    • const assert = require('assert') (if not already present).
    • const { evaluateMarker } = require('../../../src/providers/python/marker_evaluator') (adjust the relative path to match your project layout and how other provider utilities are imported).
  2. The base environment keys in evalMarker (python_version, python_full_version, platform_system, sys_platform, os_name) must match what marker_evaluator actually expects. If the module uses different variable names (e.g. platform_system vs platform_system_name), update the keys accordingly.
  3. If your test runner uses a different global (describe/it instead of suite/test), adapt the new suite and test calls to be consistent with the rest of this file.
  4. If your marker evaluator has a different signature (e.g. evaluateMarker(marker, context, opts)), pass any additional required parameters in evalMarker so the tests exercise the real runtime behavior.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant