from __future__ import annotations import os import sys import sysconfig from collections.abc import Iterable from types import TracebackType from typing import TYPE_CHECKING from pip._internal.build_env.base import ( BuildEnvironment, BuildEnvironmentInstaller, Prefix, ) from pip._internal.exceptions import VenvCreationError, VenvImportError from pip._internal.utils.temp_dir import TempDirectory, tempdir_kinds if TYPE_CHECKING: from pip._internal.req.req_install import InstallRequirement def _get_venv_path_from_sysconfig(name: str, env_dir: str) -> str: vars = { "base": env_dir, "platbase": env_dir, } return sysconfig.get_path(name, scheme="venv", vars=vars) class VenvBuildEnvironment(BuildEnvironment): """A venv-based build environment.""" def __init__(self, installer: BuildEnvironmentInstaller) -> None: # We defer this import because certain distributions of Python do not include # a functional venv out of the box. try: import venv except ImportError: raise VenvImportError self._env_path = TempDirectory( kind=tempdir_kinds.BUILD_ENV, globally_managed=True ).path # Use symlinks to support relocatable Python installations on POSIX, including # python-build-standalone. This matches upstream venv CLI's behaviour. env = venv.EnvBuilder(symlinks=(os.name != "nt")) try: context = env.ensure_directories(self._env_path) env.create(self._env_path) except OSError as e: raise VenvCreationError(str(e)) if sys.version_info >= (3, 12): # The context object was only documented in Python 3.12 self.lib_dirs = [context.lib_path] self._bin_path = context.bin_path elif sys.version_info[:2] == (3, 11): # On Python 3.11, we can use sysconfig. self.lib_dirs = [_get_venv_path_from_sysconfig("purelib", self._env_path)] self._bin_path = _get_venv_path_from_sysconfig("scripts", self._env_path) else: # Otherwise, we need to manually construct all the paths... sigh. if sys.platform == "win32": libpath = os.path.join(self._env_path, "Lib", "site-packages") else: python = "pypy" if sys.implementation.name == "pypy" else "python" libpath = os.path.join( self._env_path, "lib", f"{python}{sys.version_info.major}.{sys.version_info.minor}", "site-packages", ) self.lib_dirs = [libpath] # Same reasoning for try-except as for python_executable below. try: self._bin_path = context.bin_path except AttributeError: scripts_dir = "Scripts" if os.name == "nt" else "bin" self._bin_path = os.path.join(self._env_path, scripts_dir) # There are enough ways trying to construct the Python executable path can go # wrong that we're better off assuming that the context object has the right # attributes, and only when they don't exist do we try to guess. # # These attributes seem to exist in every CPython version after 3.10.1 and # are documented to exist on 3.12 and higher. try: self.python_executable = context.env_exec_cmd except AttributeError: try: self.python_executable = context.env_exe except AttributeError: executable_name = "python.exe" if os.name == "nt" else "python" self.python_executable = os.path.join(self._bin_path, executable_name) self._save_env: dict[str, str | None] = {} self._installer = installer if not os.path.exists(self.python_executable): # This error is only likely on Windows due to interference from AV software. raise VenvCreationError( f"Python executable failed to copy to {self.python_executable}" ) def __enter__(self) -> None: # We want backend calls to be able to use binaries installed as if this # virtual environment was "activated". self._save_env = { name: os.environ.get(name, None) for name in ("PATH", "PYTHONPATH") } new_path = [self._bin_path] if old_path := self._save_env["PATH"]: new_path.extend(old_path.split(os.pathsep)) # However, we don't want a pre-existing PYTHONPATH to influence the # backend calls. os.environ.update({"PATH": os.pathsep.join(new_path), "PYTHONPATH": ""}) def __exit__( self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: TracebackType | None, ) -> None: for varname, old_value in self._save_env.items(): if old_value is None: os.environ.pop(varname, None) else: os.environ[varname] = old_value def install_requirements( self, requirements: Iterable[str], prefix_as_string: str, *, kind: str, for_req: InstallRequirement | None = None, ) -> None: if not requirements: return # TODO: when better support for installing to arbitrary Python environments # is added, replace this prefix hack with that. prefix = Prefix(self._env_path, venv_executable=self.python_executable) self._installer.install(requirements, prefix, kind=kind, for_req=for_req)