File: //opt/saltstack/salt/lib/python3.10/site-packages/python_discovery/_py_spec.py
"""A Python specification is an abstract requirement definition of an interpreter."""
from __future__ import annotations
import contextlib
import pathlib
import re
from typing import Final
from ._py_info import normalize_isa
from ._specifier import SimpleSpecifier, SimpleSpecifierSet, SimpleVersion
PATTERN = re.compile(
r"""
^
(?P<impl>[a-zA-Z]+)? # implementation (e.g. cpython, pypy)
(?P<version>[0-9.]+)? # version (e.g. 3.12, 3.12.1)
(?P<threaded>t)? # free-threaded flag
(?:-(?P<arch>32|64))? # architecture bitness
(?:-(?P<machine>[a-zA-Z0-9_.]+))? # ISA (e.g. arm64, x86_64, i86pc.64bit)
$
""",
re.VERBOSE,
)
SPECIFIER_PATTERN = re.compile(
r"""
^
(?:(?P<impl>[A-Za-z]+)\s*)? # optional implementation prefix
(?P<spec>(?:===|==|~=|!=|<=|>=|<|>).+) # PEP 440 version specifier
$
""",
re.VERBOSE,
)
_MAX_VERSION_PARTS: Final[int] = 3
_SINGLE_DIGIT_MAX: Final[int] = 9
KNOWN_IMPLEMENTATIONS: Final[tuple[str, ...]] = ("python", "cpython", "pypy", "graalpy")
SpecifierSet = SimpleSpecifierSet
Version = SimpleVersion
InvalidSpecifier = ValueError
InvalidVersion = ValueError
def _int_or_none(val: str | None) -> int | None:
return None if val is None else int(val)
def _parse_version_parts(version: str) -> tuple[int | None, int | None, int | None]:
versions = tuple(int(i) for i in version.split(".") if i)
if len(versions) > _MAX_VERSION_PARTS:
msg = "too many version parts"
raise ValueError(msg)
if len(versions) == _MAX_VERSION_PARTS:
return versions[0], versions[1], versions[2]
if len(versions) == 2: # noqa: PLR2004
return versions[0], versions[1], None
version_data = versions[0]
major = int(str(version_data)[0])
minor = int(str(version_data)[1:]) if version_data > _SINGLE_DIGIT_MAX else None
return major, minor, None
def _parse_spec_pattern(string_spec: str) -> PythonSpec | None:
if not (match := re.match(PATTERN, string_spec)):
return None
groups = match.groupdict()
version = groups["version"]
major, minor, micro, threaded = None, None, None, None
if version is not None:
try:
major, minor, micro = _parse_version_parts(version)
except ValueError:
return None
threaded = bool(groups["threaded"])
impl = groups["impl"]
if impl in {"py", "python"}:
impl = None
if impl == "graalvm":
impl = "graalpy"
arch = _int_or_none(groups["arch"])
machine = groups.get("machine")
if machine is not None:
machine = normalize_isa(machine)
return PythonSpec(string_spec, impl, major, minor, micro, arch, None, free_threaded=threaded, machine=machine)
def _parse_specifier(string_spec: str) -> PythonSpec | None:
if not (specifier_match := SPECIFIER_PATTERN.match(string_spec.strip())):
return None
if SpecifierSet is None: # pragma: no cover
return None
impl = specifier_match.group("impl")
spec_text = specifier_match.group("spec").strip()
try:
version_specifier = SpecifierSet.from_string(spec_text)
except InvalidSpecifier: # pragma: no cover
return None
if impl in {"py", "python"}:
impl = None
if impl == "graalvm":
impl = "graalpy"
return PythonSpec(string_spec, impl, None, None, None, None, None, version_specifier=version_specifier)
class PythonSpec:
"""
Contains specification about a Python Interpreter.
:param str_spec: the raw specification string as provided by the caller.
:param implementation: interpreter implementation name (e.g. ``"cpython"``, ``"pypy"``), or ``None`` for any.
:param major: required major version, or ``None`` for any.
:param minor: required minor version, or ``None`` for any.
:param micro: required micro (patch) version, or ``None`` for any.
:param architecture: required pointer-size bitness (``32`` or ``64``), or ``None`` for any.
:param path: filesystem path to a specific interpreter, or ``None``.
:param free_threaded: whether a free-threaded build is required, or ``None`` for any.
:param machine: required ISA (e.g. ``"arm64"``), or ``None`` for any.
:param version_specifier:
`version specifier <https://packaging.python.org/en/latest/specifications/version-specifiers/>`_
constraints, or ``None``.
"""
def __init__( # noqa: PLR0913, PLR0917
self,
str_spec: str,
implementation: str | None,
major: int | None,
minor: int | None,
micro: int | None,
architecture: int | None,
path: str | None,
*,
free_threaded: bool | None = None,
machine: str | None = None,
version_specifier: SpecifierSet | None = None,
) -> None:
self.str_spec = str_spec
self.implementation = implementation
self.major = major
self.minor = minor
self.micro = micro
self.free_threaded = free_threaded
self.architecture = architecture
self.machine = machine
self.path = path
self.version_specifier = version_specifier
@classmethod
def from_string_spec(cls, string_spec: str) -> PythonSpec:
"""
Parse a string specification into a :class:`PythonSpec`.
:param string_spec: an interpreter spec — an absolute path, a version string, an implementation prefix,
or a `version specifier <https://packaging.python.org/en/latest/specifications/version-specifiers/>`_.
"""
if pathlib.Path(string_spec).is_absolute():
return cls(string_spec, None, None, None, None, None, string_spec)
if result := _parse_spec_pattern(string_spec):
return result
if result := _parse_specifier(string_spec):
return result
return cls(string_spec, None, None, None, None, None, string_spec)
def generate_re(self, *, windows: bool, all_implementations: bool = False) -> re.Pattern:
"""
Generate a regular expression for matching interpreter filenames.
:param windows: if ``True``, require a ``.exe`` suffix.
:param all_implementations: when ``True`` and the spec does not constrain the implementation, match every
filename in :data:`KNOWN_IMPLEMENTATIONS` instead of only ``python``. Used by enumeration APIs.
"""
version = r"{}(\.{}(\.{})?)?".format(
*(r"\d+" if v is None else v for v in (self.major, self.minor, self.micro)),
)
if self.implementation is not None:
impl = f"python|{re.escape(self.implementation)}"
elif all_implementations:
impl = "|".join(re.escape(i) for i in KNOWN_IMPLEMENTATIONS)
else:
impl = "python"
mod = "t?" if self.free_threaded else ""
suffix = r"\.exe" if windows else ""
version_conditional = "?" if windows or self.major is None else ""
return re.compile(
rf"(?P<impl>{impl})(?P<v>{version}{mod}){version_conditional}{suffix}$",
flags=re.IGNORECASE,
)
@property
def is_abs(self) -> bool:
"""``True`` if the spec refers to an absolute filesystem path."""
return self.path is not None and pathlib.Path(self.path).is_absolute()
def _check_version_specifier(self, spec: PythonSpec) -> bool:
"""Check if version specifier is satisfied."""
components: list[int] = []
for part in (self.major, self.minor, self.micro):
if part is None:
break
components.append(part)
if not components:
return True
version_str = ".".join(str(part) for part in components)
if spec.version_specifier is None:
return True
with contextlib.suppress(InvalidVersion):
Version.from_string(version_str)
for item in spec.version_specifier:
required_precision = self._get_required_precision(item)
if required_precision is None or len(components) < required_precision:
continue
if not item.contains(version_str):
return False
return True
@staticmethod
def _get_required_precision(item: SimpleSpecifier) -> int | None:
"""Get the required precision for a specifier item."""
if item.version is None:
return None
with contextlib.suppress(AttributeError, ValueError):
return len(item.version.release)
return None
def satisfies(self, spec: PythonSpec) -> bool: # noqa: PLR0911
"""
Check if this spec is compatible with the given *spec* (e.g. PEP-514 on Windows).
:param spec: the requirement to check against.
"""
if spec.is_abs and self.is_abs and self.path != spec.path:
return False
if (
spec.implementation is not None
and self.implementation is not None
and spec.implementation.lower() != self.implementation.lower()
):
return False
if spec.architecture is not None and spec.architecture != self.architecture:
return False
if spec.machine is not None and self.machine is not None and spec.machine != self.machine:
return False
if spec.free_threaded is not None and spec.free_threaded != self.free_threaded:
return False
if spec.version_specifier is not None and not self._check_version_specifier(spec):
return False
return all(
req is None or our is None or our == req
for our, req in zip((self.major, self.minor, self.micro), (spec.major, spec.minor, spec.micro))
)
def __repr__(self) -> str:
name = type(self).__name__
params = (
"implementation",
"major",
"minor",
"micro",
"architecture",
"machine",
"path",
"free_threaded",
"version_specifier",
)
return f"{name}({', '.join(f'{k}={getattr(self, k)}' for k in params if getattr(self, k) is not None)})"
__all__ = [
"KNOWN_IMPLEMENTATIONS",
"InvalidSpecifier",
"InvalidVersion",
"PythonSpec",
"SpecifierSet",
"Version",
]