_py_spec.py 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262
  1. """A Python specification is an abstract requirement definition of an interpreter."""
  2. from __future__ import annotations
  3. import contextlib
  4. import pathlib
  5. import re
  6. from typing import Final
  7. from ._py_info import normalize_isa
  8. from ._specifier import SimpleSpecifier, SimpleSpecifierSet, SimpleVersion
  9. PATTERN = re.compile(
  10. r"""
  11. ^
  12. (?P<impl>[a-zA-Z]+)? # implementation (e.g. cpython, pypy)
  13. (?P<version>[0-9.]+)? # version (e.g. 3.12, 3.12.1)
  14. (?P<threaded>t)? # free-threaded flag
  15. (?:-(?P<arch>32|64))? # architecture bitness
  16. (?:-(?P<machine>[a-zA-Z0-9_]+))? # ISA (e.g. arm64, x86_64)
  17. $
  18. """,
  19. re.VERBOSE,
  20. )
  21. SPECIFIER_PATTERN = re.compile(
  22. r"""
  23. ^
  24. (?:(?P<impl>[A-Za-z]+)\s*)? # optional implementation prefix
  25. (?P<spec>(?:===|==|~=|!=|<=|>=|<|>).+) # PEP 440 version specifier
  26. $
  27. """,
  28. re.VERBOSE,
  29. )
  30. _MAX_VERSION_PARTS: Final[int] = 3
  31. _SINGLE_DIGIT_MAX: Final[int] = 9
  32. SpecifierSet = SimpleSpecifierSet
  33. Version = SimpleVersion
  34. InvalidSpecifier = ValueError
  35. InvalidVersion = ValueError
  36. def _int_or_none(val: str | None) -> int | None:
  37. return None if val is None else int(val)
  38. def _parse_version_parts(version: str) -> tuple[int | None, int | None, int | None]:
  39. versions = tuple(int(i) for i in version.split(".") if i)
  40. if len(versions) > _MAX_VERSION_PARTS:
  41. msg = "too many version parts"
  42. raise ValueError(msg)
  43. if len(versions) == _MAX_VERSION_PARTS:
  44. return versions[0], versions[1], versions[2]
  45. if len(versions) == 2: # noqa: PLR2004
  46. return versions[0], versions[1], None
  47. version_data = versions[0]
  48. major = int(str(version_data)[0])
  49. minor = int(str(version_data)[1:]) if version_data > _SINGLE_DIGIT_MAX else None
  50. return major, minor, None
  51. def _parse_spec_pattern(string_spec: str) -> PythonSpec | None:
  52. if not (match := re.match(PATTERN, string_spec)):
  53. return None
  54. groups = match.groupdict()
  55. version = groups["version"]
  56. major, minor, micro, threaded = None, None, None, None
  57. if version is not None:
  58. try:
  59. major, minor, micro = _parse_version_parts(version)
  60. except ValueError:
  61. return None
  62. threaded = bool(groups["threaded"])
  63. impl = groups["impl"]
  64. if impl in {"py", "python"}:
  65. impl = None
  66. arch = _int_or_none(groups["arch"])
  67. machine = groups.get("machine")
  68. if machine is not None:
  69. machine = normalize_isa(machine)
  70. return PythonSpec(string_spec, impl, major, minor, micro, arch, None, free_threaded=threaded, machine=machine)
  71. def _parse_specifier(string_spec: str) -> PythonSpec | None:
  72. if not (specifier_match := SPECIFIER_PATTERN.match(string_spec.strip())):
  73. return None
  74. if SpecifierSet is None: # pragma: no cover
  75. return None
  76. impl = specifier_match.group("impl")
  77. spec_text = specifier_match.group("spec").strip()
  78. try:
  79. version_specifier = SpecifierSet.from_string(spec_text)
  80. except InvalidSpecifier: # pragma: no cover
  81. return None
  82. if impl in {"py", "python"}:
  83. impl = None
  84. return PythonSpec(string_spec, impl, None, None, None, None, None, version_specifier=version_specifier)
  85. class PythonSpec:
  86. """
  87. Contains specification about a Python Interpreter.
  88. :param str_spec: the raw specification string as provided by the caller.
  89. :param implementation: interpreter implementation name (e.g. ``"cpython"``, ``"pypy"``), or ``None`` for any.
  90. :param major: required major version, or ``None`` for any.
  91. :param minor: required minor version, or ``None`` for any.
  92. :param micro: required micro (patch) version, or ``None`` for any.
  93. :param architecture: required pointer-size bitness (``32`` or ``64``), or ``None`` for any.
  94. :param path: filesystem path to a specific interpreter, or ``None``.
  95. :param free_threaded: whether a free-threaded build is required, or ``None`` for any.
  96. :param machine: required ISA (e.g. ``"arm64"``), or ``None`` for any.
  97. :param version_specifier: PEP 440 version constraints, or ``None``.
  98. """
  99. def __init__( # noqa: PLR0913, PLR0917
  100. self,
  101. str_spec: str,
  102. implementation: str | None,
  103. major: int | None,
  104. minor: int | None,
  105. micro: int | None,
  106. architecture: int | None,
  107. path: str | None,
  108. *,
  109. free_threaded: bool | None = None,
  110. machine: str | None = None,
  111. version_specifier: SpecifierSet | None = None,
  112. ) -> None:
  113. self.str_spec = str_spec
  114. self.implementation = implementation
  115. self.major = major
  116. self.minor = minor
  117. self.micro = micro
  118. self.free_threaded = free_threaded
  119. self.architecture = architecture
  120. self.machine = machine
  121. self.path = path
  122. self.version_specifier = version_specifier
  123. @classmethod
  124. def from_string_spec(cls, string_spec: str) -> PythonSpec:
  125. """
  126. Parse a string specification into a :class:`PythonSpec`.
  127. :param string_spec: an interpreter spec — an absolute path, a version string, an implementation prefix,
  128. or a PEP 440 specifier.
  129. """
  130. if pathlib.Path(string_spec).is_absolute():
  131. return cls(string_spec, None, None, None, None, None, string_spec)
  132. if result := _parse_spec_pattern(string_spec):
  133. return result
  134. if result := _parse_specifier(string_spec):
  135. return result
  136. return cls(string_spec, None, None, None, None, None, string_spec)
  137. def generate_re(self, *, windows: bool) -> re.Pattern:
  138. """
  139. Generate a regular expression for matching interpreter filenames.
  140. :param windows: if ``True``, require a ``.exe`` suffix.
  141. """
  142. version = r"{}(\.{}(\.{})?)?".format(
  143. *(r"\d+" if v is None else v for v in (self.major, self.minor, self.micro)),
  144. )
  145. impl = "python" if self.implementation is None else f"python|{re.escape(self.implementation)}"
  146. mod = "t?" if self.free_threaded else ""
  147. suffix = r"\.exe" if windows else ""
  148. version_conditional = "?" if windows or self.major is None else ""
  149. return re.compile(
  150. rf"(?P<impl>{impl})(?P<v>{version}{mod}){version_conditional}{suffix}$",
  151. flags=re.IGNORECASE,
  152. )
  153. @property
  154. def is_abs(self) -> bool:
  155. """``True`` if the spec refers to an absolute filesystem path."""
  156. return self.path is not None and pathlib.Path(self.path).is_absolute()
  157. def _check_version_specifier(self, spec: PythonSpec) -> bool:
  158. """Check if version specifier is satisfied."""
  159. components: list[int] = []
  160. for part in (self.major, self.minor, self.micro):
  161. if part is None:
  162. break
  163. components.append(part)
  164. if not components:
  165. return True
  166. version_str = ".".join(str(part) for part in components)
  167. if spec.version_specifier is None:
  168. return True
  169. with contextlib.suppress(InvalidVersion):
  170. Version.from_string(version_str)
  171. for item in spec.version_specifier:
  172. required_precision = self._get_required_precision(item)
  173. if required_precision is None or len(components) < required_precision:
  174. continue
  175. if not item.contains(version_str):
  176. return False
  177. return True
  178. @staticmethod
  179. def _get_required_precision(item: SimpleSpecifier) -> int | None:
  180. """Get the required precision for a specifier item."""
  181. if item.version is None:
  182. return None
  183. with contextlib.suppress(AttributeError, ValueError):
  184. return len(item.version.release)
  185. return None
  186. def satisfies(self, spec: PythonSpec) -> bool: # noqa: PLR0911
  187. """
  188. Check if this spec is compatible with the given *spec* (e.g. PEP-514 on Windows).
  189. :param spec: the requirement to check against.
  190. """
  191. if spec.is_abs and self.is_abs and self.path != spec.path:
  192. return False
  193. if (
  194. spec.implementation is not None
  195. and self.implementation is not None
  196. and spec.implementation.lower() != self.implementation.lower()
  197. ):
  198. return False
  199. if spec.architecture is not None and spec.architecture != self.architecture:
  200. return False
  201. if spec.machine is not None and self.machine is not None and spec.machine != self.machine:
  202. return False
  203. if spec.free_threaded is not None and spec.free_threaded != self.free_threaded:
  204. return False
  205. if spec.version_specifier is not None and not self._check_version_specifier(spec):
  206. return False
  207. return all(
  208. req is None or our is None or our == req
  209. for our, req in zip((self.major, self.minor, self.micro), (spec.major, spec.minor, spec.micro))
  210. )
  211. def __repr__(self) -> str:
  212. name = type(self).__name__
  213. params = (
  214. "implementation",
  215. "major",
  216. "minor",
  217. "micro",
  218. "architecture",
  219. "machine",
  220. "path",
  221. "free_threaded",
  222. "version_specifier",
  223. )
  224. return f"{name}({', '.join(f'{k}={getattr(self, k)}' for k in params if getattr(self, k) is not None)})"
  225. __all__ = [
  226. "InvalidSpecifier",
  227. "InvalidVersion",
  228. "PythonSpec",
  229. "SpecifierSet",
  230. "Version",
  231. ]