1# This file is dual licensed under the terms of the Apache License, Version
2# 2.0, and the BSD License. See the LICENSE file in the root of this repository
3# for complete details.
4
5from __future__ import annotations
6
7import functools
8import operator
9import os
10import platform
11import sys
12from collections.abc import Set as AbstractSet
13from typing import TYPE_CHECKING, Callable, Literal, TypedDict, Union, cast
14
15from ._parser import MarkerAtom, MarkerList, Op, Value, Variable
16from ._parser import parse_marker as _parse_marker
17from ._tokenizer import ParserSyntaxError
18from .specifiers import InvalidSpecifier, Specifier
19from .utils import canonicalize_name
20
21if TYPE_CHECKING:
22 from collections.abc import Mapping
23
24__all__ = [
25 "Environment",
26 "EvaluateContext",
27 "InvalidMarker",
28 "Marker",
29 "UndefinedComparison",
30 "UndefinedEnvironmentName",
31 "default_environment",
32]
33
34
35def __dir__() -> list[str]:
36 return __all__
37
38
39Operator = Callable[[str, Union[str, AbstractSet[str]]], bool]
40EvaluateContext = Literal["metadata", "lock_file", "requirement"]
41"""A ``typing.Literal`` enumerating valid marker evaluation contexts.
42
43Valid values for the ``context`` passed to :meth:`Marker.evaluate` are:
44
45* ``"metadata"`` (for core metadata; default)
46* ``"lock_file"`` (for lock files)
47* ``"requirement"`` (i.e. all other situations)
48
49.. versionadded:: 25.0
50"""
51
52MARKERS_ALLOWING_SET = {"extras", "dependency_groups"}
53MARKERS_REQUIRING_VERSION = {
54 "implementation_version",
55 "platform_release",
56 "python_full_version",
57 "python_version",
58}
59
60
61class InvalidMarker(ValueError):
62 """Raised when attempting to create a :class:`Marker` from invalid input.
63
64 This error indicates that the given marker string does not conform to the
65 :ref:`specification of dependency specifiers <pypug:dependency-specifiers>`.
66 """
67
68
69class UndefinedComparison(ValueError):
70 """Raised when evaluating an unsupported marker comparison.
71
72 This can happen when marker values are compared as versions but do not
73 conform to the :ref:`specification of version specifiers
74 <pypug:version-specifiers>`.
75 """
76
77
78class UndefinedEnvironmentName(KeyError):
79 """Raised when evaluating a marker that references a missing environment key.
80
81 Subclasses :class:`KeyError` so that code catching the bare ``KeyError`` that
82 a missing environment lookup historically produced keeps working.
83
84 .. versionchanged:: 26.3
85 Now subclasses :class:`KeyError` (was :class:`ValueError`) and is raised by
86 :meth:`Marker.evaluate` for missing environment keys, where a bare
87 ``KeyError`` was raised before.
88 """
89
90
91class Environment(TypedDict):
92 """
93 A dictionary that represents a Python environment as captured by
94 :func:`default_environment`. All fields are required.
95 """
96
97 implementation_name: str
98 """The implementation's identifier, e.g. ``'cpython'``."""
99
100 implementation_version: str
101 """
102 The implementation's version, e.g. ``'3.13.0a2'`` for CPython 3.13.0a2, or
103 ``'7.3.13'`` for PyPy3.10 v7.3.13.
104 """
105
106 os_name: str
107 """
108 The value of :py:data:`os.name`. The name of the operating system dependent module
109 imported, e.g. ``'posix'``.
110 """
111
112 platform_machine: str
113 """
114 Returns the machine type, e.g. ``'i386'``.
115
116 An empty string if the value cannot be determined.
117 """
118
119 platform_release: str
120 """
121 The system's release, e.g. ``'2.2.0'`` or ``'NT'``.
122
123 An empty string if the value cannot be determined.
124 """
125
126 platform_system: str
127 """
128 The system/OS name, e.g. ``'Linux'``, ``'Windows'`` or ``'Java'``.
129
130 An empty string if the value cannot be determined.
131 """
132
133 platform_version: str
134 """
135 The system's release version, e.g. ``'#3 on degas'``.
136
137 An empty string if the value cannot be determined.
138 """
139
140 python_full_version: str
141 """
142 The Python version as string ``'major.minor.patchlevel'``.
143
144 Note that unlike the Python :py:data:`sys.version`, this value will always include
145 the patchlevel (it defaults to 0).
146 """
147
148 platform_python_implementation: str
149 """
150 A string identifying the Python implementation, e.g. ``'CPython'``.
151 """
152
153 python_version: str
154 """The Python version as string ``'major.minor'``."""
155
156 sys_platform: str
157 """
158 This string contains a platform identifier that can be used to append
159 platform-specific components to :py:data:`sys.path`, for instance.
160
161 For Unix systems, except on Linux and AIX, this is the lowercased OS name as
162 returned by ``uname -s`` with the first part of the version as returned by
163 ``uname -r`` appended, e.g. ``'sunos5'`` or ``'freebsd8'``, at the time when Python
164 was built.
165 """
166
167
168def _normalize_extras(
169 result: MarkerList | MarkerAtom | str,
170) -> MarkerList | MarkerAtom | str:
171 if isinstance(result, list):
172 return [_normalize_extras(r) for r in result]
173 if not isinstance(result, tuple):
174 return result
175
176 lhs, op, rhs = result
177 if isinstance(lhs, Variable) and lhs.value == "extra" and isinstance(rhs, Value):
178 normalized_extra = canonicalize_name(rhs.value)
179 rhs = Value(normalized_extra)
180 elif isinstance(rhs, Variable) and rhs.value == "extra" and isinstance(lhs, Value):
181 normalized_extra = canonicalize_name(lhs.value)
182 lhs = Value(normalized_extra)
183 elif (
184 isinstance(rhs, Variable)
185 and rhs.value in MARKERS_ALLOWING_SET
186 and isinstance(lhs, Value)
187 ):
188 # PEP 685 (extras) / PEP 735 (dependency_groups): the set-valued membership
189 # literal must also be normalized. evaluate() already canonicalizes both
190 # operands for these keys (see _normalize), so normalizing the literal at
191 # parse time keeps __str__/__eq__/__hash__ consistent with evaluate() -- e.g.
192 # Marker('"Foo" in extras') and Marker('"foo" in extras') must compare and
193 # hash equal (the membership variable is always the right-hand operand).
194 lhs = Value(canonicalize_name(lhs.value))
195 return lhs, op, rhs
196
197
198def _normalize_extra_values(results: MarkerList) -> MarkerList:
199 """
200 Normalize extra values.
201 """
202
203 return [_normalize_extras(r) for r in results]
204
205
206def _format_marker(
207 marker: list[str] | MarkerAtom | str, first: bool | None = True
208) -> str:
209 assert isinstance(marker, (list, tuple, str))
210
211 # Unwrap a redundant [[...]] wrapper, but keep the nesting context so a
212 # nested group keeps the parentheses its and/or precedence needs.
213 if (
214 isinstance(marker, list)
215 and len(marker) == 1
216 and isinstance(marker[0], (list, tuple))
217 ):
218 return _format_marker(marker[0], first=first)
219
220 if isinstance(marker, list):
221 inner = (_format_marker(m, first=False) for m in marker)
222 if first:
223 return " ".join(inner)
224 else:
225 return "(" + " ".join(inner) + ")"
226 elif isinstance(marker, tuple):
227 return " ".join([m.serialize() for m in marker])
228 else:
229 return marker
230
231
232_operators: dict[str, Operator] = {
233 "in": lambda lhs, rhs: lhs in rhs,
234 "not in": lambda lhs, rhs: lhs not in rhs,
235 "<": lambda _lhs, _rhs: False,
236 "<=": operator.eq,
237 "==": operator.eq,
238 "!=": operator.ne,
239 ">=": operator.eq,
240 ">": lambda _lhs, _rhs: False,
241}
242
243
244def _eval_op(lhs: str, op: Op, rhs: str | AbstractSet[str], *, key: str) -> bool:
245 op_str = op.serialize()
246 if key in MARKERS_REQUIRING_VERSION:
247 try:
248 spec = Specifier(f"{op_str}{rhs}")
249 except InvalidSpecifier:
250 pass
251 else:
252 return spec.contains(lhs, prereleases=True)
253
254 oper: Operator | None = _operators.get(op_str)
255 if oper is None:
256 raise UndefinedComparison(f"Undefined {op!r} on {lhs!r} and {rhs!r}.")
257
258 return oper(lhs, rhs)
259
260
261def _normalize(
262 lhs: str, rhs: str | AbstractSet[str], key: str
263) -> tuple[str, str | AbstractSet[str]]:
264 # PEP 685 - Comparison of extra names for optional distribution dependencies
265 # https://peps.python.org/pep-0685/
266 # > When comparing extra names, tools MUST normalize the names being
267 # > compared using the semantics outlined in PEP 503 for names
268 if key == "extra":
269 assert isinstance(rhs, str), "extra value must be a string"
270 # Both sides are normalized at this point already
271 return (lhs, rhs)
272 if key in MARKERS_ALLOWING_SET:
273 if isinstance(rhs, str): # pragma: no cover
274 return (canonicalize_name(lhs), canonicalize_name(rhs))
275 else:
276 return (canonicalize_name(lhs), {canonicalize_name(v) for v in rhs})
277
278 # other environment markers don't have such standards
279 return lhs, rhs
280
281
282def _lookup_environment(
283 environment: dict[str, str | AbstractSet[str]], key: str
284) -> str | AbstractSet[str]:
285 try:
286 return environment[key]
287 except KeyError:
288 raise UndefinedEnvironmentName(key) from None
289
290
291def _evaluate_markers(
292 markers: MarkerList, environment: dict[str, str | AbstractSet[str]]
293) -> bool:
294 groups: list[list[bool]] = [[]]
295
296 for marker in markers:
297 if isinstance(marker, list):
298 groups[-1].append(_evaluate_markers(marker, environment))
299 elif isinstance(marker, tuple):
300 lhs, op, rhs = marker
301
302 if isinstance(lhs, Variable):
303 environment_key = lhs.value
304 lhs_value = _lookup_environment(environment, environment_key)
305 rhs_value = rhs.value
306 else:
307 lhs_value = lhs.value
308 environment_key = rhs.value
309 rhs_value = _lookup_environment(environment, environment_key)
310
311 if not isinstance(lhs_value, str):
312 raise UndefinedComparison(
313 f"Set-valued marker {environment_key!r} can only be used "
314 f'with the membership form (e.g. "<name>" in '
315 f"{environment_key}); it cannot appear on the left-hand "
316 f"side of {op.serialize()!r}."
317 )
318 lhs_value, rhs_value = _normalize(lhs_value, rhs_value, key=environment_key)
319 groups[-1].append(_eval_op(lhs_value, op, rhs_value, key=environment_key))
320 elif marker == "or":
321 groups.append([])
322 elif marker == "and":
323 pass
324 else: # pragma: nocover
325 raise TypeError(f"Unexpected marker {marker!r}")
326
327 return any(all(item) for item in groups)
328
329
330def _format_full_version(info: sys._version_info) -> str:
331 version = f"{info.major}.{info.minor}.{info.micro}"
332 kind = info.releaselevel
333 if kind != "final":
334 version += kind[0] + str(info.serial)
335 return version
336
337
338@functools.cache
339def _cached_default_environment() -> Environment:
340 """Build the default marker environment for the current Python process.
341
342 The values are derived from process-constant data (the running interpreter
343 and the host platform), so this is cached and built only once. The result is
344 shared between callers and must never be mutated; :func:`default_environment`
345 returns a fresh copy.
346 """
347 iver = _format_full_version(sys.implementation.version)
348 implementation_name = sys.implementation.name
349 return {
350 "implementation_name": implementation_name,
351 "implementation_version": iver,
352 "os_name": os.name,
353 "platform_machine": platform.machine(),
354 "platform_release": platform.release(),
355 "platform_system": platform.system(),
356 "platform_version": platform.version(),
357 "python_full_version": platform.python_version(),
358 "platform_python_implementation": platform.python_implementation(),
359 "python_version": ".".join(platform.python_version_tuple()[:2]),
360 "sys_platform": sys.platform,
361 }
362
363
364def default_environment() -> Environment:
365 """Return the default marker environment for the current Python process.
366
367 This is the base environment used by :meth:`Marker.evaluate`. A fresh copy
368 is returned on every call so callers may freely mutate the result; a shallow
369 copy suffices because all values are immutable strings.
370
371 .. versionchanged:: 26.3
372 The environment is computed once per process and cached, since it is
373 derived from process-constant data. Patching ``platform``/``sys``/``os``
374 after the first call has no effect; pass an explicit ``environment`` to
375 :meth:`Marker.evaluate` to evaluate against different values.
376 """
377 return cast("Environment", dict(_cached_default_environment()))
378
379
380class Marker:
381 """Represents a parsed dependency marker expression.
382
383 Marker expressions are parsed according to the
384 :ref:`specification of dependency specifiers <pypug:dependency-specifiers>`.
385
386 :param marker: The string representation of a marker expression.
387 :raises InvalidMarker: If ``marker`` cannot be parsed.
388
389 Instances are safe to serialize with :mod:`pickle`. They use a stable
390 format so the same pickle can be loaded in future packaging releases.
391
392 .. versionchanged:: 26.2
393
394 Added a stable pickle format. Pickles created with packaging 26.2+ can
395 be unpickled with future releases. Backward compatibility with pickles
396 from pip._vendor.packaging < 26.2 is supported but may be removed in a future
397 release.
398 """
399
400 __slots__ = ("_markers",)
401
402 def __init__(self, marker: str) -> None:
403 # Note: We create a Marker object without calling this constructor in
404 # packaging.requirements.Requirement. If any additional logic is
405 # added here, make sure to mirror/adapt Requirement.
406
407 # If this fails and throws an error, the repr still expects _markers to
408 # be defined.
409 self._markers: MarkerList = []
410
411 try:
412 self._markers = _normalize_extra_values(_parse_marker(marker))
413 # The attribute `_markers` can be described in terms of a recursive type:
414 # MarkerList = List[Union[Tuple[Node, ...], str, MarkerList]]
415 #
416 # For example, the following expression:
417 # python_version > "3.6" or (python_version == "3.6" and os_name == "unix")
418 #
419 # is parsed into:
420 # [
421 # (<Variable('python_version')>, <Op('>')>, <Value('3.6')>),
422 # 'and',
423 # [
424 # (<Variable('python_version')>, <Op('==')>, <Value('3.6')>),
425 # 'or',
426 # (<Variable('os_name')>, <Op('==')>, <Value('unix')>)
427 # ]
428 # ]
429 except ParserSyntaxError as e:
430 raise InvalidMarker(str(e)) from e
431
432 @classmethod
433 def _from_markers(cls, markers: MarkerList) -> Marker:
434 """Create a Marker instance from a pre-parsed marker tree.
435
436 This avoids re-parsing serialised marker strings when combining markers.
437 """
438 new = cls.__new__(cls)
439 new._markers = markers
440 return new
441
442 def __str__(self) -> str:
443 return _format_marker(self._markers)
444
445 def __repr__(self) -> str:
446 return f"<{self.__class__.__name__}({str(self)!r})>"
447
448 def __hash__(self) -> int:
449 return hash(str(self))
450
451 def __eq__(self, other: object) -> bool:
452 if not isinstance(other, Marker):
453 return NotImplemented
454
455 return str(self) == str(other)
456
457 def __getstate__(self) -> str:
458 # Return the marker expression string for compactness and stability.
459 # Internal Node objects are excluded; the string is re-parsed on load.
460 return str(self)
461
462 def __setstate__(self, state: object) -> None:
463 if isinstance(state, str):
464 # New format (26.2+): just the marker expression string.
465 try:
466 self._markers = _normalize_extra_values(_parse_marker(state))
467 except ParserSyntaxError as exc:
468 raise TypeError(f"Cannot restore Marker from {state!r}") from exc
469 return
470 if isinstance(state, dict) and "_markers" in state:
471 # Old format (packaging <= 26.1, no __slots__): plain __dict__.
472 markers = state["_markers"]
473 if isinstance(markers, list):
474 self._markers = markers
475 return
476 if isinstance(state, tuple) and len(state) == 2:
477 # Old format (packaging <= 26.1, __slots__): (None, {slot: value}).
478 _, slot_dict = state
479 if isinstance(slot_dict, dict) and "_markers" in slot_dict:
480 markers = slot_dict["_markers"]
481 if isinstance(markers, list):
482 self._markers = markers
483 return
484 raise TypeError(f"Cannot restore Marker from {state!r}")
485
486 def __and__(self, other: Marker) -> Marker:
487 """Combine this marker with another using ``and``.
488
489 .. versionadded:: 26.1
490 """
491 if not isinstance(other, Marker):
492 return NotImplemented
493 return self._from_markers([self._markers, "and", other._markers])
494
495 def __or__(self, other: Marker) -> Marker:
496 """Combine this marker with another using ``or``.
497
498 .. versionadded:: 26.1
499 """
500 if not isinstance(other, Marker):
501 return NotImplemented
502 return self._from_markers([self._markers, "or", other._markers])
503
504 def evaluate(
505 self,
506 environment: Mapping[str, str | AbstractSet[str]] | None = None,
507 context: EvaluateContext = "metadata",
508 ) -> bool:
509 """Evaluate a marker.
510
511 Return the boolean from evaluating this marker against the environment.
512 The environment is determined from the current Python process unless
513 passed in explicitly.
514
515 :param environment: Mapping containing keys and values to override the
516 detected environment.
517 :param EvaluateContext context: The context in which the marker is
518 evaluated, which influences what marker names are considered valid.
519 Accepted values are ``"metadata"`` (for core metadata; default),
520 ``"lock_file"``, and ``"requirement"`` (i.e. all other situations).
521 :raises UndefinedComparison: If the marker uses a comparison on values
522 that are not valid versions per the :ref:`specification of version
523 specifiers <pypug:version-specifiers>`.
524 :raises UndefinedEnvironmentName: If the marker references a value that
525 is missing from the evaluation environment.
526 :returns: ``True`` if the marker matches, otherwise ``False``.
527
528 .. versionchanged:: 25.0
529 Added the ``context`` parameter, which influences which marker names
530 are considered valid.
531 """
532 current_environment = cast(
533 "dict[str, str | AbstractSet[str]]", default_environment()
534 )
535 if context == "lock_file":
536 current_environment |= {
537 "extras": frozenset(),
538 "dependency_groups": frozenset(),
539 }
540 elif context == "metadata":
541 current_environment["extra"] = ""
542
543 if environment is not None:
544 current_environment |= environment
545 if "extra" in current_environment:
546 # The API used to allow setting extra to None. We need to handle
547 # this case for backwards compatibility. Also skip running
548 # normalize name if extra is empty.
549 extra = cast("str | None", current_environment["extra"])
550 current_environment["extra"] = canonicalize_name(extra) if extra else ""
551
552 return _evaluate_markers(
553 self._markers, _repair_python_full_version(current_environment)
554 )
555
556
557def _pep440_python_full_version(python_full_version: str) -> str:
558 """
559 Work around platform.python_version() returning something that is not PEP 440
560 compliant for non-tagged Python builds.
561 """
562 if python_full_version.endswith("+"):
563 return f"{python_full_version}local"
564 return python_full_version
565
566
567def _repair_python_full_version(
568 env: dict[str, str | AbstractSet[str]],
569) -> dict[str, str | AbstractSet[str]]:
570 """
571 Work around platform.python_version() returning something that is not PEP 440
572 compliant for non-tagged Python builds.
573 """
574 python_full_version = cast("str", env["python_full_version"])
575 env["python_full_version"] = _pep440_python_full_version(python_full_version)
576 return env