codekingpro/portable-devtools
114k
1# This file is dual licensed under the terms of the Apache License, Version2# 2.0, and the BSD License. See the LICENSE file in the root of this repository3# for complete details.4 5from __future__ import annotations6 7import operator8import os9import platform10import sys11from typing import AbstractSet, Callable, Literal, Mapping, TypedDict, Union, cast12 13from ._parser import MarkerAtom, MarkerList, Op, Value, Variable14from ._parser import parse_marker as _parse_marker15from ._tokenizer import ParserSyntaxError16from .specifiers import InvalidSpecifier, Specifier17from .utils import canonicalize_name18 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 the58 :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 not66 conform to the :ref:`specification of version specifiers67 <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 by78 :func:`default_environment`. All fields are required.79 """80 81 implementation_name: str82 """The implementation's identifier, e.g. ``'cpython'``."""83 84 implementation_version: str85 """86 The implementation's version, e.g. ``'3.13.0a2'`` for CPython 3.13.0a2, or87 ``'7.3.13'`` for PyPy3.10 v7.3.13.88 """89 90 os_name: str91 """92 The value of :py:data:`os.name`. The name of the operating system dependent module93 imported, e.g. ``'posix'``.94 """95 96 platform_machine: str97 """98 Returns the machine type, e.g. ``'i386'``.99 100 An empty string if the value cannot be determined.101 """102 103 platform_release: str104 """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: str111 """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: str118 """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: str125 """126 The Python version as string ``'major.minor.patchlevel'``.127 128 Note that unlike the Python :py:data:`sys.version`, this value will always include129 the patchlevel (it defaults to 0).130 """131 132 platform_python_implementation: str133 """134 A string identifying the Python implementation, e.g. ``'CPython'``.135 """136 137 python_version: str138 """The Python version as string ``'major.minor'``."""139 140 sys_platform: str141 """142 This string contains a platform identifier that can be used to append143 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 as146 returned by ``uname -s`` with the first part of the version as returned by147 ``uname -r`` appended, e.g. ``'sunos5'`` or ``'freebsd8'``, at the time when Python148 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 result157 158 lhs, op, rhs = result159 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, rhs166 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 = True178) -> str:179 assert isinstance(marker, (list, tuple, str))180 181 # Sometimes we have a structure like [[...]] which is a single item list182 # where the single item is itself it's own list. In that case we want skip183 # the rest of this function so that we don't get extraneous () on the184 # outside.185 if (186 isinstance(marker, list)187 and len(marker) == 1188 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 marker202 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 pass223 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: str235) -> tuple[str, str | AbstractSet[str]]:236 # PEP 685 - Comparison of extra names for optional distribution dependencies237 # https://peps.python.org/pep-0685/238 # > When comparing extra names, tools MUST normalize the names being239 # > compared using the semantics outlined in PEP 503 for names240 if key == "extra":241 assert isinstance(rhs, str), "extra value must be a string"242 # Both sides are normalized at this point already243 return (lhs, rhs)244 if key in MARKERS_ALLOWING_SET:245 if isinstance(rhs, str): # pragma: no cover246 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 standards251 return lhs, rhs252 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 = marker264 265 if isinstance(lhs, Variable):266 environment_key = lhs.value267 lhs_value = environment[environment_key]268 rhs_value = rhs.value269 else:270 lhs_value = lhs.value271 environment_key = rhs.value272 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 pass281 else: # pragma: nocover282 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.releaselevel290 if kind != "final":291 version += kind[0] + str(info.serial)292 return version293 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.name302 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 the321 :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 stable327 format so the same pickle can be loaded in future packaging releases.328 329 .. versionchanged:: 26.2330 331 Added a stable pickle format. Pickles created with packaging 26.2+ can332 be unpickled with future releases. Backward compatibility with pickles333 from packaging < 26.2 is supported but may be removed in a future334 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 in341 # packaging.requirements.Requirement. If any additional logic is342 # added here, make sure to mirror/adapt Requirement.343 344 # If this fails and throws an error, the repr still expects _markers to345 # 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 e368 369 @classmethod370 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 = markers377 return new378 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 NotImplemented391 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 exc406 return407 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 = markers412 return413 if isinstance(state, tuple) and len(state) == 2:414 # Old format (packaging <= 26.1, __slots__): (None, {slot: value}).415 _, slot_dict = state416 if isinstance(slot_dict, dict) and "_markers" in slot_dict:417 markers = slot_dict["_markers"]418 if isinstance(markers, list):419 self._markers = markers420 return421 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 NotImplemented426 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 NotImplemented431 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 unless442 passed in explicitly.443 444 :param environment: Mapping containing keys and values to override the445 detected environment.446 :param EvaluateContext context: The context in which the marker is447 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 values451 that are not valid versions per the :ref:`specification of version452 specifiers <pypug:version-specifiers>`.453 :raises UndefinedEnvironmentName: If the marker references a value that454 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 handle472 # this case for backwards compatibility. Also skip running473 # 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 440487 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 env493 