Skip to content
Open
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
52 changes: 48 additions & 4 deletions src/packaging/markers.py
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ def _format_marker(
return marker


_operators: dict[str, Operator] = {
_OPERATORS: dict[str, Operator] = {
"in": lambda lhs, rhs: lhs in rhs,
"not in": lambda lhs, rhs: lhs not in rhs,
"<": lambda _lhs, _rhs: False,
Expand All @@ -212,8 +212,38 @@ def _format_marker(
">": lambda _lhs, _rhs: False,
}

# Operator inversion map: lhs op rhs ⟺ rhs (inv op) lhs
_OP_INVERSION: dict[str, str] = {
"<": ">",
">": "<",
"<=": ">=",
">=": "<=",
"==": "==",
"!=": "!=",
"in": "in",
"not in": "not in",
}


def _eval_op(
lhs: str, op: Op, rhs: str | AbstractSet[str], *, key: str, invert: bool = False
) -> bool:
"""Evaluate a marker comparison.

When *invert* is ``True``, the caller passed operands in reversed order
(because the marker variable was on the RHS). For version markers we
swap them back and flip the operator so ``_eval_op`` always sees the
canonical ``env-value op spec-pattern`` order. For set-based markers
(extras / dependency_groups with ``in`` / ``not in``) the original
``literal-in-set`` order is already correct, so no swap is needed.
"""
# Only swap for directional comparison operators. Membership operators
# (in / not in) perform a substring or set-membership check where the
# original order (literal lhs, env rhs) is already correct.
if invert and op.value not in ("in", "not in") and key in MARKERS_REQUIRING_VERSION:
lhs, rhs = cast("str", rhs), cast("str | AbstractSet[str]", lhs)
op = Op(_OP_INVERSION[op.value])

def _eval_op(lhs: str, op: Op, rhs: str | AbstractSet[str], *, key: str) -> bool:
op_str = op.serialize()
if key in MARKERS_REQUIRING_VERSION:
try:
Expand All @@ -223,7 +253,7 @@ def _eval_op(lhs: str, op: Op, rhs: str | AbstractSet[str], *, key: str) -> bool
else:
return spec.contains(lhs, prereleases=True)

oper: Operator | None = _operators.get(op_str)
oper: Operator | None = _OPERATORS.get(op_str)
if oper is None:
raise UndefinedComparison(f"Undefined {op!r} on {lhs!r} and {rhs!r}.")

Expand Down Expand Up @@ -273,7 +303,21 @@ def _evaluate_markers(

assert isinstance(lhs_value, str), "lhs must be a string"
lhs_value, rhs_value = _normalize(lhs_value, rhs_value, key=environment_key)
groups[-1].append(_eval_op(lhs_value, op, rhs_value, key=environment_key))

# When the marker variable is on the RHS, tell _eval_op so it can
# swap operands back for version comparison markers. For set-based
# markers (in / not in with extras / dependency_groups) the
# original order already works, so _eval_op skips the swap.
var_on_rhs = isinstance(rhs, Variable)
groups[-1].append(
_eval_op(
lhs_value,
op,
rhs_value,
key=environment_key,
invert=var_on_rhs,
)
)
elif marker == "or":
groups.append([])
elif marker == "and":
Expand Down
57 changes: 57 additions & 0 deletions tests/test_markers.py
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,63 @@ def test_version_like_equality(
marker = Marker(marker_string)
assert marker.evaluate(environment) is expected

@pytest.mark.parametrize(
("marker_string", "environment", "expected"),
[
# Issue #934: marker variable on RHS should work like LHS
(
"'3.13.*' == python_full_version",
{"python_full_version": "3.13.7"},
True,
),
(
"'3.13.*' == python_full_version",
{"python_full_version": "3.14.0"},
False,
),
(
"'3.9' <= python_version",
{"python_version": "3.13"},
True,
),
(
"'3.14' > python_version",
{"python_version": "3.13"},
True,
),
(
"'3.14' > python_version",
{"python_version": "3.15"},
False,
),
# Non-version markers with variable on RHS
(
"'posix' == os_name",
{"os_name": "posix"},
True,
),
(
"'nt' != os_name",
{"os_name": "posix"},
True,
),
],
)
def test_marker_variable_on_rhs(
self, marker_string: str, environment: dict[str, str], expected: bool
) -> None:
"""
Test for issue #934: Marker version comparison fails when the marker
variable is on the RHS of a term.

The spec allows marker variables on either side, e.g.::

python_version >= '3.9'
'3.9' <= python_version
"""
marker = Marker(marker_string)
assert marker.evaluate(environment) is expected


def test_and_operator_evaluates_true() -> None:
env = {"python_version": "3.8", "os_name": "posix"}
Expand Down
Loading