build_env.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417
  1. """Build Environment used for isolation during sdist building"""
  2. from __future__ import annotations
  3. import logging
  4. import os
  5. import pathlib
  6. import site
  7. import sys
  8. import textwrap
  9. from collections import OrderedDict
  10. from collections.abc import Iterable
  11. from types import TracebackType
  12. from typing import TYPE_CHECKING, Protocol, TypedDict
  13. from pip._vendor.packaging.version import Version
  14. from pip import __file__ as pip_location
  15. from pip._internal.cli.spinners import open_spinner
  16. from pip._internal.locations import get_platlib, get_purelib, get_scheme
  17. from pip._internal.metadata import get_default_environment, get_environment
  18. from pip._internal.utils.deprecation import deprecated
  19. from pip._internal.utils.logging import VERBOSE
  20. from pip._internal.utils.packaging import get_requirement
  21. from pip._internal.utils.subprocess import call_subprocess
  22. from pip._internal.utils.temp_dir import TempDirectory, tempdir_kinds
  23. if TYPE_CHECKING:
  24. from pip._internal.index.package_finder import PackageFinder
  25. from pip._internal.req.req_install import InstallRequirement
  26. class ExtraEnviron(TypedDict, total=False):
  27. extra_environ: dict[str, str]
  28. logger = logging.getLogger(__name__)
  29. def _dedup(a: str, b: str) -> tuple[str] | tuple[str, str]:
  30. return (a, b) if a != b else (a,)
  31. class _Prefix:
  32. def __init__(self, path: str) -> None:
  33. self.path = path
  34. self.setup = False
  35. scheme = get_scheme("", prefix=path)
  36. self.bin_dir = scheme.scripts
  37. self.lib_dirs = _dedup(scheme.purelib, scheme.platlib)
  38. def get_runnable_pip() -> str:
  39. """Get a file to pass to a Python executable, to run the currently-running pip.
  40. This is used to run a pip subprocess, for installing requirements into the build
  41. environment.
  42. """
  43. source = pathlib.Path(pip_location).resolve().parent
  44. if not source.is_dir():
  45. # This would happen if someone is using pip from inside a zip file. In that
  46. # case, we can use that directly.
  47. return str(source)
  48. return os.fsdecode(source / "__pip-runner__.py")
  49. def _get_system_sitepackages() -> set[str]:
  50. """Get system site packages
  51. Usually from site.getsitepackages,
  52. but fallback on `get_purelib()/get_platlib()` if unavailable
  53. (e.g. in a virtualenv created by virtualenv<20)
  54. Returns normalized set of strings.
  55. """
  56. if hasattr(site, "getsitepackages"):
  57. system_sites = site.getsitepackages()
  58. else:
  59. # virtualenv < 20 overwrites site.py without getsitepackages
  60. # fallback on get_purelib/get_platlib.
  61. # this is known to miss things, but shouldn't in the cases
  62. # where getsitepackages() has been removed (inside a virtualenv)
  63. system_sites = [get_purelib(), get_platlib()]
  64. return {os.path.normcase(path) for path in system_sites}
  65. class BuildEnvironmentInstaller(Protocol):
  66. """
  67. Interface for installing build dependencies into an isolated build
  68. environment.
  69. """
  70. def install(
  71. self,
  72. requirements: Iterable[str],
  73. prefix: _Prefix,
  74. *,
  75. kind: str,
  76. for_req: InstallRequirement | None,
  77. ) -> None: ...
  78. class SubprocessBuildEnvironmentInstaller:
  79. """
  80. Install build dependencies by calling pip in a subprocess.
  81. """
  82. def __init__(
  83. self,
  84. finder: PackageFinder,
  85. build_constraints: list[str] | None = None,
  86. build_constraint_feature_enabled: bool = False,
  87. ) -> None:
  88. self.finder = finder
  89. self._build_constraints = build_constraints or []
  90. self._build_constraint_feature_enabled = build_constraint_feature_enabled
  91. def _deprecation_constraint_check(self) -> None:
  92. """
  93. Check for deprecation warning: PIP_CONSTRAINT affecting build environments.
  94. This warns when build-constraint feature is NOT enabled and PIP_CONSTRAINT
  95. is not empty.
  96. """
  97. if self._build_constraint_feature_enabled or self._build_constraints:
  98. return
  99. pip_constraint = os.environ.get("PIP_CONSTRAINT")
  100. if not pip_constraint or not pip_constraint.strip():
  101. return
  102. deprecated(
  103. reason=(
  104. "Setting PIP_CONSTRAINT will not affect "
  105. "build constraints in the future,"
  106. ),
  107. replacement=(
  108. "to specify build constraints using --build-constraint or "
  109. "PIP_BUILD_CONSTRAINT. To disable this warning without "
  110. "any build constraints set --use-feature=build-constraint or "
  111. 'PIP_USE_FEATURE="build-constraint"'
  112. ),
  113. gone_in="26.2",
  114. issue=None,
  115. )
  116. def install(
  117. self,
  118. requirements: Iterable[str],
  119. prefix: _Prefix,
  120. *,
  121. kind: str,
  122. for_req: InstallRequirement | None,
  123. ) -> None:
  124. self._deprecation_constraint_check()
  125. finder = self.finder
  126. args: list[str] = [
  127. sys.executable,
  128. get_runnable_pip(),
  129. "install",
  130. "--ignore-installed",
  131. "--no-user",
  132. "--prefix",
  133. prefix.path,
  134. "--no-warn-script-location",
  135. "--disable-pip-version-check",
  136. # As the build environment is ephemeral, it's wasteful to
  137. # pre-compile everything, especially as not every Python
  138. # module will be used/compiled in most cases.
  139. "--no-compile",
  140. # The prefix specified two lines above, thus
  141. # target from config file or env var should be ignored
  142. "--target",
  143. "",
  144. ]
  145. if logger.getEffectiveLevel() <= logging.DEBUG:
  146. args.append("-vv")
  147. elif logger.getEffectiveLevel() <= VERBOSE:
  148. args.append("-v")
  149. for format_control in ("no_binary", "only_binary"):
  150. formats = getattr(finder.format_control, format_control)
  151. args.extend(
  152. (
  153. "--" + format_control.replace("_", "-"),
  154. ",".join(sorted(formats or {":none:"})),
  155. )
  156. )
  157. index_urls = finder.index_urls
  158. if index_urls:
  159. args.extend(["-i", index_urls[0]])
  160. for extra_index in index_urls[1:]:
  161. args.extend(["--extra-index-url", extra_index])
  162. else:
  163. args.append("--no-index")
  164. for link in finder.find_links:
  165. args.extend(["--find-links", link])
  166. if finder.proxy:
  167. args.extend(["--proxy", finder.proxy])
  168. for host in finder.trusted_hosts:
  169. args.extend(["--trusted-host", host])
  170. if finder.custom_cert:
  171. args.extend(["--cert", finder.custom_cert])
  172. if finder.client_cert:
  173. args.extend(["--client-cert", finder.client_cert])
  174. if finder.allow_all_prereleases:
  175. args.append("--pre")
  176. if finder.prefer_binary:
  177. args.append("--prefer-binary")
  178. # Handle build constraints
  179. if self._build_constraint_feature_enabled:
  180. args.extend(["--use-feature", "build-constraint"])
  181. if self._build_constraints:
  182. # Build constraints must be passed as both constraints
  183. # and build constraints, so that nested builds receive
  184. # build constraints
  185. for constraint_file in self._build_constraints:
  186. args.extend(["--constraint", constraint_file])
  187. args.extend(["--build-constraint", constraint_file])
  188. extra_environ: ExtraEnviron = {}
  189. if self._build_constraint_feature_enabled and not self._build_constraints:
  190. # If there are no build constraints but the build constraints
  191. # feature is enabled then we must ignore regular constraints
  192. # in the isolated build environment
  193. extra_environ = {"extra_environ": {"_PIP_IN_BUILD_IGNORE_CONSTRAINTS": "1"}}
  194. args.append("--")
  195. args.extend(requirements)
  196. identify_requirement = (
  197. f" for {for_req.name}" if for_req and for_req.name else ""
  198. )
  199. with open_spinner(f"Installing {kind}") as spinner:
  200. call_subprocess(
  201. args,
  202. command_desc=f"installing {kind}{identify_requirement}",
  203. spinner=spinner,
  204. **extra_environ,
  205. )
  206. class BuildEnvironment:
  207. """Creates and manages an isolated environment to install build deps"""
  208. def __init__(self, installer: BuildEnvironmentInstaller) -> None:
  209. self.installer = installer
  210. temp_dir = TempDirectory(kind=tempdir_kinds.BUILD_ENV, globally_managed=True)
  211. self._prefixes = OrderedDict(
  212. (name, _Prefix(os.path.join(temp_dir.path, name)))
  213. for name in ("normal", "overlay")
  214. )
  215. self._bin_dirs: list[str] = []
  216. self._lib_dirs: list[str] = []
  217. for prefix in reversed(list(self._prefixes.values())):
  218. self._bin_dirs.append(prefix.bin_dir)
  219. self._lib_dirs.extend(prefix.lib_dirs)
  220. # Customize site to:
  221. # - ensure .pth files are honored
  222. # - prevent access to system site packages
  223. system_sites = _get_system_sitepackages()
  224. self._site_dir = os.path.join(temp_dir.path, "site")
  225. if not os.path.exists(self._site_dir):
  226. os.mkdir(self._site_dir)
  227. with open(
  228. os.path.join(self._site_dir, "sitecustomize.py"), "w", encoding="utf-8"
  229. ) as fp:
  230. fp.write(
  231. textwrap.dedent(
  232. """
  233. import os, site, sys
  234. # First, drop system-sites related paths.
  235. original_sys_path = sys.path[:]
  236. known_paths = set()
  237. for path in {system_sites!r}:
  238. site.addsitedir(path, known_paths=known_paths)
  239. system_paths = set(
  240. os.path.normcase(path)
  241. for path in sys.path[len(original_sys_path):]
  242. )
  243. original_sys_path = [
  244. path for path in original_sys_path
  245. if os.path.normcase(path) not in system_paths
  246. ]
  247. sys.path = original_sys_path
  248. # Second, add lib directories.
  249. # ensuring .pth file are processed.
  250. for path in {lib_dirs!r}:
  251. assert not path in sys.path
  252. site.addsitedir(path)
  253. """
  254. ).format(system_sites=system_sites, lib_dirs=self._lib_dirs)
  255. )
  256. def __enter__(self) -> None:
  257. self._save_env = {
  258. name: os.environ.get(name, None)
  259. for name in ("PATH", "PYTHONNOUSERSITE", "PYTHONPATH")
  260. }
  261. path = self._bin_dirs[:]
  262. old_path = self._save_env["PATH"]
  263. if old_path:
  264. path.extend(old_path.split(os.pathsep))
  265. pythonpath = [self._site_dir]
  266. os.environ.update(
  267. {
  268. "PATH": os.pathsep.join(path),
  269. "PYTHONNOUSERSITE": "1",
  270. "PYTHONPATH": os.pathsep.join(pythonpath),
  271. }
  272. )
  273. def __exit__(
  274. self,
  275. exc_type: type[BaseException] | None,
  276. exc_val: BaseException | None,
  277. exc_tb: TracebackType | None,
  278. ) -> None:
  279. for varname, old_value in self._save_env.items():
  280. if old_value is None:
  281. os.environ.pop(varname, None)
  282. else:
  283. os.environ[varname] = old_value
  284. def check_requirements(
  285. self, reqs: Iterable[str]
  286. ) -> tuple[set[tuple[str, str]], set[str]]:
  287. """Return 2 sets:
  288. - conflicting requirements: set of (installed, wanted) reqs tuples
  289. - missing requirements: set of reqs
  290. """
  291. missing = set()
  292. conflicting = set()
  293. if reqs:
  294. env = (
  295. get_environment(self._lib_dirs)
  296. if hasattr(self, "_lib_dirs")
  297. else get_default_environment()
  298. )
  299. for req_str in reqs:
  300. req = get_requirement(req_str)
  301. # We're explicitly evaluating with an empty extra value, since build
  302. # environments are not provided any mechanism to select specific extras.
  303. if req.marker is not None and not req.marker.evaluate({"extra": ""}):
  304. continue
  305. dist = env.get_distribution(req.name)
  306. if not dist:
  307. missing.add(req_str)
  308. continue
  309. if isinstance(dist.version, Version):
  310. installed_req_str = f"{req.name}=={dist.version}"
  311. else:
  312. installed_req_str = f"{req.name}==={dist.version}"
  313. if not req.specifier.contains(dist.version, prereleases=True):
  314. conflicting.add((installed_req_str, req_str))
  315. # FIXME: Consider direct URL?
  316. return conflicting, missing
  317. def install_requirements(
  318. self,
  319. requirements: Iterable[str],
  320. prefix_as_string: str,
  321. *,
  322. kind: str,
  323. for_req: InstallRequirement | None = None,
  324. ) -> None:
  325. prefix = self._prefixes[prefix_as_string]
  326. assert not prefix.setup
  327. prefix.setup = True
  328. if not requirements:
  329. return
  330. self.installer.install(requirements, prefix, kind=kind, for_req=for_req)
  331. class NoOpBuildEnvironment(BuildEnvironment):
  332. """A no-op drop-in replacement for BuildEnvironment"""
  333. def __init__(self) -> None:
  334. pass
  335. def __enter__(self) -> None:
  336. pass
  337. def __exit__(
  338. self,
  339. exc_type: type[BaseException] | None,
  340. exc_val: BaseException | None,
  341. exc_tb: TracebackType | None,
  342. ) -> None:
  343. pass
  344. def cleanup(self) -> None:
  345. pass
  346. def install_requirements(
  347. self,
  348. requirements: Iterable[str],
  349. prefix_as_string: str,
  350. *,
  351. kind: str,
  352. for_req: InstallRequirement | None = None,
  353. ) -> None:
  354. raise NotImplementedError()