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