1from __future__ import annotations
2
3import logging
4import sys
5import textwrap
6from collections.abc import Iterable, Sequence
7from contextlib import AbstractContextManager as ContextManager
8from contextlib import nullcontext
9from io import StringIO
10from typing import TYPE_CHECKING
11
12from pip._internal.build_env.base import Prefix
13from pip._internal.cli.spinners import open_rich_spinner, open_spinner
14from pip._internal.exceptions import (
15 BuildDependencyInstallError,
16 DiagnosticPipError,
17 InstallWheelBuildError,
18 PipError,
19)
20from pip._internal.metadata import get_environment
21from pip._internal.utils.logging import VERBOSE, capture_logging
22from pip._internal.utils.misc import get_runnable_pip
23from pip._internal.utils.subprocess import call_subprocess
24from pip._internal.utils.temp_dir import TempDirectory
25
26if TYPE_CHECKING:
27 from pip._internal.cache import WheelCache
28 from pip._internal.index.package_finder import PackageFinder
29 from pip._internal.operations.build.build_tracker import BuildTracker
30 from pip._internal.req.req_install import InstallRequirement
31 from pip._internal.resolution.base import BaseResolver
32
33
34logger = logging.getLogger(__name__)
35
36
37class SubprocessBuildEnvironmentInstaller:
38 """
39 Install build dependencies by calling pip in a subprocess.
40 """
41
42 def __init__(
43 self,
44 finder: PackageFinder,
45 build_constraints: list[str] | None = None,
46 ) -> None:
47 self.finder = finder
48 self._build_constraints = build_constraints or []
49
50 def install(
51 self,
52 requirements: Iterable[str],
53 prefix: Prefix,
54 *,
55 kind: str,
56 for_req: InstallRequirement | None,
57 ) -> None:
58 finder = self.finder
59 args: list[str] = [
60 get_runnable_pip(),
61 "install",
62 # HACK: --prefix shouldn't be necessary for venv environments, but
63 # we set it anyway so if it's set via an envvar or configuration
64 # file, it won't break things, *sigh*.
65 "--prefix",
66 prefix.path,
67 "--no-user",
68 "--no-warn-script-location",
69 "--disable-pip-version-check",
70 # As the build environment is ephemeral, it's wasteful to
71 # pre-compile everything, especially as not every Python
72 # module will be used/compiled in most cases.
73 "--no-compile",
74 # The prefix specified two lines above, thus
75 # target from config file or env var should be ignored
76 "--target",
77 "",
78 ]
79 if prefix.venv_executable:
80 args.insert(0, prefix.venv_executable)
81 else:
82 args.insert(0, sys.executable)
83 args.append("--ignore-installed")
84
85 if logger.getEffectiveLevel() <= logging.DEBUG:
86 args.append("-vv")
87 elif logger.getEffectiveLevel() <= VERBOSE:
88 args.append("-v")
89 for format_control in ("no_binary", "only_binary"):
90 formats = getattr(finder.format_control, format_control)
91 args.extend(
92 (
93 "--" + format_control.replace("_", "-"),
94 ",".join(sorted(formats or {":none:"})),
95 )
96 )
97
98 if finder.release_control is not None:
99 # Use ordered args to preserve the user's original command-line order
100 # This is important because later flags can override earlier ones
101 for attr_name, value in finder.release_control.get_ordered_args():
102 args.extend(("--" + attr_name.replace("_", "-"), value))
103
104 index_urls = finder.index_urls
105 if index_urls:
106 args.extend(["-i", index_urls[0]])
107 for extra_index in index_urls[1:]:
108 args.extend(["--extra-index-url", extra_index])
109 else:
110 args.append("--no-index")
111 for link in finder.find_links:
112 args.extend(["--find-links", link])
113
114 # is not None: forward an empty --proxy "" (disable proxying) too.
115 if finder.proxy is not None:
116 args.extend(["--proxy", finder.proxy])
117 if finder.no_proxy_env:
118 args.append("--no-proxy-env")
119 for host in finder.trusted_hosts:
120 args.extend(["--trusted-host", host])
121 if finder.custom_cert:
122 args.extend(["--cert", finder.custom_cert])
123 if finder.client_cert:
124 args.extend(["--client-cert", finder.client_cert])
125 if finder.prefer_binary:
126 args.append("--prefer-binary")
127 if finder.refresh_package:
128 args.extend(["--refresh-package", ",".join(finder.refresh_package)])
129
130 # Only build constraints apply in the isolated build environment.
131 # _PIP_IN_BUILD_IGNORE_CONSTRAINTS tells the subprocess to ignore the
132 # regular constraints it inherits (via PIP_CONSTRAINT or config files).
133 # Build constraints reach it through --build-constraint, which also
134 # constrains any nested builds.
135 for constraint_file in self._build_constraints:
136 args.extend(["--build-constraint", constraint_file])
137
138 if finder.uploaded_prior_to:
139 args.extend(["--uploaded-prior-to", finder.uploaded_prior_to.isoformat()])
140 args.append("--")
141 args.extend(requirements)
142
143 identify_requirement = (
144 f" for {for_req.name}" if for_req and for_req.name else ""
145 )
146 with open_spinner(f"Installing {kind}") as spinner:
147 call_subprocess(
148 args,
149 command_desc=f"installing {kind}{identify_requirement}",
150 spinner=spinner,
151 extra_environ={"_PIP_IN_BUILD_IGNORE_CONSTRAINTS": "1"},
152 )
153
154
155class InprocessBuildEnvironmentInstaller:
156 """
157 Install build dependencies via the already running pip process.
158
159 This contains a stripped down version of the install command with
160 only the logic necessary for installing build dependencies. The
161 finder, session, build tracker, and wheel cache are reused, but new
162 instances of everything else are created as needed.
163
164 Options are inherited from the parent install command unless
165 they don't make sense for build dependencies (in which case, they
166 are hard-coded, see comments below).
167 """
168
169 # TODO: this plays poorly with venv-based build environments, but cannot be
170 # fixed until pip gains better support for operating within a Python
171 # environment that isn't the running environment.
172
173 def __init__(
174 self,
175 *,
176 finder: PackageFinder,
177 build_tracker: BuildTracker,
178 wheel_cache: WheelCache,
179 build_constraints: Sequence[InstallRequirement] = (),
180 verbosity: int = 0,
181 ) -> None:
182 from pip._internal.operations.prepare import RequirementPreparer
183
184 self._finder = finder
185 self._build_constraints = build_constraints
186 self._wheel_cache = wheel_cache
187 self._level = 0
188
189 build_dir = TempDirectory(kind="build-env-install", globally_managed=True)
190 self._preparer = RequirementPreparer(
191 build_isolation_installer=self,
192 # Inherited options or state.
193 finder=finder,
194 session=finder._link_collector.session,
195 build_dir=build_dir.path,
196 build_tracker=build_tracker,
197 verbosity=verbosity,
198 # This is irrelevant as it only applies to editable requirements.
199 src_dir="",
200 # Hard-coded options (that should NOT be inherited).
201 download_dir=None,
202 build_isolation="virtual",
203 check_build_deps=False,
204 progress_bar="off",
205 # TODO: hash-checking should be extended to build deps, but that is
206 # deferred for later as it'd be a breaking change.
207 require_hashes=False,
208 use_user_site=False,
209 lazy_wheel=False,
210 legacy_resolver=False,
211 allow_editables=True,
212 )
213
214 def install(
215 self,
216 requirements: Iterable[str],
217 prefix: Prefix,
218 *,
219 kind: str,
220 for_req: InstallRequirement | None,
221 ) -> None:
222 """Install entrypoint. Manages output capturing and error handling."""
223 capture_logs = not logger.isEnabledFor(VERBOSE) and self._level == 0
224 if capture_logs:
225 # Hide the logs from the installation of build dependencies.
226 # They will be shown only if an error occurs.
227 capture_ctx: ContextManager[StringIO] = capture_logging()
228 spinner: ContextManager[None] = open_rich_spinner(f"Installing {kind}")
229 else:
230 # Otherwise, pass-through all logs (with a header).
231 capture_ctx, spinner = nullcontext(StringIO()), nullcontext()
232 logger.info("Installing %s ...", kind)
233
234 try:
235 self._level += 1
236 with spinner, capture_ctx as stream:
237 self._install_impl(requirements, prefix)
238
239 except DiagnosticPipError as exc:
240 # Format similar to a nested subprocess error, where the
241 # causing error is shown first, followed by the build error.
242 logger.info(textwrap.dedent(stream.getvalue()))
243 logger.error("%s", exc, extra={"rich": True})
244 logger.info("")
245 raise BuildDependencyInstallError(
246 for_req, requirements, cause=exc, log_lines=None
247 )
248
249 except Exception as exc:
250 logs: list[str] | None = textwrap.dedent(stream.getvalue()).splitlines()
251 if not capture_logs:
252 # If logs aren't being captured, then display the error inline
253 # with the rest of the logs.
254 logs = None
255 if isinstance(exc, PipError):
256 logger.error("%s", exc)
257 else:
258 logger.exception("pip crashed unexpectedly")
259 raise BuildDependencyInstallError(
260 for_req, requirements, cause=exc, log_lines=logs
261 )
262
263 finally:
264 self._level -= 1
265
266 def _install_impl(self, requirements: Iterable[str], prefix: Prefix) -> None:
267 """Core build dependency install logic."""
268 from pip._internal.commands.install import installed_packages_summary
269 from pip._internal.req import install_given_reqs
270 from pip._internal.req.constructors import install_req_from_line
271 from pip._internal.wheel_builder import build
272
273 ireqs = [install_req_from_line(req, user_supplied=True) for req in requirements]
274 ireqs.extend(self._build_constraints)
275
276 resolver = self._make_resolver()
277 resolved_set = resolver.resolve(ireqs, check_supported_wheels=True)
278 self._preparer.prepare_linked_requirements_more(
279 resolved_set.requirements.values()
280 )
281
282 reqs_to_build = [
283 r for r in resolved_set.requirements_to_install if not r.is_wheel
284 ]
285 _, build_failures = build(
286 reqs_to_build, self._wheel_cache, verify=True, allow_editables=True
287 )
288 if build_failures:
289 raise InstallWheelBuildError(build_failures)
290
291 installed = install_given_reqs(
292 resolver.get_installation_order(resolved_set),
293 prefix=prefix.path,
294 # Hard-coded options (that should NOT be inherited).
295 root=None,
296 home=None,
297 warn_script_location=False,
298 use_user_site=False,
299 # As the build environment is ephemeral, it's wasteful to
300 # pre-compile everything since not all modules will be used.
301 pycompile=False,
302 progress_bar="off",
303 # Link console scripts to the build env's interpreter, not pip's.
304 script_executable=prefix.venv_executable,
305 )
306
307 env = get_environment(list(prefix.lib_dirs))
308 if summary := installed_packages_summary(installed, env):
309 logger.info(summary)
310
311 def _make_resolver(self) -> BaseResolver:
312 """Create a new resolver for one time use."""
313 # Legacy installer never used the legacy resolver so create a
314 # resolvelib resolver directly. Yuck.
315 from pip._internal.req.constructors import install_req_from_req_string
316 from pip._internal.resolution.resolvelib.resolver import Resolver
317
318 return Resolver(
319 make_install_req=install_req_from_req_string,
320 # Inherited state.
321 preparer=self._preparer,
322 finder=self._finder,
323 wheel_cache=self._wheel_cache,
324 # Hard-coded options (that should NOT be inherited).
325 ignore_requires_python=False,
326 use_user_site=False,
327 ignore_dependencies=False,
328 only_dependencies=False,
329 ignore_installed=True,
330 force_reinstall=False,
331 upgrade_strategy="to-satisfy-only",
332 py_version_info=None,
333 )