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