builtin.py 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266
  1. from __future__ import annotations
  2. import logging
  3. import os
  4. import sys
  5. from contextlib import suppress
  6. from pathlib import Path
  7. from typing import TYPE_CHECKING
  8. from platformdirs import user_data_path
  9. from virtualenv.info import IS_WIN, fs_path_id
  10. from .discover import Discover
  11. from .py_info import PythonInfo
  12. from .py_spec import PythonSpec
  13. if TYPE_CHECKING:
  14. from argparse import ArgumentParser
  15. from collections.abc import Callable, Generator, Iterable, Mapping, Sequence
  16. from virtualenv.app_data.base import AppData
  17. LOGGER = logging.getLogger(__name__)
  18. class Builtin(Discover):
  19. python_spec: Sequence[str]
  20. app_data: AppData
  21. try_first_with: Sequence[str]
  22. def __init__(self, options) -> None:
  23. super().__init__(options)
  24. self.python_spec = options.python or [sys.executable]
  25. if self._env.get("VIRTUALENV_PYTHON"):
  26. self.python_spec = self.python_spec[1:] + self.python_spec[:1] # Rotate the list
  27. self.app_data = options.app_data
  28. self.try_first_with = options.try_first_with
  29. @classmethod
  30. def add_parser_arguments(cls, parser: ArgumentParser) -> None:
  31. parser.add_argument(
  32. "-p",
  33. "--python",
  34. dest="python",
  35. metavar="py",
  36. type=str,
  37. action="append",
  38. default=[],
  39. help="interpreter based on what to create environment (path/identifier/version-specifier) "
  40. "- by default use the interpreter where the tool is installed - first found wins. "
  41. "Version specifiers (e.g., >=3.12, ~=3.11.0, ==3.10) are also supported",
  42. )
  43. parser.add_argument(
  44. "--try-first-with",
  45. dest="try_first_with",
  46. metavar="py_exe",
  47. type=str,
  48. action="append",
  49. default=[],
  50. help="try first these interpreters before starting the discovery",
  51. )
  52. def run(self) -> PythonInfo | None:
  53. for python_spec in self.python_spec:
  54. result = get_interpreter(python_spec, self.try_first_with, self.app_data, self._env)
  55. if result is not None:
  56. return result
  57. return None
  58. def __repr__(self) -> str:
  59. spec = self.python_spec[0] if len(self.python_spec) == 1 else self.python_spec
  60. return f"{self.__class__.__name__} discover of python_spec={spec!r}"
  61. def get_interpreter(
  62. key, try_first_with: Iterable[str], app_data: AppData | None = None, env: Mapping[str, str] | None = None
  63. ) -> PythonInfo | None:
  64. spec = PythonSpec.from_string_spec(key)
  65. LOGGER.info("find interpreter for spec %r", spec)
  66. proposed_paths = set()
  67. env = os.environ if env is None else env
  68. for interpreter, impl_must_match in propose_interpreters(spec, try_first_with, app_data, env):
  69. key = interpreter.system_executable, impl_must_match
  70. if key in proposed_paths:
  71. continue
  72. LOGGER.info("proposed %s", interpreter)
  73. if interpreter.satisfies(spec, impl_must_match):
  74. LOGGER.debug("accepted %s", interpreter)
  75. return interpreter
  76. proposed_paths.add(key)
  77. return None
  78. def propose_interpreters( # noqa: C901, PLR0912, PLR0915
  79. spec: PythonSpec,
  80. try_first_with: Iterable[str],
  81. app_data: AppData | None = None,
  82. env: Mapping[str, str] | None = None,
  83. ) -> Generator[tuple[PythonInfo, bool], None, None]:
  84. # 0. if it's a path and exists, and is absolute path, this is the only option we consider
  85. env = os.environ if env is None else env
  86. tested_exes: set[str] = set()
  87. if spec.is_abs:
  88. try:
  89. os.lstat(spec.path) # Windows Store Python does not work with os.path.exists, but does for os.lstat
  90. except OSError:
  91. pass
  92. else:
  93. exe_raw = os.path.abspath(spec.path)
  94. exe_id = fs_path_id(exe_raw)
  95. if exe_id not in tested_exes:
  96. tested_exes.add(exe_id)
  97. yield PythonInfo.from_exe(exe_raw, app_data, env=env), True
  98. return
  99. # 1. try with first
  100. for py_exe in try_first_with:
  101. path = os.path.abspath(py_exe)
  102. try:
  103. os.lstat(path) # Windows Store Python does not work with os.path.exists, but does for os.lstat
  104. except OSError:
  105. pass
  106. else:
  107. exe_raw = os.path.abspath(path)
  108. exe_id = fs_path_id(exe_raw)
  109. if exe_id in tested_exes:
  110. continue
  111. tested_exes.add(exe_id)
  112. yield PythonInfo.from_exe(exe_raw, app_data, env=env), True
  113. # 1. if it's a path and exists
  114. if spec.path is not None:
  115. try:
  116. os.lstat(spec.path) # Windows Store Python does not work with os.path.exists, but does for os.lstat
  117. except OSError:
  118. pass
  119. else:
  120. exe_raw = os.path.abspath(spec.path)
  121. exe_id = fs_path_id(exe_raw)
  122. if exe_id not in tested_exes:
  123. tested_exes.add(exe_id)
  124. yield PythonInfo.from_exe(exe_raw, app_data, env=env), True
  125. if spec.is_abs:
  126. return
  127. else:
  128. # 2. otherwise try with the current
  129. current_python = PythonInfo.current_system(app_data)
  130. exe_raw = str(current_python.executable)
  131. exe_id = fs_path_id(exe_raw)
  132. if exe_id not in tested_exes:
  133. tested_exes.add(exe_id)
  134. yield current_python, True
  135. # 3. otherwise fallback to platform default logic
  136. if IS_WIN:
  137. from .windows import propose_interpreters # noqa: PLC0415
  138. for interpreter in propose_interpreters(spec, app_data, env):
  139. exe_raw = str(interpreter.executable)
  140. exe_id = fs_path_id(exe_raw)
  141. if exe_id in tested_exes:
  142. continue
  143. tested_exes.add(exe_id)
  144. yield interpreter, True
  145. # try to find on path, the path order matters (as the candidates are less easy to control by end user)
  146. find_candidates = path_exe_finder(spec)
  147. for pos, path in enumerate(get_paths(env)):
  148. LOGGER.debug(LazyPathDump(pos, path, env))
  149. for exe, impl_must_match in find_candidates(path):
  150. exe_raw = str(exe)
  151. exe_id = fs_path_id(exe_raw)
  152. if exe_id in tested_exes:
  153. continue
  154. tested_exes.add(exe_id)
  155. interpreter = PathPythonInfo.from_exe(exe_raw, app_data, raise_on_error=False, env=env)
  156. if interpreter is not None:
  157. yield interpreter, impl_must_match
  158. # otherwise try uv-managed python (~/.local/share/uv/python or platform equivalent)
  159. if uv_python_dir := os.getenv("UV_PYTHON_INSTALL_DIR"):
  160. uv_python_path = Path(uv_python_dir).expanduser()
  161. elif xdg_data_home := os.getenv("XDG_DATA_HOME"):
  162. uv_python_path = Path(xdg_data_home).expanduser() / "uv" / "python"
  163. else:
  164. uv_python_path = user_data_path("uv") / "python"
  165. for exe_path in uv_python_path.glob("*/bin/python"):
  166. interpreter = PathPythonInfo.from_exe(str(exe_path), app_data, raise_on_error=False, env=env)
  167. if interpreter is not None:
  168. yield interpreter, True
  169. def get_paths(env: Mapping[str, str]) -> Generator[Path, None, None]:
  170. path = env.get("PATH", None)
  171. if path is None:
  172. try:
  173. path = os.confstr("CS_PATH")
  174. except (AttributeError, ValueError):
  175. path = os.defpath
  176. if path:
  177. for p in map(Path, path.split(os.pathsep)):
  178. with suppress(OSError):
  179. if p.is_dir() and next(p.iterdir(), None):
  180. yield p
  181. class LazyPathDump:
  182. def __init__(self, pos: int, path: Path, env: Mapping[str, str]) -> None:
  183. self.pos = pos
  184. self.path = path
  185. self.env = env
  186. def __repr__(self) -> str:
  187. content = f"discover PATH[{self.pos}]={self.path}"
  188. if self.env.get("_VIRTUALENV_DEBUG"): # this is the over the board debug
  189. content += " with =>"
  190. for file_path in self.path.iterdir():
  191. try:
  192. if file_path.is_dir():
  193. continue
  194. if IS_WIN:
  195. pathext = self.env.get("PATHEXT", ".COM;.EXE;.BAT;.CMD").split(";")
  196. if not any(file_path.name.upper().endswith(ext) for ext in pathext):
  197. continue
  198. elif not (file_path.stat().st_mode & os.X_OK):
  199. continue
  200. except OSError:
  201. pass
  202. content += " "
  203. content += file_path.name
  204. return content
  205. def path_exe_finder(spec: PythonSpec) -> Callable[[Path], Generator[tuple[Path, bool], None, None]]:
  206. """Given a spec, return a function that can be called on a path to find all matching files in it."""
  207. pat = spec.generate_re(windows=sys.platform == "win32")
  208. direct = spec.str_spec
  209. if sys.platform == "win32":
  210. direct = f"{direct}.exe"
  211. def path_exes(path: Path) -> Generator[tuple[Path, bool], None, None]:
  212. # 4. then maybe it's something exact on PATH - if it was direct lookup implementation no longer counts
  213. direct_path = path / direct
  214. if direct_path.exists():
  215. yield direct_path, False
  216. # 5. or from the spec we can deduce if a name on path matches
  217. for exe in path.iterdir():
  218. match = pat.fullmatch(exe.name)
  219. if match:
  220. # the implementation must match when we find “python[ver]”
  221. yield exe.absolute(), match["impl"] == "python"
  222. return path_exes
  223. class PathPythonInfo(PythonInfo):
  224. """python info from path."""
  225. __all__ = [
  226. "Builtin",
  227. "PathPythonInfo",
  228. "get_interpreter",
  229. ]