1from __future__ import annotations
2
3import os
4import sys
5import sysconfig
6from collections.abc import Iterable
7from types import TracebackType
8from typing import TYPE_CHECKING
9
10from pip._internal.build_env.base import (
11 BuildEnvironment,
12 BuildEnvironmentInstaller,
13 Prefix,
14)
15from pip._internal.exceptions import VenvCreationError, VenvImportError
16from pip._internal.utils.temp_dir import TempDirectory, tempdir_kinds
17
18if TYPE_CHECKING:
19 from pip._internal.req.req_install import InstallRequirement
20
21
22def _get_venv_path_from_sysconfig(name: str, env_dir: str) -> str:
23 vars = {
24 "base": env_dir,
25 "platbase": env_dir,
26 }
27 return sysconfig.get_path(name, scheme="venv", vars=vars)
28
29
30class VenvBuildEnvironment(BuildEnvironment):
31 """A venv-based build environment."""
32
33 def __init__(self, installer: BuildEnvironmentInstaller) -> None:
34 # We defer this import because certain distributions of Python do not include
35 # a functional venv out of the box.
36 try:
37 import venv
38 except ImportError:
39 raise VenvImportError
40
41 self._env_path = TempDirectory(
42 kind=tempdir_kinds.BUILD_ENV, globally_managed=True
43 ).path
44 # Use symlinks to support relocatable Python installations on POSIX, including
45 # python-build-standalone. This matches upstream venv CLI's behaviour.
46 env = venv.EnvBuilder(symlinks=(os.name != "nt"))
47 try:
48 context = env.ensure_directories(self._env_path)
49 env.create(self._env_path)
50 except OSError as e:
51 raise VenvCreationError(str(e))
52
53 if sys.version_info >= (3, 12):
54 # The context object was only documented in Python 3.12
55 self.lib_dirs = [context.lib_path]
56 self._bin_path = context.bin_path
57 elif sys.version_info[:2] == (3, 11):
58 # On Python 3.11, we can use sysconfig.
59 self.lib_dirs = [_get_venv_path_from_sysconfig("purelib", self._env_path)]
60 self._bin_path = _get_venv_path_from_sysconfig("scripts", self._env_path)
61 else:
62 # Otherwise, we need to manually construct all the paths... sigh.
63 if sys.platform == "win32":
64 libpath = os.path.join(self._env_path, "Lib", "site-packages")
65 else:
66 python = "pypy" if sys.implementation.name == "pypy" else "python"
67 libpath = os.path.join(
68 self._env_path,
69 "lib",
70 f"{python}{sys.version_info.major}.{sys.version_info.minor}",
71 "site-packages",
72 )
73 self.lib_dirs = [libpath]
74 # Same reasoning for try-except as for python_executable below.
75 try:
76 self._bin_path = context.bin_path
77 except AttributeError:
78 scripts_dir = "Scripts" if os.name == "nt" else "bin"
79 self._bin_path = os.path.join(self._env_path, scripts_dir)
80
81 # There are enough ways trying to construct the Python executable path can go
82 # wrong that we're better off assuming that the context object has the right
83 # attributes, and only when they don't exist do we try to guess.
84 #
85 # These attributes seem to exist in every CPython version after 3.10.1 and
86 # are documented to exist on 3.12 and higher.
87 try:
88 self.python_executable = context.env_exec_cmd
89 except AttributeError:
90 try:
91 self.python_executable = context.env_exe
92 except AttributeError:
93 executable_name = "python.exe" if os.name == "nt" else "python"
94 self.python_executable = os.path.join(self._bin_path, executable_name)
95
96 self._save_env: dict[str, str | None] = {}
97 self._installer = installer
98
99 if not os.path.exists(self.python_executable):
100 # This error is only likely on Windows due to interference from AV software.
101 raise VenvCreationError(
102 f"Python executable failed to copy to {self.python_executable}"
103 )
104
105 def __enter__(self) -> None:
106 # We want backend calls to be able to use binaries installed as if this
107 # virtual environment was "activated".
108 self._save_env = {
109 name: os.environ.get(name, None) for name in ("PATH", "PYTHONPATH")
110 }
111
112 new_path = [self._bin_path]
113 if old_path := self._save_env["PATH"]:
114 new_path.extend(old_path.split(os.pathsep))
115 # However, we don't want a pre-existing PYTHONPATH to influence the
116 # backend calls.
117 os.environ.update({"PATH": os.pathsep.join(new_path), "PYTHONPATH": ""})
118
119 def __exit__(
120 self,
121 exc_type: type[BaseException] | None,
122 exc_val: BaseException | None,
123 exc_tb: TracebackType | None,
124 ) -> None:
125 for varname, old_value in self._save_env.items():
126 if old_value is None:
127 os.environ.pop(varname, None)
128 else:
129 os.environ[varname] = old_value
130
131 def install_requirements(
132 self,
133 requirements: Iterable[str],
134 prefix_as_string: str,
135 *,
136 kind: str,
137 for_req: InstallRequirement | None = None,
138 ) -> None:
139 if not requirements:
140 return
141
142 # TODO: when better support for installing to arbitrary Python environments
143 # is added, replace this prefix hack with that.
144 prefix = Prefix(self._env_path, venv_executable=self.python_executable)
145 self._installer.install(requirements, prefix, kind=kind, for_req=for_req)