summaryrefslogtreecommitdiffstats
path: root/src/build/env.py
diff options
context:
space:
mode:
Diffstat (limited to 'src/build/env.py')
-rw-r--r--src/build/env.py340
1 files changed, 340 insertions, 0 deletions
diff --git a/src/build/env.py b/src/build/env.py
new file mode 100644
index 0000000..b4a90a9
--- /dev/null
+++ b/src/build/env.py
@@ -0,0 +1,340 @@
+"""
+Creates and manages isolated build environments.
+"""
+import abc
+import functools
+import logging
+import os
+import platform
+import shutil
+import subprocess
+import sys
+import sysconfig
+import tempfile
+import warnings
+
+from types import TracebackType
+from typing import Callable, Collection, List, Optional, Tuple, Type
+
+import build
+
+
+try:
+ import virtualenv
+except ModuleNotFoundError:
+ virtualenv = None
+
+
+_logger = logging.getLogger(__name__)
+
+
+class IsolatedEnv(metaclass=abc.ABCMeta):
+ """Abstract base of isolated build environments, as required by the build project."""
+
+ @property
+ @abc.abstractmethod
+ def executable(self) -> str:
+ """The executable of the isolated build environment."""
+ raise NotImplementedError
+
+ @property
+ @abc.abstractmethod
+ def scripts_dir(self) -> str:
+ """The scripts directory of the isolated build environment."""
+ raise NotImplementedError
+
+ @abc.abstractmethod
+ def install(self, requirements: Collection[str]) -> None:
+ """
+ Install packages from PEP 508 requirements in the isolated build environment.
+
+ :param requirements: PEP 508 requirements
+ """
+ raise NotImplementedError
+
+
+@functools.lru_cache(maxsize=None)
+def _should_use_virtualenv() -> bool:
+ import packaging.requirements
+
+ # virtualenv might be incompatible if it was installed separately
+ # from build. This verifies that virtualenv and all of its
+ # dependencies are installed as specified by build.
+ return virtualenv is not None and not any(
+ packaging.requirements.Requirement(d[1]).name == 'virtualenv'
+ for d in build.check_dependency('build[virtualenv]')
+ if len(d) > 1
+ )
+
+
+def _subprocess(cmd: List[str]) -> None:
+ """Invoke subprocess and output stdout and stderr if it fails."""
+ try:
+ subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
+ except subprocess.CalledProcessError as e:
+ print(e.output.decode(), end='', file=sys.stderr)
+ raise e
+
+
+class IsolatedEnvBuilder:
+ """Builder object for isolated environments."""
+
+ def __init__(self) -> None:
+ self._path: Optional[str] = None
+
+ def __enter__(self) -> IsolatedEnv:
+ """
+ Create an isolated build environment.
+
+ :return: The isolated build environment
+ """
+ # Call ``realpath`` to prevent spurious warning from being emitted
+ # that the venv location has changed on Windows. The username is
+ # DOS-encoded in the output of tempfile - the location is the same
+ # but the representation of it is different, which confuses venv.
+ # Ref: https://bugs.python.org/issue46171
+ self._path = os.path.realpath(tempfile.mkdtemp(prefix='build-env-'))
+ try:
+ # use virtualenv when available (as it's faster than venv)
+ if _should_use_virtualenv():
+ self.log('Creating virtualenv isolated environment...')
+ executable, scripts_dir = _create_isolated_env_virtualenv(self._path)
+ else:
+ self.log('Creating venv isolated environment...')
+ executable, scripts_dir = _create_isolated_env_venv(self._path)
+ return _IsolatedEnvVenvPip(
+ path=self._path,
+ python_executable=executable,
+ scripts_dir=scripts_dir,
+ log=self.log,
+ )
+ except Exception: # cleanup folder if creation fails
+ self.__exit__(*sys.exc_info())
+ raise
+
+ def __exit__(
+ self, exc_type: Optional[Type[BaseException]], exc_val: Optional[BaseException], exc_tb: Optional[TracebackType]
+ ) -> None:
+ """
+ Delete the created isolated build environment.
+
+ :param exc_type: The type of exception raised (if any)
+ :param exc_val: The value of exception raised (if any)
+ :param exc_tb: The traceback of exception raised (if any)
+ """
+ if self._path is not None and os.path.exists(self._path): # in case the user already deleted skip remove
+ shutil.rmtree(self._path)
+
+ @staticmethod
+ def log(message: str) -> None:
+ """
+ Prints message
+
+ The default implementation uses the logging module but this function can be
+ overwritten by users to have a different implementation.
+
+ :param msg: Message to output
+ """
+ if sys.version_info >= (3, 8):
+ _logger.log(logging.INFO, message, stacklevel=2)
+ else:
+ _logger.log(logging.INFO, message)
+
+
+class _IsolatedEnvVenvPip(IsolatedEnv):
+ """
+ Isolated build environment context manager
+
+ Non-standard paths injected directly to sys.path will still be passed to the environment.
+ """
+
+ def __init__(
+ self,
+ path: str,
+ python_executable: str,
+ scripts_dir: str,
+ log: Callable[[str], None],
+ ) -> None:
+ """
+ :param path: The path where the environment exists
+ :param python_executable: The python executable within the environment
+ :param log: Log function
+ """
+ self._path = path
+ self._python_executable = python_executable
+ self._scripts_dir = scripts_dir
+ self._log = log
+
+ @property
+ def path(self) -> str:
+ """The location of the isolated build environment."""
+ return self._path
+
+ @property
+ def executable(self) -> str:
+ """The python executable of the isolated build environment."""
+ return self._python_executable
+
+ @property
+ def scripts_dir(self) -> str:
+ return self._scripts_dir
+
+ def install(self, requirements: Collection[str]) -> None:
+ """
+ Install packages from PEP 508 requirements in the isolated build environment.
+
+ :param requirements: PEP 508 requirement specification to install
+
+ :note: Passing non-PEP 508 strings will result in undefined behavior, you *should not* rely on it. It is
+ merely an implementation detail, it may change any time without warning.
+ """
+ if not requirements:
+ return
+
+ self._log('Installing packages in isolated environment... ({})'.format(', '.join(sorted(requirements))))
+
+ # pip does not honour environment markers in command line arguments
+ # but it does for requirements from a file
+ with tempfile.NamedTemporaryFile('w+', prefix='build-reqs-', suffix='.txt', delete=False) as req_file:
+ req_file.write(os.linesep.join(requirements))
+ try:
+ cmd = [
+ self.executable,
+ '-Im',
+ 'pip',
+ 'install',
+ '--use-pep517',
+ '--no-warn-script-location',
+ '-r',
+ os.path.abspath(req_file.name),
+ ]
+ _subprocess(cmd)
+ finally:
+ os.unlink(req_file.name)
+
+
+def _create_isolated_env_virtualenv(path: str) -> Tuple[str, str]:
+ """
+ We optionally can use the virtualenv package to provision a virtual environment.
+
+ :param path: The path where to create the isolated build environment
+ :return: The Python executable and script folder
+ """
+ cmd = [str(path), '--no-setuptools', '--no-wheel', '--activators', '']
+ result = virtualenv.cli_run(cmd, setup_logging=False)
+ executable = str(result.creator.exe)
+ script_dir = str(result.creator.script_dir)
+ return executable, script_dir
+
+
+@functools.lru_cache(maxsize=None)
+def _fs_supports_symlink() -> bool:
+ """Return True if symlinks are supported"""
+ # Using definition used by venv.main()
+ if not sys.platform.startswith('win'):
+ return True
+
+ # Windows may support symlinks (setting in Windows 10)
+ with tempfile.NamedTemporaryFile(prefix='build-symlink-') as tmp_file:
+ dest = f'{tmp_file}-b'
+ try:
+ os.symlink(tmp_file.name, dest)
+ os.unlink(dest)
+ return True
+ except (OSError, NotImplementedError, AttributeError):
+ return False
+
+
+def _create_isolated_env_venv(path: str) -> Tuple[str, str]:
+ """
+ On Python 3 we use the venv package from the standard library.
+
+ :param path: The path where to create the isolated build environment
+ :return: The Python executable and script folder
+ """
+ import venv
+
+ import packaging.version
+
+ if sys.version_info < (3, 8):
+ import importlib_metadata as metadata
+ else:
+ from importlib import metadata
+
+ symlinks = _fs_supports_symlink()
+ try:
+ with warnings.catch_warnings():
+ if sys.version_info[:3] == (3, 11, 0):
+ warnings.filterwarnings('ignore', 'check_home argument is deprecated and ignored.', DeprecationWarning)
+ venv.EnvBuilder(with_pip=True, symlinks=symlinks).create(path)
+ except subprocess.CalledProcessError as exc:
+ raise build.FailedProcessError(exc, 'Failed to create venv. Maybe try installing virtualenv.') from None
+
+ executable, script_dir, purelib = _find_executable_and_scripts(path)
+
+ # Get the version of pip in the environment
+ pip_distribution = next(iter(metadata.distributions(name='pip', path=[purelib]))) # type: ignore[no-untyped-call]
+ current_pip_version = packaging.version.Version(pip_distribution.version)
+
+ if platform.system() == 'Darwin' and int(platform.mac_ver()[0].split('.')[0]) >= 11:
+ # macOS 11+ name scheme change requires 20.3. Intel macOS 11.0 can be told to report 10.16 for backwards
+ # compatibility; but that also fixes earlier versions of pip so this is only needed for 11+.
+ is_apple_silicon_python = platform.machine() != 'x86_64'
+ minimum_pip_version = '21.0.1' if is_apple_silicon_python else '20.3.0'
+ else:
+ # PEP-517 and manylinux1 was first implemented in 19.1
+ minimum_pip_version = '19.1.0'
+
+ if current_pip_version < packaging.version.Version(minimum_pip_version):
+ _subprocess([executable, '-m', 'pip', 'install', f'pip>={minimum_pip_version}'])
+
+ # Avoid the setuptools from ensurepip to break the isolation
+ _subprocess([executable, '-m', 'pip', 'uninstall', 'setuptools', '-y'])
+ return executable, script_dir
+
+
+def _find_executable_and_scripts(path: str) -> Tuple[str, str, str]:
+ """
+ Detect the Python executable and script folder of a virtual environment.
+
+ :param path: The location of the virtual environment
+ :return: The Python executable, script folder, and purelib folder
+ """
+ config_vars = sysconfig.get_config_vars().copy() # globally cached, copy before altering it
+ config_vars['base'] = path
+ scheme_names = sysconfig.get_scheme_names()
+ if 'venv' in scheme_names:
+ # Python distributors with custom default installation scheme can set a
+ # scheme that can't be used to expand the paths in a venv.
+ # This can happen if build itself is not installed in a venv.
+ # The distributors are encouraged to set a "venv" scheme to be used for this.
+ # See https://bugs.python.org/issue45413
+ # and https://github.com/pypa/virtualenv/issues/2208
+ paths = sysconfig.get_paths(scheme='venv', vars=config_vars)
+ elif 'posix_local' in scheme_names:
+ # The Python that ships on Debian/Ubuntu varies the default scheme to
+ # install to /usr/local
+ # But it does not (yet) set the "venv" scheme.
+ # If we're the Debian "posix_local" scheme is available, but "venv"
+ # is not, we use "posix_prefix" instead which is venv-compatible there.
+ paths = sysconfig.get_paths(scheme='posix_prefix', vars=config_vars)
+ elif 'osx_framework_library' in scheme_names:
+ # The Python that ships with the macOS developer tools varies the
+ # default scheme depending on whether the ``sys.prefix`` is part of a framework.
+ # But it does not (yet) set the "venv" scheme.
+ # If the Apple-custom "osx_framework_library" scheme is available but "venv"
+ # is not, we use "posix_prefix" instead which is venv-compatible there.
+ paths = sysconfig.get_paths(scheme='posix_prefix', vars=config_vars)
+ else:
+ paths = sysconfig.get_paths(vars=config_vars)
+ executable = os.path.join(paths['scripts'], 'python.exe' if sys.platform.startswith('win') else 'python')
+ if not os.path.exists(executable):
+ raise RuntimeError(f'Virtual environment creation failed, executable {executable} missing')
+
+ return executable, paths['scripts'], paths['purelib']
+
+
+__all__ = [
+ 'IsolatedEnvBuilder',
+ 'IsolatedEnv',
+]