Spaces:
Build error
Build error
from __future__ import annotations | |
import abc | |
import functools | |
import importlib.util | |
import os | |
import platform | |
import shutil | |
import subprocess | |
import sys | |
import sysconfig | |
import tempfile | |
import typing | |
from collections.abc import Collection, Mapping | |
from . import _ctx | |
from ._ctx import run_subprocess | |
from ._exceptions import FailedProcessError | |
from ._util import check_dependency | |
Installer = typing.Literal['pip', 'uv'] | |
INSTALLERS = typing.get_args(Installer) | |
class IsolatedEnv(typing.Protocol): | |
"""Isolated build environment ABC.""" | |
def python_executable(self) -> str: | |
"""The Python executable of the isolated environment.""" | |
def make_extra_environ(self) -> Mapping[str, str] | None: | |
"""Generate additional env vars specific to the isolated environment.""" | |
def _has_dependency(name: str, minimum_version_str: str | None = None, /, **distargs: object) -> bool | None: | |
""" | |
Given a path, see if a package is present and return True if the version is | |
sufficient for build, False if it is not, None if the package is missing. | |
""" | |
from packaging.version import Version | |
from ._compat import importlib | |
try: | |
distribution = next(iter(importlib.metadata.distributions(name=name, **distargs))) | |
except StopIteration: | |
return None | |
if minimum_version_str is None: | |
return True | |
return Version(distribution.version) >= Version(minimum_version_str) | |
class DefaultIsolatedEnv(IsolatedEnv): | |
""" | |
Isolated environment which supports several different underlying implementations. | |
""" | |
def __init__( | |
self, | |
*, | |
installer: Installer = 'pip', | |
) -> None: | |
self.installer: Installer = installer | |
def __enter__(self) -> DefaultIsolatedEnv: | |
try: | |
path = tempfile.mkdtemp(prefix='build-env-') | |
# Call ``realpath`` to prevent spurious warning from being emitted | |
# that the venv location has changed on Windows for the venv impl. | |
# 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 | |
path = os.path.realpath(path) | |
self._path = path | |
self._env_backend: _EnvBackend | |
# uv is opt-in only. | |
if self.installer == 'uv': | |
self._env_backend = _UvBackend() | |
else: | |
self._env_backend = _PipBackend() | |
_ctx.log(f'Creating isolated environment: {self._env_backend.display_name}...') | |
self._env_backend.create(self._path) | |
except Exception: # cleanup folder if creation fails | |
self.__exit__(*sys.exc_info()) | |
raise | |
return self | |
def __exit__(self, *args: object) -> None: | |
if os.path.exists(self._path): # in case the user already deleted skip remove | |
shutil.rmtree(self._path) | |
def path(self) -> str: | |
"""The location of the isolated build environment.""" | |
return self._path | |
def python_executable(self) -> str: | |
"""The python executable of the isolated build environment.""" | |
return self._env_backend.python_executable | |
def make_extra_environ(self) -> dict[str, str]: | |
path = os.environ.get('PATH') | |
return { | |
'PATH': os.pathsep.join([self._env_backend.scripts_dir, path]) | |
if path is not None | |
else self._env_backend.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 | |
_ctx.log('Installing packages in isolated environment:\n' + '\n'.join(f'- {r}' for r in sorted(requirements))) | |
self._env_backend.install_requirements(requirements) | |
class _EnvBackend(typing.Protocol): # pragma: no cover | |
python_executable: str | |
scripts_dir: str | |
def create(self, path: str) -> None: ... | |
def install_requirements(self, requirements: Collection[str]) -> None: ... | |
def display_name(self) -> str: ... | |
class _PipBackend(_EnvBackend): | |
def __init__(self) -> None: | |
self._create_with_virtualenv = not self._has_valid_outer_pip and self._has_virtualenv | |
def _has_valid_outer_pip(self) -> bool | None: | |
""" | |
This checks for a valid global pip. Returns None if pip is missing, False | |
if pip is too old, and True if it can be used. | |
""" | |
# Version to have added the `--python` option. | |
return _has_dependency('pip', '22.3') | |
def _has_virtualenv(self) -> bool: | |
""" | |
virtualenv might be incompatible if it was installed separately | |
from build. This verifies that virtualenv and all of its | |
dependencies are installed as required by build. | |
""" | |
from packaging.requirements import Requirement | |
name = 'virtualenv' | |
return importlib.util.find_spec(name) is not None and not any( | |
Requirement(d[1]).name == name for d in check_dependency(f'build[{name}]') if len(d) > 1 | |
) | |
def _get_minimum_pip_version_str() -> str: | |
if platform.system() == 'Darwin': | |
release, _, machine = platform.mac_ver() | |
if int(release[: release.find('.')]) >= 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 = machine != 'x86_64' | |
return '21.0.1' if is_apple_silicon_python else '20.3.0' | |
# PEP-517 and manylinux1 was first implemented in 19.1 | |
return '19.1.0' | |
def create(self, path: str) -> None: | |
if self._create_with_virtualenv: | |
import virtualenv | |
result = virtualenv.cli_run( | |
[ | |
path, | |
'--activators', | |
'', | |
'--no-setuptools', | |
'--no-wheel', | |
], | |
setup_logging=False, | |
) | |
# The creator attributes are `pathlib.Path`s. | |
self.python_executable = str(result.creator.exe) | |
self.scripts_dir = str(result.creator.script_dir) | |
else: | |
import venv | |
with_pip = not self._has_valid_outer_pip | |
try: | |
venv.EnvBuilder(symlinks=_fs_supports_symlink(), with_pip=with_pip).create(path) | |
except subprocess.CalledProcessError as exc: | |
_ctx.log_subprocess_error(exc) | |
raise FailedProcessError(exc, 'Failed to create venv. Maybe try installing virtualenv.') from None | |
self.python_executable, self.scripts_dir, purelib = _find_executable_and_scripts(path) | |
if with_pip: | |
minimum_pip_version_str = self._get_minimum_pip_version_str() | |
if not _has_dependency( | |
'pip', | |
minimum_pip_version_str, | |
path=[purelib], | |
): | |
run_subprocess([self.python_executable, '-Im', 'pip', 'install', f'pip>={minimum_pip_version_str}']) | |
# Uninstall setuptools from the build env to prevent depending on it implicitly. | |
# Pythons 3.12 and up do not install setuptools, check if it exists first. | |
if _has_dependency( | |
'setuptools', | |
path=[purelib], | |
): | |
run_subprocess([self.python_executable, '-Im', 'pip', 'uninstall', '-y', 'setuptools']) | |
def install_requirements(self, requirements: Collection[str]) -> None: | |
# pip does not honour environment markers in command line arguments | |
# but it does from requirement files. | |
with tempfile.NamedTemporaryFile('w', prefix='build-reqs-', suffix='.txt', delete=False, encoding='utf-8') as req_file: | |
req_file.write(os.linesep.join(requirements)) | |
try: | |
if self._has_valid_outer_pip: | |
cmd = [sys.executable, '-m', 'pip', '--python', self.python_executable] | |
else: | |
cmd = [self.python_executable, '-Im', 'pip'] | |
if _ctx.verbosity > 1: | |
cmd += [f'-{"v" * (_ctx.verbosity - 1)}'] | |
cmd += [ | |
'install', | |
'--use-pep517', | |
'--no-warn-script-location', | |
'--no-compile', | |
'-r', | |
os.path.abspath(req_file.name), | |
] | |
run_subprocess(cmd) | |
finally: | |
os.unlink(req_file.name) | |
def display_name(self) -> str: | |
return 'virtualenv+pip' if self._create_with_virtualenv else 'venv+pip' | |
class _UvBackend(_EnvBackend): | |
def create(self, path: str) -> None: | |
import venv | |
self._env_path = path | |
try: | |
import uv | |
self._uv_bin = uv.find_uv_bin() | |
except (ModuleNotFoundError, FileNotFoundError): | |
uv_bin = shutil.which('uv') | |
if uv_bin is None: | |
msg = 'uv executable not found' | |
raise RuntimeError(msg) from None | |
_ctx.log(f'Using external uv from {uv_bin}') | |
self._uv_bin = uv_bin | |
venv.EnvBuilder(symlinks=_fs_supports_symlink(), with_pip=False).create(self._env_path) | |
self.python_executable, self.scripts_dir, _ = _find_executable_and_scripts(self._env_path) | |
def install_requirements(self, requirements: Collection[str]) -> None: | |
cmd = [self._uv_bin, 'pip'] | |
if _ctx.verbosity > 1: | |
cmd += [f'-{"v" * min(2, _ctx.verbosity - 1)}'] | |
run_subprocess([*cmd, 'install', *requirements], env={**os.environ, 'VIRTUAL_ENV': self._env_path}) | |
def display_name(self) -> str: | |
return 'venv+uv' | |
def _fs_supports_symlink() -> bool: | |
"""Return True if symlinks are supported""" | |
# Using definition used by venv.main() | |
if os.name != 'nt': | |
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) | |
except (OSError, NotImplementedError, AttributeError): | |
return False | |
return True | |
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 os.name == 'nt' else 'python') | |
if not os.path.exists(executable): | |
msg = f'Virtual environment creation failed, executable {executable} missing' | |
raise RuntimeError(msg) | |
return executable, paths['scripts'], paths['purelib'] | |
__all__ = [ | |
'IsolatedEnv', | |
'DefaultIsolatedEnv', | |
] | |