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
5 changes: 4 additions & 1 deletion CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ Changelog
*unreleased*
~~~~~~~~~~~~

No unreleased changes.
Features:

* 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
~~~~~~~~~~~~~~~~~
Expand Down
12 changes: 12 additions & 0 deletions benchmarks/markers.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,15 @@ 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()

@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)
42 changes: 41 additions & 1 deletion docs/markers.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
<Marker('python_version >= "3.10" and os_name == "posix"')>
>>> windows = Marker('sys_platform == "win32"')
>>> macos = Marker('sys_platform == "darwin"')
>>> windows | macos
<Marker('sys_platform == "win32" or sys_platform == "darwin"')>
>>> 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
---------
Expand All @@ -73,3 +110,6 @@ Reference
:members:
:special-members: __and__, __or__
:exclude-members: __init__

.. autodata:: MarkerCompareOp
:no-value:
208 changes: 206 additions & 2 deletions src/packaging/markers.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,27 @@
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,
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
Expand All @@ -25,6 +43,11 @@
"EvaluateContext",
"InvalidMarker",
"Marker",
"MarkerAnd",
"MarkerCompare",
"MarkerCompareOp",
"MarkerNode",
"MarkerOr",
"UndefinedComparison",
"UndefinedEnvironmentName",
"default_environment",
Expand Down Expand Up @@ -318,6 +341,141 @@ 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."""


# 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]]:
Comment thread
danyeaw marked this conversation as resolved.
"""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))


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.

Expand Down Expand Up @@ -434,6 +592,52 @@ def __or__(self, other: Marker) -> Marker:
return NotImplemented
return self._from_markers([self._markers, "or", other._markers])

def as_ast(self) -> MarkerNode | None:
Comment thread
henryiii marked this conversation as resolved.
"""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)

@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,
Expand Down
Loading