From c5921ef6377df6c9719f9123f47d2f596f49548e Mon Sep 17 00:00:00 2001 From: Dan Yeaw Date: Fri, 3 Apr 2026 10:20:39 -0400 Subject: [PATCH 1/3] Add API for markers as a structured tree Expose Marker.as_ast() returning MarkerCompare, MarkerAnd, and MarkerOr nodes so callers can walk markers without using private _markers. --- CHANGELOG.rst | 4 +- docs/markers.rst | 42 ++++++- src/packaging/markers.py | 132 ++++++++++++++++++++- tests/test_markers.py | 244 +++++++++++++++++++++++++++------------ 4 files changed, 343 insertions(+), 79 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index fc4a0fa9a..947da25bf 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -4,7 +4,9 @@ Changelog *unreleased* ~~~~~~~~~~~~ -No unreleased changes. +Features: + +* Expose a public structured marker tree via ``Marker.as_ast()`` (:pull:`1145`) 26.2 - 2026-04-24 ~~~~~~~~~~~~~~~~~ diff --git a/docs/markers.rst b/docs/markers.rst index 6bf3f2a7d..ac3a50eac 100644 --- a/docs/markers.rst +++ b/docs/markers.rst @@ -63,8 +63,45 @@ combining markers without manually constructing marker strings: This is equivalent to writing the combined marker string directly but is useful when building markers dynamically from separate conditions. -.. versionadded:: 26.1 +Structured marker tree +^^^^^^^^^^^^^^^^^^^^^^ + +:meth:`Marker.as_ast` returns an immutable tree of :class:`MarkerCompare`, +:class:`MarkerAnd`, and :class:`MarkerOr` nodes (see :class:`MarkerNode`). The +string form preserves PEP 508 ``and`` / ``or`` precedence. + +.. doctest:: + + >>> from packaging.markers import Marker, MarkerCompare, MarkerAnd, MarkerOr + >>> py_at_least_310 = Marker('python_version >= "3.10"') + >>> posix = Marker('os_name == "posix"') + >>> py_at_least_310 & posix + = "3.10" and os_name == "posix"')> + >>> windows = Marker('sys_platform == "win32"') + >>> macos = Marker('sys_platform == "darwin"') + >>> windows | macos + + >>> expr = Marker( + ... "python_version > '3.12' or (python_version == '3.12' and os_name == 'unix')" + ... ) + >>> node = expr.as_ast() + >>> isinstance(node, MarkerOr) + True + >>> len(node.operands) + 2 + >>> isinstance(node.operands[0], MarkerCompare) + True + >>> (node.operands[0].left, node.operands[0].op, node.operands[0].right) + ('python_version', '>', '3.12') + >>> isinstance(node.operands[1], MarkerAnd) + True + >>> [type(p).__name__ for p in node.operands[1].operands] + ['MarkerCompare', 'MarkerCompare'] +.. versionadded:: 26.1 + :class:`Marker` supports ``&`` and ``|`` for composition. :meth:`~Marker.as_ast` + and the :class:`MarkerCompare`, :class:`MarkerAnd`, :class:`MarkerOr`, and + :class:`MarkerNode` types expose the parsed marker as a tree. Reference --------- @@ -73,3 +110,6 @@ Reference :members: :special-members: __and__, __or__ :exclude-members: __init__ + +.. autodata:: MarkerCompareOp + :no-value: diff --git a/src/packaging/markers.py b/src/packaging/markers.py index 203838845..06ae981e8 100644 --- a/src/packaging/markers.py +++ b/src/packaging/markers.py @@ -9,9 +9,28 @@ import platform import sys from collections.abc import Set as AbstractSet -from typing import TYPE_CHECKING, Callable, Literal, TypedDict, Union, cast +from dataclasses import dataclass +from typing import ( + TYPE_CHECKING, + AbstractSet, + Callable, + Literal, + TypedDict, + Union, + cast, +) -from ._parser import MarkerAtom, MarkerList, Op, Value, Variable +if TYPE_CHECKING: + from typing_extensions import TypeAlias + +from ._parser import ( + MarkerAtom, + MarkerItem, + MarkerList, + Op, + Value, + Variable, +) from ._parser import parse_marker as _parse_marker from ._tokenizer import ParserSyntaxError from .specifiers import InvalidSpecifier, Specifier @@ -25,6 +44,11 @@ "EvaluateContext", "InvalidMarker", "Marker", + "MarkerAnd", + "MarkerCompare", + "MarkerCompareOp", + "MarkerNode", + "MarkerOr", "UndefinedComparison", "UndefinedEnvironmentName", "default_environment", @@ -318,6 +342,97 @@ def default_environment() -> Environment: } +MarkerCompareOp = Literal[ + "===", + "==", + "!=", + "<", + "<=", + ">", + ">=", + "~=", + "in", + "not in", +] + + +@dataclass(frozen=True) +class MarkerCompare: + """One comparison from a marker expression (a PEP 508 atom). + + *left*, *op*, and *right* match the parse tree. Each side is the string form + of a variable name or literal value (without surrounding quote characters). + """ + + left: str + op: MarkerCompareOp + right: str + + +@dataclass(frozen=True) +class MarkerAnd: + """The boolean ``and`` of one or more sub-expressions.""" + + operands: tuple[MarkerNode, ...] + + +@dataclass(frozen=True) +class MarkerOr: + """The boolean ``or`` of one or more sub-expressions.""" + + operands: tuple[MarkerNode, ...] + + +MarkerNode: TypeAlias = Union[MarkerCompare, MarkerAnd, MarkerOr] +"""A marker expression: comparison, conjunction, or disjunction.""" + + +def _split_marker_or_groups(markers: MarkerList) -> list[list[MarkerList | MarkerItem]]: + """Split a marker list into ``or`` groups (each group is ``and``-combined).""" + groups: list[list[MarkerList | MarkerItem]] = [[]] + for item in markers: + if item == "or": + groups.append([]) + elif item == "and": + continue + else: + groups[-1].append(cast("MarkerList | MarkerItem", item)) + return groups + + +def _marker_expr_to_node(item: MarkerList | MarkerItem) -> MarkerNode: + if isinstance(item, list): + inner = _markers_to_ast(item) + if inner is None: + raise InvalidMarker("empty parenthesized marker expression") + return inner + lhs, op, rhs = item + return MarkerCompare(lhs.value, cast("MarkerCompareOp", op.value), rhs.value) + + +def _markers_to_ast(markers: MarkerList) -> MarkerNode | None: + """Build a public AST from the internal marker list representation.""" + if not markers: + return None + + or_groups = _split_marker_or_groups(markers) + or_operands: list[MarkerNode] = [] + for group in or_groups: + if not group: + continue + and_parts = [_marker_expr_to_node(item) for item in group] + if len(and_parts) == 1: + or_operands.append(and_parts[0]) + else: + or_operands.append(MarkerAnd(tuple(and_parts))) + + if not or_operands: + return None + if len(or_operands) == 1: + return or_operands[0] + return MarkerOr(tuple(or_operands)) + + class Marker: """Represents a parsed dependency marker expression. @@ -434,6 +549,19 @@ def __or__(self, other: Marker) -> Marker: return NotImplemented return self._from_markers([self._markers, "or", other._markers]) + def as_ast(self) -> MarkerNode | None: + """Return a structured tree for this marker. + + The tree uses :class:`MarkerCompare`, :class:`MarkerAnd`, and + :class:`MarkerOr`. It preserves PEP 508 ``and`` / ``or`` precedence. + Parenthesized sub-expressions are nested nodes. + + :returns: The root node, or ``None`` if the internal marker list is empty + (a vacuous marker, which :meth:`evaluate` treats as true). + + """ + return _markers_to_ast(self._markers) + def evaluate( self, environment: Mapping[str, str | AbstractSet[str]] | None = None, diff --git a/tests/test_markers.py b/tests/test_markers.py index ffad27959..be4ad2a09 100644 --- a/tests/test_markers.py +++ b/tests/test_markers.py @@ -14,14 +14,19 @@ import pytest -from packaging._parser import Node, Op, Value, Variable +from packaging._parser import Node, Op, Value, Variable, parse_marker from packaging.markers import ( InvalidMarker, Marker, + MarkerAnd, + MarkerCompare, + MarkerNode, + MarkerOr, UndefinedComparison, _format_full_version, default_environment, ) +from packaging.requirements import Requirement VARIABLES = [ "extra", @@ -488,83 +493,172 @@ def test_version_like_equality( assert marker.evaluate(environment) is expected -def test_and_operator_evaluates_true() -> None: - env = {"python_version": "3.8", "os_name": "posix"} - - m = Marker('python_version >= "3.6"') & Marker('os_name == "posix"') - assert m.evaluate(env) is True - - -def test_and_operator_str_equality() -> None: - a = Marker('python_version >= "3.6" and os_name == "posix"') - b = Marker('python_version >= "3.6"') & Marker('os_name == "posix"') - assert a == b - assert str(a) == str(b) - - -def test_or_operator_evaluates_true() -> None: - env = {"python_version": "3.7", "os_name": "windows"} - - m = Marker('python_version < "3.6"') | Marker('os_name == "windows"') - assert m.evaluate(env) is True - - -def test_or_operator_str_equality() -> None: - a = Marker('python_version < "3.6" or os_name == "windows"') - b = Marker('python_version < "3.6"') | Marker('os_name == "windows"') - assert a == b - assert str(a) == str(b) - - -def test_operator_rejects_non_marker() -> None: - m = Marker('python_version >= "3.6"') - # dunder returns NotImplemented for non-Marker - assert m.__and__(cast("Any", "not-a-marker")) is NotImplemented - assert m.__or__(cast("Any", 123)) is NotImplemented - - -def test_inplace_operators_fallback() -> None: - m = Marker('python_version >= "3.6"') - m &= Marker('os_name == "posix"') - assert isinstance(m, Marker) - assert m == Marker('python_version >= "3.6"') & Marker('os_name == "posix"') - - -def test_right_hand_ops_and_typeerror() -> None: - m = Marker('python_version >= "3.6"') - assert m.__and__(cast("Any", "x")) is NotImplemented - with pytest.raises(TypeError): - cast("Any", "not-a-marker") & Marker('python_version >= "3.6"') - - -def test_chaining_associativity_and_str() -> None: - a = Marker( - '(python_version >= "3.6" and os_name == "posix") ' - 'and platform_system == "Linux"' - ) - b = ( - Marker('python_version >= "3.6"') - & Marker('os_name == "posix"') - & Marker('platform_system == "Linux"') - ) - assert a == b - assert str(a) == str(b) - +class TestMarkerAsAST: + def test_evaluation_of_combined_markers(self) -> None: + env = {"python_version": "3.8", "os_name": "posix", "platform_system": "Linux"} + m = ( + Marker('python_version >= "3.6"') + & Marker('os_name == "posix"') + & Marker('platform_system == "Linux"') + ) + assert m.evaluate(env) is True -def test_hash_eq_for_combined_markers() -> None: - assert hash(Marker('python_version >= "3.6" and os_name == "posix"')) == hash( - Marker('python_version >= "3.6"') & Marker('os_name == "posix"') + @pytest.mark.parametrize( + ("marker_string", "expected"), + [ + ( + 'python_version == "2.7"', + MarkerCompare("python_version", "==", "2.7"), + ), + ( + '"2.7" == python_version', + MarkerCompare("2.7", "==", "python_version"), + ), + ( + 'python_version == "2.7" and os_name == "linux"', + MarkerAnd( + ( + MarkerCompare("python_version", "==", "2.7"), + MarkerCompare("os_name", "==", "linux"), + ) + ), + ), + ( + 'python_version == "2.7" or os_name == "linux"', + MarkerOr( + ( + MarkerCompare("python_version", "==", "2.7"), + MarkerCompare("os_name", "==", "linux"), + ) + ), + ), + ( + 'python_version == "2.7" and os_name == "linux" or ' + 'sys_platform == "win32"', + MarkerOr( + ( + MarkerAnd( + ( + MarkerCompare("python_version", "==", "2.7"), + MarkerCompare("os_name", "==", "linux"), + ) + ), + MarkerCompare("sys_platform", "==", "win32"), + ) + ), + ), + ( + 'python_version == "2.7" and (sys_platform == "win32" or ' + 'sys_platform == "linux")', + MarkerAnd( + ( + MarkerCompare("python_version", "==", "2.7"), + MarkerOr( + ( + MarkerCompare("sys_platform", "==", "win32"), + MarkerCompare("sys_platform", "==", "linux"), + ) + ), + ) + ), + ), + ( + '"dev" in dependency_groups', + MarkerCompare("dev", "in", "dependency_groups"), + ), + ], ) + def test_as_ast(self, marker_string: str, expected: MarkerNode) -> None: + marker = Marker(marker_string) + assert marker.as_ast() == expected + + def test_requirement_marker_as_ast(self) -> None: + requirement = Requirement('foo; os_name == "posix"') + assert requirement.marker is not None + assert requirement.marker.as_ast() == MarkerCompare("os_name", "==", "posix") + + def test_as_ast_none_when_markers_empty(self) -> None: + marker = Marker.__new__(Marker) + marker._markers = [] + assert marker.as_ast() is None + + def test_as_ast_none_when_only_or_separators(self) -> None: + marker = Marker.__new__(Marker) + marker._markers = ["or"] + assert marker.as_ast() is None + + def test_as_ast_skips_empty_or_group(self) -> None: + marker = Marker.__new__(Marker) + marker._markers = ["or", *parse_marker('os_name == "posix"')] + assert marker.as_ast() == MarkerCompare("os_name", "==", "posix") + + def test_invalid_empty_parenthesized_subexpression(self) -> None: + marker = Marker.__new__(Marker) + marker._markers = [[]] + with pytest.raises(InvalidMarker, match="empty parenthesized"): + _ = marker.as_ast() + + +class TestMarkerOperators: + def test_and_operator_evaluates_true(self) -> None: + env = {"python_version": "3.8", "os_name": "posix"} + + m = Marker('python_version >= "3.6"') & Marker('os_name == "posix"') + assert m.evaluate(env) is True + + def test_and_operator_str_equality(self) -> None: + a = Marker('python_version >= "3.6" and os_name == "posix"') + b = Marker('python_version >= "3.6"') & Marker('os_name == "posix"') + assert a == b + assert str(a) == str(b) + + def test_or_operator_evaluates_true(self) -> None: + env = {"python_version": "3.7", "os_name": "windows"} + + m = Marker('python_version < "3.6"') | Marker('os_name == "windows"') + assert m.evaluate(env) is True + + def test_or_operator_str_equality(self) -> None: + a = Marker('python_version < "3.6" or os_name == "windows"') + b = Marker('python_version < "3.6"') | Marker('os_name == "windows"') + assert a == b + assert str(a) == str(b) + + def test_operator_rejects_non_marker(self) -> None: + m = Marker('python_version >= "3.6"') + # dunder returns NotImplemented for non-Marker + assert m.__and__(cast("Any", "not-a-marker")) is NotImplemented + assert m.__or__(cast("Any", 123)) is NotImplemented + + def test_inplace_operators_fallback(self) -> None: + m = Marker('python_version >= "3.6"') + m &= Marker('os_name == "posix"') + assert isinstance(m, Marker) + assert m == Marker('python_version >= "3.6"') & Marker('os_name == "posix"') + + def test_right_hand_ops_and_typeerror(self) -> None: + m = Marker('python_version >= "3.6"') + assert m.__and__(cast("Any", "x")) is NotImplemented + with pytest.raises(TypeError): + cast("Any", "not-a-marker") & Marker('python_version >= "3.6"') + + def test_chaining_associativity_and_str(self) -> None: + a = Marker( + '(python_version >= "3.6" and os_name == "posix") ' + 'and platform_system == "Linux"' + ) + b = ( + Marker('python_version >= "3.6"') + & Marker('os_name == "posix"') + & Marker('platform_system == "Linux"') + ) + assert a == b + assert str(a) == str(b) - -def test_evaluation_of_combined_markers() -> None: - env = {"python_version": "3.8", "os_name": "posix", "platform_system": "Linux"} - m = ( - Marker('python_version >= "3.6"') - & Marker('os_name == "posix"') - & Marker('platform_system == "Linux"') - ) - assert m.evaluate(env) is True + def test_hash_eq_for_combined_markers(self) -> None: + assert hash(Marker('python_version >= "3.6" and os_name == "posix"')) == hash( + Marker('python_version >= "3.6"') & Marker('os_name == "posix"') + ) @pytest.mark.parametrize( From 6def6804c93a74f1ffa47c86ed0858f91b1fb47c Mon Sep 17 00:00:00 2001 From: Dan Yeaw Date: Sun, 26 Apr 2026 20:24:03 -0400 Subject: [PATCH 2/3] Add benchmark for as_ast --- benchmarks/markers.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/benchmarks/markers.py b/benchmarks/markers.py index 7fdff4652..5085ab90e 100644 --- a/benchmarks/markers.py +++ b/benchmarks/markers.py @@ -33,3 +33,8 @@ def time_constructor(self) -> None: def time_evaluate(self) -> None: for m in self.markers: m.evaluate(self.env) + + @add_attributes(pretty_name="Marker as_ast") + def time_as_ast(self) -> None: + for m in self.markers: + m.as_ast() From 7ea56cb612f31db20b669f103f7b78fa6258aca8 Mon Sep 17 00:00:00 2001 From: Dan Yeaw Date: Sun, 26 Apr 2026 21:31:54 -0400 Subject: [PATCH 3/3] Add from_ast to construct a Marker from a tree --- CHANGELOG.rst | 3 +- benchmarks/markers.py | 7 +++ src/packaging/markers.py | 78 ++++++++++++++++++++++++- tests/test_markers.py | 122 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 208 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 947da25bf..f94fdd4d0 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -6,7 +6,8 @@ Changelog Features: -* Expose a public structured marker tree via ``Marker.as_ast()`` (:pull:`1145`) +* Expose a public structured marker tree via ``Marker.as_ast()`` in (:pull:`1145`) +* Add ``Marker.from_ast()`` to construct a ``Marker`` from a ``MarkerNode`` tree for marker construction and mutation in (:pull:`1145`) 26.2 - 2026-04-24 ~~~~~~~~~~~~~~~~~ diff --git a/benchmarks/markers.py b/benchmarks/markers.py index 5085ab90e..11dfa0177 100644 --- a/benchmarks/markers.py +++ b/benchmarks/markers.py @@ -38,3 +38,10 @@ def time_evaluate(self) -> None: def time_as_ast(self) -> None: for m in self.markers: m.as_ast() + + @add_attributes(pretty_name="Marker from_ast") + def time_from_ast(self) -> None: + for m in self.markers: + ast = m.as_ast() + if ast is not None: + Marker.from_ast(ast) diff --git a/src/packaging/markers.py b/src/packaging/markers.py index 06ae981e8..177add1a3 100644 --- a/src/packaging/markers.py +++ b/src/packaging/markers.py @@ -12,7 +12,6 @@ from dataclasses import dataclass from typing import ( TYPE_CHECKING, - AbstractSet, Callable, Literal, TypedDict, @@ -387,6 +386,29 @@ class MarkerOr: """A marker expression: comparison, conjunction, or disjunction.""" +# Canonical marker environment variable names after normalization +# (process_env_var + .replace(".", "_")), used to distinguish variables +# from literal values when serializing a MarkerNode tree. +_MARKER_VARIABLE_NAMES: frozenset[str] = frozenset( + { + "python_version", + "python_full_version", + "os_name", + "sys_platform", + "platform_release", + "platform_system", + "platform_version", + "platform_machine", + "platform_python_implementation", + "implementation_name", + "implementation_version", + "extra", + "extras", + "dependency_groups", + } +) + + def _split_marker_or_groups(markers: MarkerList) -> list[list[MarkerList | MarkerItem]]: """Split a marker list into ``or`` groups (each group is ``and``-combined).""" groups: list[list[MarkerList | MarkerItem]] = [[]] @@ -433,6 +455,27 @@ def _markers_to_ast(markers: MarkerList) -> MarkerNode | None: return MarkerOr(tuple(or_operands)) +def _serialize_node(node: MarkerNode, *, _parens: bool = False) -> str: + """Serialize a :class:`MarkerNode` tree to a PEP 508 marker string. + + Parenthesization rule: a :class:`MarkerOr` appearing as a direct operand + of :class:`MarkerAnd` is wrapped in parentheses because ``and`` binds more + tightly than ``or`` in PEP 508 syntax. + """ + if isinstance(node, MarkerCompare): + lhs = node.left if node.left in _MARKER_VARIABLE_NAMES else f'"{node.left}"' + rhs = node.right if node.right in _MARKER_VARIABLE_NAMES else f'"{node.right}"' + return f"{lhs} {node.op} {rhs}" + if isinstance(node, MarkerAnd): + inner = " and ".join( + _serialize_node(op, _parens=isinstance(op, MarkerOr)) + for op in node.operands + ) + else: # MarkerOr + inner = " or ".join(_serialize_node(op) for op in node.operands) + return f"({inner})" if _parens else inner + + class Marker: """Represents a parsed dependency marker expression. @@ -562,6 +605,39 @@ def as_ast(self) -> MarkerNode | None: """ return _markers_to_ast(self._markers) + @classmethod + def from_ast(cls, node: MarkerNode) -> Marker: + """Construct a :class:`Marker` from a :class:`MarkerNode` tree. + + This is the inverse of :meth:`as_ast`. It lets you build or mutate a + marker programmatically using :class:`MarkerCompare`, + :class:`MarkerAnd`, and :class:`MarkerOr`, then get a fully functional + :class:`Marker` back. + + The node tree is serialized to a PEP 508 string and parsed by the + normal constructor, so all validation and normalization (including + ``PEP 503`` canonicalization of extra names) are applied automatically. + An invalid operator or unknown structure raises :exc:`InvalidMarker`. + + :param node: The root of a marker expression tree. + :returns: A new :class:`Marker` equivalent to the given tree. + + Example:: + + from packaging.markers import Marker, MarkerAnd, MarkerCompare + + # python_version >= "3.10" and extra == "docs" + node = MarkerAnd( + operands=( + MarkerCompare("python_version", ">=", "3.10"), + MarkerCompare("extra", "==", "docs"), + ) + ) + m = Marker.from_ast(node) + print(m) # python_version >= "3.10" and extra == "docs" + """ + return cls(_serialize_node(node)) + def evaluate( self, environment: Mapping[str, str | AbstractSet[str]] | None = None, diff --git a/tests/test_markers.py b/tests/test_markers.py index be4ad2a09..bdae30fc0 100644 --- a/tests/test_markers.py +++ b/tests/test_markers.py @@ -599,6 +599,128 @@ def test_invalid_empty_parenthesized_subexpression(self) -> None: _ = marker.as_ast() +class TestMarkerFromAST: + @pytest.mark.parametrize( + "marker_string", + [ + 'python_version == "2.7"', + '"2.7" == python_version', + 'python_version == "2.7" and os_name == "linux"', + 'python_version == "2.7" or os_name == "linux"', + 'python_version == "2.7" and os_name == "linux" or sys_platform == "win32"', + ( + 'python_version == "2.7" and ' + '(sys_platform == "win32" or sys_platform == "linux")' + ), + '"dev" in dependency_groups', + ], + ) + def test_roundtrip(self, marker_string: str) -> None: + m = Marker(marker_string) + node = m.as_ast() + assert node is not None + assert str(Marker.from_ast(node)) == str(m) + + @pytest.mark.parametrize( + ("node", "expected_string"), + [ + ( + MarkerCompare("python_version", "==", "2.7"), + 'python_version == "2.7"', + ), + ( + MarkerAnd( + operands=( + MarkerCompare("python_version", "==", "2.7"), + MarkerCompare("os_name", "==", "linux"), + ) + ), + 'python_version == "2.7" and os_name == "linux"', + ), + ( + MarkerOr( + operands=( + MarkerCompare("python_version", "==", "2.7"), + MarkerCompare("os_name", "==", "linux"), + ) + ), + 'python_version == "2.7" or os_name == "linux"', + ), + ( + # MarkerOr inside MarkerAnd — parens required + MarkerAnd( + operands=( + MarkerCompare("python_version", ">=", "3.10"), + MarkerOr( + operands=( + MarkerCompare("sys_platform", "==", "linux"), + MarkerCompare("sys_platform", "==", "darwin"), + ) + ), + ) + ), + ( + 'python_version >= "3.10" and ' + '(sys_platform == "linux" or sys_platform == "darwin")' + ), + ), + ( + # MarkerAnd inside MarkerOr — no extra parens needed + MarkerOr( + operands=( + MarkerCompare("sys_platform", "==", "win32"), + MarkerAnd( + operands=( + MarkerCompare("python_version", ">=", "3.10"), + MarkerCompare("os_name", "==", "posix"), + ) + ), + ) + ), + ( + 'sys_platform == "win32" or ' + 'python_version >= "3.10" and os_name == "posix"' + ), + ), + ], + ) + def test_from_node(self, node: MarkerNode, expected_string: str) -> None: + m = Marker.from_ast(node) + assert str(m) == expected_string + assert m == Marker(expected_string) + + def test_extra_normalization(self) -> None: + m = Marker.from_ast(MarkerCompare("extra", "==", "My-Extra")) + assert "my-extra" in str(m) + assert m.evaluate({"extra": "my-extra"}) + assert not m.evaluate({"extra": "other"}) + + def test_evaluate_correctness(self) -> None: + node = MarkerAnd( + operands=( + MarkerCompare("python_version", ">=", "3.10"), + MarkerCompare("os_name", "==", "posix"), + ) + ) + m = Marker.from_ast(node) + ref = Marker('python_version >= "3.10" and os_name == "posix"') + matching = {"python_version": "3.12", "os_name": "posix"} + non_matching = {"python_version": "3.9", "os_name": "posix"} + assert m.evaluate(matching) == ref.evaluate(matching) + assert m.evaluate(non_matching) == ref.evaluate(non_matching) + + def test_in_operator_with_set_variable(self) -> None: + m = Marker.from_ast(MarkerCompare("dev", "in", "dependency_groups")) + assert m.evaluate({"dependency_groups": {"dev", "docs"}}, context="lock_file") + assert not m.evaluate({"dependency_groups": {"docs"}}, context="lock_file") + + def test_invalid_op_raises(self) -> None: + with pytest.raises(InvalidMarker): + Marker.from_ast( + MarkerCompare("python_version", cast("Any", "xyzzy"), "3.10") + ) + + class TestMarkerOperators: def test_and_operator_evaluates_true(self) -> None: env = {"python_version": "3.8", "os_name": "posix"}