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
29Operator = Callable[[str, Union[str, AbstractSet[str]]], bool]
30EvaluateContext = Literal["metadata", "lock_file", "requirement"]
31MARKERS_ALLOWING_SET = {"extras", "dependency_groups"}
32MARKERS_REQUIRING_VERSION = {
33 "implementation_version",
34 "platform_release",
35 "python_full_version",
36 "python_version",
37}
38
39
40class InvalidMarker(ValueError):
41 """
42 An invalid marker was found, users should refer to PEP 508.
43 """
44
45
46class UndefinedComparison(ValueError):
47 """
48 An invalid operation was attempted on a value that doesn't support it.
49 """
50
51
52class UndefinedEnvironmentName(ValueError):
53 """
54 A name was attempted to be used that does not exist inside of the
55 environment.
56 """
57
58
59class Environment(TypedDict):
60 implementation_name: str
61 """The implementation's identifier, e.g. ``'cpython'``."""
62
63 implementation_version: str
64 """
65 The implementation's version, e.g. ``'3.13.0a2'`` for CPython 3.13.0a2, or
66 ``'7.3.13'`` for PyPy3.10 v7.3.13.
67 """
68
69 os_name: str
70 """
71 The value of :py:data:`os.name`. The name of the operating system dependent module
72 imported, e.g. ``'posix'``.
73 """
74
75 platform_machine: str
76 """
77 Returns the machine type, e.g. ``'i386'``.
78
79 An empty string if the value cannot be determined.
80 """
81
82 platform_release: str
83 """
84 The system's release, e.g. ``'2.2.0'`` or ``'NT'``.
85
86 An empty string if the value cannot be determined.
87 """
88
89 platform_system: str
90 """
91 The system/OS name, e.g. ``'Linux'``, ``'Windows'`` or ``'Java'``.
92
93 An empty string if the value cannot be determined.
94 """
95
96 platform_version: str
97 """
98 The system's release version, e.g. ``'#3 on degas'``.
99
100 An empty string if the value cannot be determined.
101 """
102
103 python_full_version: str
104 """
105 The Python version as string ``'major.minor.patchlevel'``.
106
107 Note that unlike the Python :py:data:`sys.version`, this value will always include
108 the patchlevel (it defaults to 0).
109 """
110
111 platform_python_implementation: str
112 """
113 A string identifying the Python implementation, e.g. ``'CPython'``.
114 """
115
116 python_version: str
117 """The Python version as string ``'major.minor'``."""
118
119 sys_platform: str
120 """
121 This string contains a platform identifier that can be used to append
122 platform-specific components to :py:data:`sys.path`, for instance.
123
124 For Unix systems, except on Linux and AIX, this is the lowercased OS name as
125 returned by ``uname -s`` with the first part of the version as returned by
126 ``uname -r`` appended, e.g. ``'sunos5'`` or ``'freebsd8'``, at the time when Python
127 was built.
128 """
129
130
131def _normalize_extras(
132 result: MarkerList | MarkerAtom | str,
133) -> MarkerList | MarkerAtom | str:
134 if not isinstance(result, tuple):
135 return result
136
137 lhs, op, rhs = result
138 if isinstance(lhs, Variable) and lhs.value == "extra":
139 normalized_extra = canonicalize_name(rhs.value)
140 rhs = Value(normalized_extra)
141 elif isinstance(rhs, Variable) and rhs.value == "extra":
142 normalized_extra = canonicalize_name(lhs.value)
143 lhs = Value(normalized_extra)
144 return lhs, op, rhs
145
146
147def _normalize_extra_values(results: MarkerList) -> MarkerList:
148 """
149 Normalize extra values.
150 """
151
152 return [_normalize_extras(r) for r in results]
153
154
155def _format_marker(
156 marker: list[str] | MarkerAtom | str, first: bool | None = True
157) -> str:
158 assert isinstance(marker, (list, tuple, str))
159
160 # Sometimes we have a structure like [[...]] which is a single item list
161 # where the single item is itself it's own list. In that case we want skip
162 # the rest of this function so that we don't get extraneous () on the
163 # outside.
164 if (
165 isinstance(marker, list)
166 and len(marker) == 1
167 and isinstance(marker[0], (list, tuple))
168 ):
169 return _format_marker(marker[0])
170
171 if isinstance(marker, list):
172 inner = (_format_marker(m, first=False) for m in marker)
173 if first:
174 return " ".join(inner)
175 else:
176 return "(" + " ".join(inner) + ")"
177 elif isinstance(marker, tuple):
178 return " ".join([m.serialize() for m in marker])
179 else:
180 return marker
181
182
183_operators: dict[str, Operator] = {
184 "in": lambda lhs, rhs: lhs in rhs,
185 "not in": lambda lhs, rhs: lhs not in rhs,
186 "<": lambda _lhs, _rhs: False,
187 "<=": operator.eq,
188 "==": operator.eq,
189 "!=": operator.ne,
190 ">=": operator.eq,
191 ">": lambda _lhs, _rhs: False,
192}
193
194
195def _eval_op(lhs: str, op: Op, rhs: str | AbstractSet[str], *, key: str) -> bool:
196 op_str = op.serialize()
197 if key in MARKERS_REQUIRING_VERSION:
198 try:
199 spec = Specifier(f"{op_str}{rhs}")
200 except InvalidSpecifier:
201 pass
202 else:
203 return spec.contains(lhs, prereleases=True)
204
205 oper: Operator | None = _operators.get(op_str)
206 if oper is None:
207 raise UndefinedComparison(f"Undefined {op!r} on {lhs!r} and {rhs!r}.")
208
209 return oper(lhs, rhs)
210
211
212def _normalize(
213 lhs: str, rhs: str | AbstractSet[str], key: str
214) -> tuple[str, str | AbstractSet[str]]:
215 # PEP 685 - Comparison of extra names for optional distribution dependencies
216 # https://peps.python.org/pep-0685/
217 # > When comparing extra names, tools MUST normalize the names being
218 # > compared using the semantics outlined in PEP 503 for names
219 if key == "extra":
220 assert isinstance(rhs, str), "extra value must be a string"
221 # Both sides are normalized at this point already
222 return (lhs, rhs)
223 if key in MARKERS_ALLOWING_SET:
224 if isinstance(rhs, str): # pragma: no cover
225 return (canonicalize_name(lhs), canonicalize_name(rhs))
226 else:
227 return (canonicalize_name(lhs), {canonicalize_name(v) for v in rhs})
228
229 # other environment markers don't have such standards
230 return lhs, rhs
231
232
233def _evaluate_markers(
234 markers: MarkerList, environment: dict[str, str | AbstractSet[str]]
235) -> bool:
236 groups: list[list[bool]] = [[]]
237
238 for marker in markers:
239 if isinstance(marker, list):
240 groups[-1].append(_evaluate_markers(marker, environment))
241 elif isinstance(marker, tuple):
242 lhs, op, rhs = marker
243
244 if isinstance(lhs, Variable):
245 environment_key = lhs.value
246 lhs_value = environment[environment_key]
247 rhs_value = rhs.value
248 else:
249 lhs_value = lhs.value
250 environment_key = rhs.value
251 rhs_value = environment[environment_key]
252
253 assert isinstance(lhs_value, str), "lhs must be a string"
254 lhs_value, rhs_value = _normalize(lhs_value, rhs_value, key=environment_key)
255 groups[-1].append(_eval_op(lhs_value, op, rhs_value, key=environment_key))
256 elif marker == "or":
257 groups.append([])
258 elif marker == "and":
259 pass
260 else: # pragma: nocover
261 raise TypeError(f"Unexpected marker {marker!r}")
262
263 return any(all(item) for item in groups)
264
265
266def format_full_version(info: sys._version_info) -> str:
267 version = f"{info.major}.{info.minor}.{info.micro}"
268 kind = info.releaselevel
269 if kind != "final":
270 version += kind[0] + str(info.serial)
271 return version
272
273
274def default_environment() -> Environment:
275 iver = format_full_version(sys.implementation.version)
276 implementation_name = sys.implementation.name
277 return {
278 "implementation_name": implementation_name,
279 "implementation_version": iver,
280 "os_name": os.name,
281 "platform_machine": platform.machine(),
282 "platform_release": platform.release(),
283 "platform_system": platform.system(),
284 "platform_version": platform.version(),
285 "python_full_version": platform.python_version(),
286 "platform_python_implementation": platform.python_implementation(),
287 "python_version": ".".join(platform.python_version_tuple()[:2]),
288 "sys_platform": sys.platform,
289 }
290
291
292class Marker:
293 def __init__(self, marker: str) -> None:
294 # Note: We create a Marker object without calling this constructor in
295 # packaging.requirements.Requirement. If any additional logic is
296 # added here, make sure to mirror/adapt Requirement.
297
298 # If this fails and throws an error, the repr still expects _markers to
299 # be defined.
300 self._markers: MarkerList = []
301
302 try:
303 self._markers = _normalize_extra_values(_parse_marker(marker))
304 # The attribute `_markers` can be described in terms of a recursive type:
305 # MarkerList = List[Union[Tuple[Node, ...], str, MarkerList]]
306 #
307 # For example, the following expression:
308 # python_version > "3.6" or (python_version == "3.6" and os_name == "unix")
309 #
310 # is parsed into:
311 # [
312 # (<Variable('python_version')>, <Op('>')>, <Value('3.6')>),
313 # 'and',
314 # [
315 # (<Variable('python_version')>, <Op('==')>, <Value('3.6')>),
316 # 'or',
317 # (<Variable('os_name')>, <Op('==')>, <Value('unix')>)
318 # ]
319 # ]
320 except ParserSyntaxError as e:
321 raise InvalidMarker(str(e)) from e
322
323 def __str__(self) -> str:
324 return _format_marker(self._markers)
325
326 def __repr__(self) -> str:
327 return f"<{self.__class__.__name__}('{self}')>"
328
329 def __hash__(self) -> int:
330 return hash(str(self))
331
332 def __eq__(self, other: object) -> bool:
333 if not isinstance(other, Marker):
334 return NotImplemented
335
336 return str(self) == str(other)
337
338 def evaluate(
339 self,
340 environment: Mapping[str, str | AbstractSet[str]] | None = None,
341 context: EvaluateContext = "metadata",
342 ) -> bool:
343 """Evaluate a marker.
344
345 Return the boolean from evaluating the given marker against the
346 environment. environment is an optional argument to override all or
347 part of the determined environment. The *context* parameter specifies what
348 context the markers are being evaluated for, which influences what markers
349 are considered valid. Acceptable values are "metadata" (for core metadata;
350 default), "lock_file", and "requirement" (i.e. all other situations).
351
352 The environment is determined from the current Python process.
353 """
354 current_environment = cast(
355 "dict[str, str | AbstractSet[str]]", default_environment()
356 )
357 if context == "lock_file":
358 current_environment.update(
359 extras=frozenset(), dependency_groups=frozenset()
360 )
361 elif context == "metadata":
362 current_environment["extra"] = ""
363
364 if environment is not None:
365 current_environment.update(environment)
366 if "extra" in current_environment:
367 # The API used to allow setting extra to None. We need to handle
368 # this case for backwards compatibility. Also skip running
369 # normalize name if extra is empty.
370 extra = cast("str | None", current_environment["extra"])
371 current_environment["extra"] = canonicalize_name(extra) if extra else ""
372
373 return _evaluate_markers(
374 self._markers, _repair_python_full_version(current_environment)
375 )
376
377
378def _repair_python_full_version(
379 env: dict[str, str | AbstractSet[str]],
380) -> dict[str, str | AbstractSet[str]]:
381 """
382 Work around platform.python_version() returning something that is not PEP 440
383 compliant for non-tagged Python builds.
384 """
385 python_full_version = cast("str", env["python_full_version"])
386 if python_full_version.endswith("+"):
387 env["python_full_version"] = f"{python_full_version}local"
388 return env