build_env.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606
  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, Sequence
  11. from contextlib import AbstractContextManager as ContextManager
  12. from contextlib import nullcontext
  13. from io import StringIO
  14. from types import TracebackType
  15. from typing import TYPE_CHECKING, Protocol, TypedDict
  16. from pip._vendor.packaging.version import Version
  17. from pip import __file__ as pip_location
  18. from pip._internal.cli.spinners import open_rich_spinner, open_spinner
  19. from pip._internal.exceptions import (
  20. BuildDependencyInstallError,
  21. DiagnosticPipError,
  22. InstallWheelBuildError,
  23. PipError,
  24. )
  25. from pip._internal.locations import get_platlib, get_purelib, get_scheme
  26. from pip._internal.metadata import get_default_environment, get_environment
  27. from pip._internal.utils.deprecation import deprecated
  28. from pip._internal.utils.logging import VERBOSE, capture_logging
  29. from pip._internal.utils.packaging import get_requirement
  30. from pip._internal.utils.subprocess import call_subprocess
  31. from pip._internal.utils.temp_dir import TempDirectory, tempdir_kinds
  32. if TYPE_CHECKING:
  33. from pip._internal.cache import WheelCache
  34. from pip._internal.index.package_finder import PackageFinder
  35. from pip._internal.operations.build.build_tracker import BuildTracker
  36. from pip._internal.req.req_install import InstallRequirement
  37. from pip._internal.resolution.base import BaseResolver
  38. class ExtraEnviron(TypedDict, total=False):
  39. extra_environ: dict[str, str]
  40. logger = logging.getLogger(__name__)
  41. def _dedup(a: str, b: str) -> tuple[str] | tuple[str, str]:
  42. return (a, b) if a != b else (a,)
  43. class _Prefix:
  44. def __init__(self, path: str) -> None:
  45. self.path = path
  46. self.setup = False
  47. scheme = get_scheme("", prefix=path)
  48. self.bin_dir = scheme.scripts
  49. self.lib_dirs = _dedup(scheme.purelib, scheme.platlib)
  50. def get_runnable_pip() -> str:
  51. """Get a file to pass to a Python executable, to run the currently-running pip.
  52. This is used to run a pip subprocess, for installing requirements into the build
  53. environment.
  54. """
  55. source = pathlib.Path(pip_location).resolve().parent
  56. if not source.is_dir():
  57. # This would happen if someone is using pip from inside a zip file. In that
  58. # case, we can use that directly.
  59. return str(source)
  60. return os.fsdecode(source / "__pip-runner__.py")
  61. def _get_system_sitepackages() -> set[str]:
  62. """Get system site packages
  63. Usually from site.getsitepackages,
  64. but fallback on `get_purelib()/get_platlib()` if unavailable
  65. (e.g. in a virtualenv created by virtualenv<20)
  66. Returns normalized set of strings.
  67. """
  68. if hasattr(site, "getsitepackages"):
  69. system_sites = site.getsitepackages()
  70. else:
  71. # virtualenv < 20 overwrites site.py without getsitepackages
  72. # fallback on get_purelib/get_platlib.
  73. # this is known to miss things, but shouldn't in the cases
  74. # where getsitepackages() has been removed (inside a virtualenv)
  75. system_sites = [get_purelib(), get_platlib()]
  76. return {os.path.normcase(path) for path in system_sites}
  77. class BuildEnvironmentInstaller(Protocol):
  78. """
  79. Interface for installing build dependencies into an isolated build
  80. environment.
  81. """
  82. def install(
  83. self,
  84. requirements: Iterable[str],
  85. prefix: _Prefix,
  86. *,
  87. kind: str,
  88. for_req: InstallRequirement | None,
  89. ) -> None: ...
  90. class SubprocessBuildEnvironmentInstaller:
  91. """
  92. Install build dependencies by calling pip in a subprocess.
  93. """
  94. def __init__(
  95. self,
  96. finder: PackageFinder,
  97. build_constraints: list[str] | None = None,
  98. build_constraint_feature_enabled: bool = False,
  99. ) -> None:
  100. self.finder = finder
  101. self._build_constraints = build_constraints or []
  102. self._build_constraint_feature_enabled = build_constraint_feature_enabled
  103. def _deprecation_constraint_check(self) -> None:
  104. """
  105. Check for deprecation warning: PIP_CONSTRAINT affecting build environments.
  106. This warns when build-constraint feature is NOT enabled and PIP_CONSTRAINT
  107. is not empty.
  108. """
  109. if self._build_constraint_feature_enabled or self._build_constraints:
  110. return
  111. pip_constraint = os.environ.get("PIP_CONSTRAINT")
  112. if not pip_constraint or not pip_constraint.strip():
  113. return
  114. deprecated(
  115. reason=(
  116. "Setting PIP_CONSTRAINT will not affect "
  117. "build constraints in the future,"
  118. ),
  119. replacement=(
  120. "to specify build constraints using --build-constraint or "
  121. "PIP_BUILD_CONSTRAINT. To disable this warning without "
  122. "any build constraints set --use-feature=build-constraint or "
  123. 'PIP_USE_FEATURE="build-constraint"'
  124. ),
  125. gone_in="26.2",
  126. issue=None,
  127. )
  128. def install(
  129. self,
  130. requirements: Iterable[str],
  131. prefix: _Prefix,
  132. *,
  133. kind: str,
  134. for_req: InstallRequirement | None,
  135. ) -> None:
  136. self._deprecation_constraint_check()
  137. finder = self.finder
  138. args: list[str] = [
  139. sys.executable,
  140. get_runnable_pip(),
  141. "install",
  142. "--ignore-installed",
  143. "--no-user",
  144. "--prefix",
  145. prefix.path,
  146. "--no-warn-script-location",
  147. "--disable-pip-version-check",
  148. # As the build environment is ephemeral, it's wasteful to
  149. # pre-compile everything, especially as not every Python
  150. # module will be used/compiled in most cases.
  151. "--no-compile",
  152. # The prefix specified two lines above, thus
  153. # target from config file or env var should be ignored
  154. "--target",
  155. "",
  156. ]
  157. if logger.getEffectiveLevel() <= logging.DEBUG:
  158. args.append("-vv")
  159. elif logger.getEffectiveLevel() <= VERBOSE:
  160. args.append("-v")
  161. for format_control in ("no_binary", "only_binary"):
  162. formats = getattr(finder.format_control, format_control)
  163. args.extend(
  164. (
  165. "--" + format_control.replace("_", "-"),
  166. ",".join(sorted(formats or {":none:"})),
  167. )
  168. )
  169. if finder.release_control is not None:
  170. # Use ordered args to preserve the user's original command-line order
  171. # This is important because later flags can override earlier ones
  172. for attr_name, value in finder.release_control.get_ordered_args():
  173. args.extend(("--" + attr_name.replace("_", "-"), value))
  174. index_urls = finder.index_urls
  175. if index_urls:
  176. args.extend(["-i", index_urls[0]])
  177. for extra_index in index_urls[1:]:
  178. args.extend(["--extra-index-url", extra_index])
  179. else:
  180. args.append("--no-index")
  181. for link in finder.find_links:
  182. args.extend(["--find-links", link])
  183. if finder.proxy:
  184. args.extend(["--proxy", finder.proxy])
  185. for host in finder.trusted_hosts:
  186. args.extend(["--trusted-host", host])
  187. if finder.custom_cert:
  188. args.extend(["--cert", finder.custom_cert])
  189. if finder.client_cert:
  190. args.extend(["--client-cert", finder.client_cert])
  191. if finder.prefer_binary:
  192. args.append("--prefer-binary")
  193. # Handle build constraints
  194. if self._build_constraint_feature_enabled:
  195. args.extend(["--use-feature", "build-constraint"])
  196. if self._build_constraints:
  197. # Build constraints must be passed as both constraints
  198. # and build constraints, so that nested builds receive
  199. # build constraints
  200. for constraint_file in self._build_constraints:
  201. args.extend(["--constraint", constraint_file])
  202. args.extend(["--build-constraint", constraint_file])
  203. extra_environ: ExtraEnviron = {}
  204. if self._build_constraint_feature_enabled and not self._build_constraints:
  205. # If there are no build constraints but the build constraints
  206. # feature is enabled then we must ignore regular constraints
  207. # in the isolated build environment
  208. extra_environ = {"extra_environ": {"_PIP_IN_BUILD_IGNORE_CONSTRAINTS": "1"}}
  209. if finder.uploaded_prior_to:
  210. args.extend(["--uploaded-prior-to", finder.uploaded_prior_to.isoformat()])
  211. args.append("--")
  212. args.extend(requirements)
  213. identify_requirement = (
  214. f" for {for_req.name}" if for_req and for_req.name else ""
  215. )
  216. with open_spinner(f"Installing {kind}") as spinner:
  217. call_subprocess(
  218. args,
  219. command_desc=f"installing {kind}{identify_requirement}",
  220. spinner=spinner,
  221. **extra_environ,
  222. )
  223. class InprocessBuildEnvironmentInstaller:
  224. """
  225. Build dependency installer that runs in the same pip process.
  226. This contains a stripped down version of the install command with
  227. only the logic necessary for installing build dependencies. The
  228. finder, session, build tracker, and wheel cache are reused, but new
  229. instances of everything else are created as needed.
  230. Options are inherited from the parent install command unless
  231. they don't make sense for build dependencies (in which case, they
  232. are hard-coded, see comments below).
  233. """
  234. def __init__(
  235. self,
  236. *,
  237. finder: PackageFinder,
  238. build_tracker: BuildTracker,
  239. wheel_cache: WheelCache,
  240. build_constraints: Sequence[InstallRequirement] = (),
  241. verbosity: int = 0,
  242. ) -> None:
  243. from pip._internal.operations.prepare import RequirementPreparer
  244. self._finder = finder
  245. self._build_constraints = build_constraints
  246. self._wheel_cache = wheel_cache
  247. self._level = 0
  248. build_dir = TempDirectory(kind="build-env-install", globally_managed=True)
  249. self._preparer = RequirementPreparer(
  250. build_isolation_installer=self,
  251. # Inherited options or state.
  252. finder=finder,
  253. session=finder._link_collector.session,
  254. build_dir=build_dir.path,
  255. build_tracker=build_tracker,
  256. verbosity=verbosity,
  257. # This is irrelevant as it only applies to editable requirements.
  258. src_dir="",
  259. # Hard-coded options (that should NOT be inherited).
  260. download_dir=None,
  261. build_isolation=True,
  262. check_build_deps=False,
  263. progress_bar="off",
  264. # TODO: hash-checking should be extended to build deps, but that is
  265. # deferred for later as it'd be a breaking change.
  266. require_hashes=False,
  267. use_user_site=False,
  268. lazy_wheel=False,
  269. legacy_resolver=False,
  270. )
  271. def install(
  272. self,
  273. requirements: Iterable[str],
  274. prefix: _Prefix,
  275. *,
  276. kind: str,
  277. for_req: InstallRequirement | None,
  278. ) -> None:
  279. """Install entrypoint. Manages output capturing and error handling."""
  280. capture_logs = not logger.isEnabledFor(VERBOSE) and self._level == 0
  281. if capture_logs:
  282. # Hide the logs from the installation of build dependencies.
  283. # They will be shown only if an error occurs.
  284. capture_ctx: ContextManager[StringIO] = capture_logging()
  285. spinner: ContextManager[None] = open_rich_spinner(f"Installing {kind}")
  286. else:
  287. # Otherwise, pass-through all logs (with a header).
  288. capture_ctx, spinner = nullcontext(StringIO()), nullcontext()
  289. logger.info("Installing %s ...", kind)
  290. try:
  291. self._level += 1
  292. with spinner, capture_ctx as stream:
  293. self._install_impl(requirements, prefix)
  294. except DiagnosticPipError as exc:
  295. # Format similar to a nested subprocess error, where the
  296. # causing error is shown first, followed by the build error.
  297. logger.info(textwrap.dedent(stream.getvalue()))
  298. logger.error("%s", exc, extra={"rich": True})
  299. logger.info("")
  300. raise BuildDependencyInstallError(
  301. for_req, requirements, cause=exc, log_lines=None
  302. )
  303. except Exception as exc:
  304. logs: list[str] | None = textwrap.dedent(stream.getvalue()).splitlines()
  305. if not capture_logs:
  306. # If logs aren't being captured, then display the error inline
  307. # with the rest of the logs.
  308. logs = None
  309. if isinstance(exc, PipError):
  310. logger.error("%s", exc)
  311. else:
  312. logger.exception("pip crashed unexpectedly")
  313. raise BuildDependencyInstallError(
  314. for_req, requirements, cause=exc, log_lines=logs
  315. )
  316. finally:
  317. self._level -= 1
  318. def _install_impl(self, requirements: Iterable[str], prefix: _Prefix) -> None:
  319. """Core build dependency install logic."""
  320. from pip._internal.commands.install import installed_packages_summary
  321. from pip._internal.req import install_given_reqs
  322. from pip._internal.req.constructors import install_req_from_line
  323. from pip._internal.wheel_builder import build
  324. ireqs = [install_req_from_line(req, user_supplied=True) for req in requirements]
  325. ireqs.extend(self._build_constraints)
  326. resolver = self._make_resolver()
  327. resolved_set = resolver.resolve(ireqs, check_supported_wheels=True)
  328. self._preparer.prepare_linked_requirements_more(
  329. resolved_set.requirements.values()
  330. )
  331. reqs_to_build = [
  332. r for r in resolved_set.requirements_to_install if not r.is_wheel
  333. ]
  334. _, build_failures = build(reqs_to_build, self._wheel_cache, verify=True)
  335. if build_failures:
  336. raise InstallWheelBuildError(build_failures)
  337. installed = install_given_reqs(
  338. resolver.get_installation_order(resolved_set),
  339. prefix=prefix.path,
  340. # Hard-coded options (that should NOT be inherited).
  341. root=None,
  342. home=None,
  343. warn_script_location=False,
  344. use_user_site=False,
  345. # As the build environment is ephemeral, it's wasteful to
  346. # pre-compile everything since not all modules will be used.
  347. pycompile=False,
  348. progress_bar="off",
  349. )
  350. env = get_environment(list(prefix.lib_dirs))
  351. if summary := installed_packages_summary(installed, env):
  352. logger.info(summary)
  353. def _make_resolver(self) -> BaseResolver:
  354. """Create a new resolver for one time use."""
  355. # Legacy installer never used the legacy resolver so create a
  356. # resolvelib resolver directly. Yuck.
  357. from pip._internal.req.constructors import install_req_from_req_string
  358. from pip._internal.resolution.resolvelib.resolver import Resolver
  359. return Resolver(
  360. make_install_req=install_req_from_req_string,
  361. # Inherited state.
  362. preparer=self._preparer,
  363. finder=self._finder,
  364. wheel_cache=self._wheel_cache,
  365. # Hard-coded options (that should NOT be inherited).
  366. ignore_requires_python=False,
  367. use_user_site=False,
  368. ignore_dependencies=False,
  369. ignore_installed=True,
  370. force_reinstall=False,
  371. upgrade_strategy="to-satisfy-only",
  372. py_version_info=None,
  373. )
  374. class BuildEnvironment:
  375. """Creates and manages an isolated environment to install build deps"""
  376. def __init__(self, installer: BuildEnvironmentInstaller) -> None:
  377. self.installer = installer
  378. temp_dir = TempDirectory(kind=tempdir_kinds.BUILD_ENV, globally_managed=True)
  379. self._prefixes = OrderedDict(
  380. (name, _Prefix(os.path.join(temp_dir.path, name)))
  381. for name in ("normal", "overlay")
  382. )
  383. self._bin_dirs: list[str] = []
  384. self._lib_dirs: list[str] = []
  385. for prefix in reversed(list(self._prefixes.values())):
  386. self._bin_dirs.append(prefix.bin_dir)
  387. self._lib_dirs.extend(prefix.lib_dirs)
  388. # Customize site to:
  389. # - ensure .pth files are honored
  390. # - prevent access to system site packages
  391. system_sites = _get_system_sitepackages()
  392. self._site_dir = os.path.join(temp_dir.path, "site")
  393. if not os.path.exists(self._site_dir):
  394. os.mkdir(self._site_dir)
  395. with open(
  396. os.path.join(self._site_dir, "sitecustomize.py"), "w", encoding="utf-8"
  397. ) as fp:
  398. fp.write(
  399. textwrap.dedent(
  400. """
  401. import os, site, sys
  402. # First, drop system-sites related paths.
  403. original_sys_path = sys.path[:]
  404. known_paths = set()
  405. for path in {system_sites!r}:
  406. site.addsitedir(path, known_paths=known_paths)
  407. system_paths = set(
  408. os.path.normcase(path)
  409. for path in sys.path[len(original_sys_path):]
  410. )
  411. original_sys_path = [
  412. path for path in original_sys_path
  413. if os.path.normcase(path) not in system_paths
  414. ]
  415. sys.path = original_sys_path
  416. # Second, add lib directories.
  417. # ensuring .pth file are processed.
  418. for path in {lib_dirs!r}:
  419. assert not path in sys.path
  420. site.addsitedir(path)
  421. """
  422. ).format(system_sites=system_sites, lib_dirs=self._lib_dirs)
  423. )
  424. def __enter__(self) -> None:
  425. self._save_env = {
  426. name: os.environ.get(name, None)
  427. for name in ("PATH", "PYTHONNOUSERSITE", "PYTHONPATH")
  428. }
  429. path = self._bin_dirs[:]
  430. old_path = self._save_env["PATH"]
  431. if old_path:
  432. path.extend(old_path.split(os.pathsep))
  433. pythonpath = [self._site_dir]
  434. os.environ.update(
  435. {
  436. "PATH": os.pathsep.join(path),
  437. "PYTHONNOUSERSITE": "1",
  438. "PYTHONPATH": os.pathsep.join(pythonpath),
  439. }
  440. )
  441. def __exit__(
  442. self,
  443. exc_type: type[BaseException] | None,
  444. exc_val: BaseException | None,
  445. exc_tb: TracebackType | None,
  446. ) -> None:
  447. for varname, old_value in self._save_env.items():
  448. if old_value is None:
  449. os.environ.pop(varname, None)
  450. else:
  451. os.environ[varname] = old_value
  452. def check_requirements(
  453. self, reqs: Iterable[str]
  454. ) -> tuple[set[tuple[str, str]], set[str]]:
  455. """Return 2 sets:
  456. - conflicting requirements: set of (installed, wanted) reqs tuples
  457. - missing requirements: set of reqs
  458. """
  459. missing = set()
  460. conflicting = set()
  461. if reqs:
  462. env = (
  463. get_environment(self._lib_dirs)
  464. if hasattr(self, "_lib_dirs")
  465. else get_default_environment()
  466. )
  467. for req_str in reqs:
  468. req = get_requirement(req_str)
  469. # We're explicitly evaluating with an empty extra value, since build
  470. # environments are not provided any mechanism to select specific extras.
  471. if req.marker is not None and not req.marker.evaluate({"extra": ""}):
  472. continue
  473. dist = env.get_distribution(req.name)
  474. if not dist:
  475. missing.add(req_str)
  476. continue
  477. if isinstance(dist.version, Version):
  478. installed_req_str = f"{req.name}=={dist.version}"
  479. else:
  480. installed_req_str = f"{req.name}==={dist.version}"
  481. if not req.specifier.contains(dist.version, prereleases=True):
  482. conflicting.add((installed_req_str, req_str))
  483. # FIXME: Consider direct URL?
  484. return conflicting, missing
  485. def install_requirements(
  486. self,
  487. requirements: Iterable[str],
  488. prefix_as_string: str,
  489. *,
  490. kind: str,
  491. for_req: InstallRequirement | None = None,
  492. ) -> None:
  493. prefix = self._prefixes[prefix_as_string]
  494. assert not prefix.setup
  495. prefix.setup = True
  496. if not requirements:
  497. return
  498. self.installer.install(requirements, prefix, kind=kind, for_req=for_req)
  499. class NoOpBuildEnvironment(BuildEnvironment):
  500. """A no-op drop-in replacement for BuildEnvironment"""
  501. def __init__(self) -> None:
  502. pass
  503. def __enter__(self) -> None:
  504. pass
  505. def __exit__(
  506. self,
  507. exc_type: type[BaseException] | None,
  508. exc_val: BaseException | None,
  509. exc_tb: TracebackType | None,
  510. ) -> None:
  511. pass
  512. def cleanup(self) -> None:
  513. pass
  514. def install_requirements(
  515. self,
  516. requirements: Iterable[str],
  517. prefix_as_string: str,
  518. *,
  519. kind: str,
  520. for_req: InstallRequirement | None = None,
  521. ) -> None:
  522. raise NotImplementedError()