python.py 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202
  1. from __future__ import annotations
  2. import dataclasses as dc
  3. import logging
  4. import os
  5. import subprocess
  6. from functools import lru_cache
  7. from pathlib import Path
  8. from packaging.version import InvalidVersion, Version
  9. from findpython.utils import get_binary_hash
  10. logger = logging.getLogger("findpython")
  11. GET_VERSION_TIMEOUT = float(os.environ.get("FINDPYTHON_GET_VERSION_TIMEOUT", 5))
  12. @lru_cache(maxsize=1024)
  13. def _run_script(executable: str, script: str, timeout: float | None = None) -> str:
  14. """Run a script and return the output."""
  15. command = [executable, "-EsSc", script]
  16. logger.debug("Running script: %s", command)
  17. return subprocess.run(
  18. command,
  19. stdout=subprocess.PIPE,
  20. stderr=subprocess.DEVNULL,
  21. timeout=timeout,
  22. check=True,
  23. text=True,
  24. ).stdout
  25. @dc.dataclass
  26. class PythonVersion:
  27. """The single Python version object found by pythonfinder."""
  28. executable: Path
  29. _version: Version | None = None
  30. _architecture: str | None = None
  31. _interpreter: Path | None = None
  32. keep_symlink: bool = False
  33. def is_valid(self) -> bool:
  34. """Return True if the python is not broken."""
  35. try:
  36. v = self._get_version()
  37. except (
  38. OSError,
  39. subprocess.CalledProcessError,
  40. subprocess.TimeoutExpired,
  41. InvalidVersion,
  42. ):
  43. return False
  44. if self._version is None:
  45. self._version = v
  46. return True
  47. @property
  48. def real_path(self) -> Path:
  49. """Resolve the symlink if possible and return the real path."""
  50. try:
  51. return self.executable.resolve()
  52. except OSError:
  53. return self.executable
  54. @property
  55. def name(self) -> str:
  56. """Return the name of the python."""
  57. return self.executable.name
  58. @property
  59. def interpreter(self) -> Path:
  60. if self._interpreter is None:
  61. self._interpreter = Path(self._get_interpreter())
  62. return self._interpreter
  63. @property
  64. def version(self) -> Version:
  65. """Return the version of the python."""
  66. if self._version is None:
  67. self._version = self._get_version()
  68. return self._version
  69. @property
  70. def major(self) -> int:
  71. """Return the major version of the python."""
  72. return self.version.major
  73. @property
  74. def minor(self) -> int:
  75. """Return the minor version of the python."""
  76. return self.version.minor
  77. @property
  78. def patch(self) -> int:
  79. """Return the micro version of the python."""
  80. return self.version.micro
  81. @property
  82. def is_prerelease(self) -> bool:
  83. """Return True if the python is a prerelease."""
  84. return self.version.is_prerelease
  85. @property
  86. def is_devrelease(self) -> bool:
  87. """Return True if the python is a devrelease."""
  88. return self.version.is_devrelease
  89. @property
  90. def architecture(self) -> str:
  91. if not self._architecture:
  92. self._architecture = self._get_architecture()
  93. return self._architecture
  94. def binary_hash(self) -> str:
  95. """Return the binary hash of the python."""
  96. return get_binary_hash(self.real_path)
  97. def matches(
  98. self,
  99. major: int | None = None,
  100. minor: int | None = None,
  101. patch: int | None = None,
  102. pre: bool | None = None,
  103. dev: bool | None = None,
  104. name: str | None = None,
  105. architecture: str | None = None,
  106. ) -> bool:
  107. """
  108. Return True if the python matches the provided criteria.
  109. :param major: The major version to match.
  110. :type major: int
  111. :param minor: The minor version to match.
  112. :type minor: int
  113. :param patch: The micro version to match.
  114. :type patch: int
  115. :param pre: Whether the python is a prerelease.
  116. :type pre: bool
  117. :param dev: Whether the python is a devrelease.
  118. :type dev: bool
  119. :param name: The name of the python.
  120. :type name: str
  121. :param architecture: The architecture of the python.
  122. :type architecture: str
  123. :return: Whether the python matches the provided criteria.
  124. :rtype: bool
  125. """
  126. if major is not None and self.major != major:
  127. return False
  128. if minor is not None and self.minor != minor:
  129. return False
  130. if patch is not None and self.patch != patch:
  131. return False
  132. if pre is not None and self.is_prerelease != pre:
  133. return False
  134. if dev is not None and self.is_devrelease != dev:
  135. return False
  136. if name is not None and self.name != name:
  137. return False
  138. if architecture is not None and self.architecture != architecture:
  139. return False
  140. return True
  141. def __hash__(self) -> int:
  142. return hash(self.executable)
  143. def __repr__(self) -> str:
  144. attrs = ("executable", "version", "architecture", "major", "minor", "patch")
  145. return "<PythonVersion {}>".format(
  146. ", ".join(f"{attr}={getattr(self, attr)!r}" for attr in attrs)
  147. )
  148. def __str__(self) -> str:
  149. return f"{self.name} {self.version} @ {self.executable}"
  150. def _get_version(self) -> Version:
  151. """Get the version of the python."""
  152. script = "import platform; print(platform.python_version())"
  153. version = _run_script(
  154. str(self.executable), script, timeout=GET_VERSION_TIMEOUT
  155. ).strip()
  156. # Dev builds may produce version like `3.11.0+` and packaging.version
  157. # will reject it. Here we just remove the part after `+`
  158. # since it isn't critical for version comparison.
  159. version = version.split("+")[0]
  160. return Version(version)
  161. def _get_architecture(self) -> str:
  162. script = "import platform; print(platform.architecture()[0])"
  163. return _run_script(str(self.executable), script).strip()
  164. def _get_interpreter(self) -> str:
  165. script = "import sys; print(sys.executable)"
  166. return _run_script(str(self.executable), script).strip()
  167. def __lt__(self, other: PythonVersion) -> bool:
  168. """Sort by the version, then by length of the executable path."""
  169. return (self.version, len(self.executable.as_posix())) < (
  170. other.version,
  171. len(other.executable.as_posix()),
  172. )