From 2f50d8db4524634c45c95f97df7b8c5a7fee1dbe Mon Sep 17 00:00:00 2001 From: layday Date: Tue, 5 Mar 2024 23:38:22 +0200 Subject: [PATCH 01/35] Add `uv` extra --- pyproject.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 0b587240..94eb9cdc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,11 +63,15 @@ test = [ 'setuptools >= 67.8.0; python_version >= "3.12"', ] typing = [ + "build[uv]", "importlib-metadata >= 5.1", "mypy ~= 1.5.0", "tomli", "typing-extensions >= 3.7.4.3", ] +uv = [ + "uv >= 0.1.15", +] virtualenv = [ "virtualenv >= 20.0.35", ] From 24d9efe6593c640e763669f752f0178a75de51d5 Mon Sep 17 00:00:00 2001 From: layday Date: Tue, 5 Mar 2024 23:39:27 +0200 Subject: [PATCH 02/35] Use argparse shortcut for dist args --- src/build/__main__.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/build/__main__.py b/src/build/__main__.py index b8cf1700..ca4e9380 100644 --- a/src/build/__main__.py +++ b/src/build/__main__.py @@ -329,13 +329,17 @@ def main_parser() -> argparse.ArgumentParser: parser.add_argument( '--sdist', '-s', - action='store_true', + dest='distributions', + action='append_const', + const='sdist', help='build a source distribution (disables the default behavior)', ) parser.add_argument( '--wheel', '-w', - action='store_true', + dest='distributions', + action='append_const', + const='wheel', help='build a wheel (disables the default behavior)', ) parser.add_argument( @@ -384,7 +388,6 @@ def main(cli_args: Sequence[str], prog: str | None = None) -> None: _setup_cli(verbosity=args.verbosity) - distributions: list[Distribution] = [] config_settings = {} if args.config_setting: @@ -398,14 +401,10 @@ def main(cli_args: Sequence[str], prog: str | None = None) -> None: config_settings[setting].append(value) - if args.sdist: - distributions.append('sdist') - if args.wheel: - distributions.append('wheel') - # outdir is relative to srcdir only if omitted. outdir = os.path.join(args.srcdir, 'dist') if args.outdir is None else args.outdir + distributions: list[Distribution] = args.distributions if distributions: build_call = build_package else: From a57609fccf2c54ac0799f2444dfe75e19783db56 Mon Sep 17 00:00:00 2001 From: layday Date: Tue, 5 Mar 2024 23:39:51 +0200 Subject: [PATCH 03/35] Rename `--config-setting` dest to be in the plural --- src/build/__main__.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/build/__main__.py b/src/build/__main__.py index ca4e9380..ea09d7ac 100644 --- a/src/build/__main__.py +++ b/src/build/__main__.py @@ -365,6 +365,7 @@ def main_parser() -> argparse.ArgumentParser: parser.add_argument( '--config-setting', '-C', + dest='config_settings', action='append', help='settings to pass to the backend. Multiple settings can be provided. ' 'Settings beginning with a hyphen will erroneously be interpreted as options to build if separated ' @@ -390,8 +391,8 @@ def main(cli_args: Sequence[str], prog: str | None = None) -> None: config_settings = {} - if args.config_setting: - for arg in args.config_setting: + if args.config_settings: + for arg in args.config_settings: setting, _, value = arg.partition('=') if setting not in config_settings: config_settings[setting] = value From db6069c69b139a09068feea24b058890da6c4490 Mon Sep 17 00:00:00 2001 From: layday Date: Tue, 5 Mar 2024 23:40:26 +0200 Subject: [PATCH 04/35] Add provision for `env` param to custom subp runner --- src/build/_ctx.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/build/_ctx.py b/src/build/_ctx.py index fae1bd14..a60c2ffd 100644 --- a/src/build/_ctx.py +++ b/src/build/_ctx.py @@ -5,7 +5,7 @@ import subprocess import typing -from collections.abc import Sequence +from collections.abc import Mapping, Sequence from functools import partial from ._types import StrPath @@ -40,7 +40,7 @@ def log_subprocess_error(error: subprocess.CalledProcessError) -> None: log(stream.decode() if isinstance(stream, bytes) else stream, origin=('subprocess', stream_name)) -def run_subprocess(cmd: Sequence[StrPath]) -> None: +def run_subprocess(cmd: Sequence[StrPath], env: Mapping[str, str] | None = None) -> None: verbosity = VERBOSITY.get() if verbosity: @@ -53,7 +53,7 @@ def log_stream(stream_name: str, stream: typing.IO[str]) -> None: log(line, origin=('subprocess', stream_name)) with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor, subprocess.Popen( - cmd, encoding='utf-8', stdout=subprocess.PIPE, stderr=subprocess.PIPE + cmd, encoding='utf-8', env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE ) as process: log(subprocess.list2cmdline(cmd), origin=('subprocess', 'cmd')) @@ -69,7 +69,7 @@ def log_stream(stream_name: str, stream: typing.IO[str]) -> None: else: try: - subprocess.run(cmd, capture_output=True, check=True) + subprocess.run(cmd, capture_output=True, check=True, env=env) except subprocess.CalledProcessError as error: log_subprocess_error(error) raise From dd1c3f6e34db784092af18d864e6b18123b864fa Mon Sep 17 00:00:00 2001 From: layday Date: Tue, 5 Mar 2024 23:40:52 +0200 Subject: [PATCH 05/35] Refactor env impl, adding support for uv --- src/build/env.py | 247 +++++++++++++++++++++++++++-------------------- 1 file changed, 144 insertions(+), 103 deletions(-) diff --git a/src/build/env.py b/src/build/env.py index 6cf47c15..62b025c3 100644 --- a/src/build/env.py +++ b/src/build/env.py @@ -11,7 +11,6 @@ import sysconfig import tempfile import typing -import warnings from collections.abc import Collection, Mapping @@ -21,6 +20,11 @@ from ._util import check_dependency +EnvImpl = typing.Literal['venv', 'virtualenv', 'uv'] + +ENV_IMPLS = typing.get_args(EnvImpl) + + class IsolatedEnv(typing.Protocol): """Isolated build environment ABC.""" @@ -35,39 +39,39 @@ def make_extra_environ(self) -> Mapping[str, str] | None: @functools.lru_cache(maxsize=None) -def _should_use_virtualenv() -> bool: - import packaging.requirements +def _has_virtualenv() -> bool: + from packaging.requirements import Requirement # 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 importlib.util.find_spec('virtualenv') is not None and not any( - packaging.requirements.Requirement(d[1]).name == 'virtualenv' - for d in check_dependency('build[virtualenv]') - if len(d) > 1 + Requirement(d[1]).name == 'virtualenv' for d in check_dependency('build[virtualenv]') if len(d) > 1 ) -def _minimum_pip_version() -> str: - 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' - return '21.0.1' if is_apple_silicon_python else '20.3.0' +def _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 _has_valid_pip(__version: str | None = None, **distargs: object) -> bool: +def _has_valid_pip(version_str: str | None = None, /, **distargs: object) -> bool: """ Given a path, see if Pip is present and return True if the version is sufficient for build, False if it is not. ModuleNotFoundError is thrown if pip is not present. """ - import packaging.version + from packaging.version import Version from ._compat import importlib @@ -78,45 +82,52 @@ def _has_valid_pip(__version: str | None = None, **distargs: object) -> bool: except StopIteration: raise ModuleNotFoundError(name) from None - current_pip_version = packaging.version.Version(pip_distribution.version) - - return current_pip_version >= packaging.version.Version(__version or _minimum_pip_version()) + return Version(pip_distribution.version) >= Version(version_str or _minimum_pip_version_str()) -@functools.lru_cache(maxsize=None) -def _valid_global_pip() -> bool | None: +class DefaultIsolatedEnv(IsolatedEnv): """ - 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. + Isolated environment which supports several different underlying implementations. """ - try: - # Version to have added the `--python` option. - return _has_valid_pip('22.3') - except ModuleNotFoundError: - return None - - -class DefaultIsolatedEnv(IsolatedEnv): - """An isolated environment which combines venv and virtualenv with pip.""" + def __init__( + self, + env_impl: EnvImpl | None = None, + ) -> None: + self.env_impl = env_impl def __enter__(self) -> DefaultIsolatedEnv: try: - self._path = tempfile.mkdtemp(prefix='build-env-') - # use virtualenv when available (as it's faster than venv) - if _should_use_virtualenv(): - _ctx.log('Creating virtualenv isolated environment...') - self._python_executable, self._scripts_dir = _create_isolated_env_virtualenv(self._path) + 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 + + msg_tpl = 'Creating isolated environment: {}...' + + self._env_impl_backend: _EnvImplBackend + + # uv is opt-in only. + if self.env_impl == 'uv': + _ctx.log(msg_tpl.format('uv')) + self._env_impl_backend = _UvImplBackend() + + # Use virtualenv when available and the user hasn't explicitly opted into + # venv (as seeding pip is faster than with venv). + elif self.env_impl == 'virtualenv' or (self.env_impl is None and _has_virtualenv()): + _ctx.log(msg_tpl.format('virtualenv')) + self._env_impl_backend = _VirtualenvImplBackend() + else: - _ctx.log('Creating venv isolated 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-')) - self._python_executable, self._scripts_dir = _create_isolated_env_venv(self._path) + _ctx.log(msg_tpl.format('venv')) + self._env_impl_backend = _VenvImplBackend() + + self._env_impl_backend.create(self._path) + except Exception: # cleanup folder if creation fails self.__exit__(*sys.exc_info()) raise @@ -135,11 +146,15 @@ def path(self) -> str: @property def python_executable(self) -> str: """The python executable of the isolated build environment.""" - return self._python_executable + return self._env_impl_backend.python_executable def make_extra_environ(self) -> dict[str, str]: path = os.environ.get('PATH') - return {'PATH': os.pathsep.join([self._scripts_dir, path]) if path is not None else self._scripts_dir} + return { + 'PATH': os.pathsep.join([self._env_impl_backend.scripts_dir, path]) + if path is not None + else self._env_impl_backend.scripts_dir + } def install(self, requirements: Collection[str]) -> None: """ @@ -154,14 +169,41 @@ def install(self, requirements: Collection[str]) -> None: return _ctx.log('Installing packages in isolated environment:\n' + '\n'.join(f'- {r}' for r in sorted(requirements))) + self._env_impl_backend.install_requirements(requirements) + +class _EnvImplBackend(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: + ... + + +class _PipInstallBackendStub(_EnvImplBackend, typing.Protocol): + @functools.cached_property + 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. + """ + try: + # Version to have added the `--python` option. + return _has_valid_pip('22.3') + except ModuleNotFoundError: + return None + + def install_requirements(self, requirements: Collection[str]) -> None: # pip does not honour environment markers in command line arguments - # but it does for requirements from a file + # 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 _valid_global_pip(): + if self._has_valid_outer_pip: cmd = [sys.executable, '-m', 'pip', '--python', self.python_executable] else: cmd = [self.python_executable, '-Im', 'pip'] @@ -169,36 +211,66 @@ def install(self, requirements: Collection[str]) -> None: if _ctx.verbosity > 1: cmd += [f'-{"v" * (_ctx.verbosity - 1)}'] - cmd += [ - 'install', - '--use-pep517', - '--no-warn-script-location', - '-r', - os.path.abspath(req_file.name), - ] + cmd += ['install', '--use-pep517', '--no-warn-script-location', '-r', os.path.abspath(req_file.name)] run_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. +class _VenvImplBackend(_PipInstallBackendStub): + def create(self, path: str) -> None: + import venv - :param path: The path where to create the isolated build environment - :return: The Python executable and script folder - """ - import virtualenv + try: + venv.EnvBuilder(symlinks=_fs_supports_symlink(), with_pip=not self._has_valid_outer_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 - if _valid_global_pip(): - cmd = [path, '--no-seed', '--activators', ''] - else: - cmd = [path, '--no-setuptools', '--no-wheel', '--activators', ''] + self.python_executable, self.scripts_dir, purelib = _find_executable_and_scripts(path) + + if not self._has_valid_outer_pip: + # Get the version of pip in the environment + if not _has_valid_pip(path=[purelib]): + run_subprocess([self.python_executable, '-Im', 'pip', 'install', f'pip>={_minimum_pip_version_str()}']) + run_subprocess([self.python_executable, '-Im', 'pip', 'uninstall', '-y', 'setuptools']) + + +class _VirtualenvImplBackend(_PipInstallBackendStub): + def create(self, path: str) -> None: + import virtualenv + + cmd = [path, '--activators', ''] + cmd += ['--no-seed'] if self._has_valid_outer_pip else ['--no-setuptools', '--no-wheel'] + + result = virtualenv.cli_run(cmd, 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) + + +class _UvImplBackend(_EnvImplBackend): + def create(self, path: str) -> None: + import uv + + self._env_path = path + self._uv_bin_path = uv.find_uv_bin() + + cmd = [self._uv_bin_path, 'venv', self._env_path] + if _ctx.verbosity > 1: + cmd += ['-v'] + run_subprocess(cmd) + + self.python_executable, self.scripts_dir, _ = _find_executable_and_scripts(path) - result = virtualenv.cli_run(cmd, setup_logging=False) - executable = str(result.creator.exe) - script_dir = str(result.creator.script_dir) - return executable, script_dir + def install_requirements(self, requirements: Collection[str]) -> None: + cmd = [self._uv_bin_path, 'pip'] + if _ctx.verbosity > 1: + # uv doesn't support doubling up -v unlike pip. + cmd += ['-v'] + run_subprocess([*cmd, 'install', *requirements], env={**os.environ, 'VIRTUAL_ENV': self._env_path}) @functools.lru_cache(maxsize=None) @@ -219,38 +291,6 @@ def _fs_supports_symlink() -> bool: return True -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 - - 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=not _valid_global_pip(), symlinks=symlinks).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 - - executable, script_dir, purelib = _find_executable_and_scripts(path) - - # Get the version of pip in the environment - if not _valid_global_pip() and not _has_valid_pip(path=[purelib]): - run_subprocess([executable, '-m', 'pip', 'install', f'pip>={_minimum_pip_version()}']) - - # Avoid the setuptools from ensurepip to break the isolation - if not _valid_global_pip(): - run_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. @@ -285,6 +325,7 @@ def _find_executable_and_scripts(path: str) -> tuple[str, str, str]: 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): msg = f'Virtual environment creation failed, executable {executable} missing' From a66b179aee19b230468c556fadaf9d88bde59f56 Mon Sep 17 00:00:00 2001 From: layday Date: Tue, 5 Mar 2024 23:41:06 +0200 Subject: [PATCH 06/35] Add `--env-impl` flag to CLI --- src/build/__main__.py | 32 +++++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/src/build/__main__.py b/src/build/__main__.py index ea09d7ac..86537856 100644 --- a/src/build/__main__.py +++ b/src/build/__main__.py @@ -22,6 +22,7 @@ import build from . import ProjectBuilder, _ctx +from . import env as _env from ._exceptions import BuildBackendException, BuildException, FailedProcessError from ._types import ConfigSettings, Distribution, StrPath from .env import DefaultIsolatedEnv @@ -124,8 +125,9 @@ def _build_in_isolated_env( outdir: StrPath, distribution: Distribution, config_settings: ConfigSettings | None, + env_impl: _env.EnvImpl | None, ) -> str: - with DefaultIsolatedEnv() as env: + with DefaultIsolatedEnv(env_impl) as env: builder = ProjectBuilder.from_isolated_env(env, srcdir) # first install the build dependencies env.install(builder.build_system_requires) @@ -160,9 +162,10 @@ def _build( distribution: Distribution, config_settings: ConfigSettings | None, skip_dependency_check: bool, + env_impl: _env.EnvImpl | None, ) -> str: if isolation: - return _build_in_isolated_env(srcdir, outdir, distribution, config_settings) + return _build_in_isolated_env(srcdir, outdir, distribution, config_settings, env_impl) else: return _build_in_current_env(srcdir, outdir, distribution, config_settings, skip_dependency_check) @@ -216,6 +219,7 @@ def build_package( config_settings: ConfigSettings | None = None, isolation: bool = True, skip_dependency_check: bool = False, + env_impl: _env.EnvImpl | None = None, ) -> Sequence[str]: """ Run the build process. @@ -229,7 +233,7 @@ def build_package( """ built: list[str] = [] for distribution in distributions: - out = _build(isolation, srcdir, outdir, distribution, config_settings, skip_dependency_check) + out = _build(isolation, srcdir, outdir, distribution, config_settings, skip_dependency_check, env_impl) built.append(os.path.basename(out)) return built @@ -241,6 +245,7 @@ def build_package_via_sdist( config_settings: ConfigSettings | None = None, isolation: bool = True, skip_dependency_check: bool = False, + env_impl: _env.EnvImpl | None = None, ) -> Sequence[str]: """ Build a sdist and then the specified distributions from it. @@ -258,7 +263,7 @@ def build_package_via_sdist( msg = 'Only binary distributions are allowed but sdist was specified' raise ValueError(msg) - sdist = _build(isolation, srcdir, outdir, 'sdist', config_settings, skip_dependency_check) + sdist = _build(isolation, srcdir, outdir, 'sdist', config_settings, skip_dependency_check, env_impl) sdist_name = os.path.basename(sdist) sdist_out = tempfile.mkdtemp(prefix='build-via-sdist-') @@ -271,7 +276,7 @@ def build_package_via_sdist( _ctx.log(f'Building {_natural_language_list(distributions)} from sdist') srcdir = os.path.join(sdist_out, sdist_name[: -len('.tar.gz')]) for distribution in distributions: - out = _build(isolation, srcdir, outdir, distribution, config_settings, skip_dependency_check) + out = _build(isolation, srcdir, outdir, distribution, config_settings, skip_dependency_check, env_impl) built.append(os.path.basename(out)) finally: shutil.rmtree(sdist_out, ignore_errors=True) @@ -355,13 +360,20 @@ def main_parser() -> argparse.ArgumentParser: action='store_true', help='do not check that build dependencies are installed', ) - parser.add_argument( + env_group = parser.add_mutually_exclusive_group() + env_group.add_argument( '--no-isolation', '-n', action='store_true', help='disable building the project in an isolated virtual environment. ' 'Build dependencies must be installed separately when this option is used', ) + env_group.add_argument( + '--env-impl', + choices=_env.ENV_IMPLS, + help='isolated environment implementation to use. Defaults to virtualenv if installed, ' + ' otherwise venv. uv support is experimental.', + ) parser.add_argument( '--config-setting', '-C', @@ -414,7 +426,13 @@ def main(cli_args: Sequence[str], prog: str | None = None) -> None: with _handle_build_error(): built = build_call( - args.srcdir, outdir, distributions, config_settings, not args.no_isolation, args.skip_dependency_check + args.srcdir, + outdir, + distributions, + config_settings, + not args.no_isolation, + args.skip_dependency_check, + args.env_impl, ) artifact_list = _natural_language_list( ['{underline}{}{reset}{bold}{green}'.format(artifact, **_styles.get()) for artifact in built] From 7084cf40829b24c1aa99536b56e786ea84234a8c Mon Sep 17 00:00:00 2001 From: layday Date: Wed, 6 Mar 2024 16:44:16 +0200 Subject: [PATCH 07/35] Use `os.name` as is done by the stdlib sysconfig and venv mods --- src/build/env.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/build/env.py b/src/build/env.py index 62b025c3..bc5d2c97 100644 --- a/src/build/env.py +++ b/src/build/env.py @@ -277,7 +277,7 @@ def install_requirements(self, requirements: Collection[str]) -> None: def _fs_supports_symlink() -> bool: """Return True if symlinks are supported""" # Using definition used by venv.main() - if not sys.platform.startswith('win'): + if os.name != 'nt': return True # Windows may support symlinks (setting in Windows 10) @@ -326,7 +326,7 @@ def _find_executable_and_scripts(path: str) -> tuple[str, str, str]: else: paths = sysconfig.get_paths(vars=config_vars) - executable = os.path.join(paths['scripts'], 'python.exe' if sys.platform.startswith('win') else 'python') + 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) From fd3563eb0111098d96b6416342484bed96b9ac91 Mon Sep 17 00:00:00 2001 From: layday Date: Tue, 5 Mar 2024 23:41:13 +0200 Subject: [PATCH 08/35] Update tests --- tests/conftest.py | 4 +-- tests/test_env.py | 83 +++++++++++++++++++++++++--------------------- tests/test_main.py | 49 +++++++++++++++------------ 3 files changed, 75 insertions(+), 61 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 7076bdce..396f8619 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -75,7 +75,7 @@ def pytest_runtest_call(item: pytest.Item): @pytest.fixture() def local_pip(monkeypatch): - monkeypatch.setattr(build.env, '_valid_global_pip', lambda: None) + monkeypatch.setattr(build.env._PipInstallBackendStub, '_has_valid_outer_pip', None) @pytest.fixture(scope='session', autouse=True) @@ -129,7 +129,7 @@ def tmp_dir(): @pytest.fixture(autouse=True) def force_venv(mocker): - mocker.patch.object(build.env, '_should_use_virtualenv', lambda: False) + mocker.patch.object(build.env, '_has_virtualenv', lambda: False) def pytest_report_header() -> str: diff --git a/tests/test_env.py b/tests/test_env.py index 93b6a6de..abfc0df4 100644 --- a/tests/test_env.py +++ b/tests/test_env.py @@ -1,12 +1,14 @@ # SPDX-License-Identifier: MIT -import collections import logging import platform import subprocess import sys import sysconfig +from types import SimpleNamespace + import pytest +import pytest_mock from packaging.version import Version @@ -27,16 +29,16 @@ def test_isolation(): @pytest.mark.isolated @pytest.mark.usefixtures('local_pip') -def test_isolated_environment_install(mocker): +def test_isolated_environment_install(mocker: pytest_mock.MockerFixture): with build.env.DefaultIsolatedEnv() as env: - mocker.patch('build.env.run_subprocess') + run_subprocess = mocker.patch('build.env.run_subprocess') env.install([]) - build.env.run_subprocess.assert_not_called() + run_subprocess.assert_not_called() env.install(['some', 'requirements']) - build.env.run_subprocess.assert_called() - args = build.env.run_subprocess.call_args[0][0][:-1] + run_subprocess.assert_called() + args = run_subprocess.call_args[0][0][:-1] assert args == [ env.python_executable, '-Im', @@ -50,7 +52,9 @@ def test_isolated_environment_install(mocker): @pytest.mark.skipif(IS_PYPY3, reason='PyPy3 uses get path to create and provision venv') @pytest.mark.skipif(sys.platform != 'darwin', reason='workaround for Apple Python') -def test_can_get_venv_paths_with_conflicting_default_scheme(mocker): +def test_can_get_venv_paths_with_conflicting_default_scheme( + mocker: pytest_mock.MockerFixture, +): get_scheme_names = mocker.patch('sysconfig.get_scheme_names', return_value=('osx_framework_library',)) with build.env.DefaultIsolatedEnv(): pass @@ -58,7 +62,9 @@ def test_can_get_venv_paths_with_conflicting_default_scheme(mocker): @pytest.mark.skipif('posix_local' not in sysconfig.get_scheme_names(), reason='workaround for Debian/Ubuntu Python') -def test_can_get_venv_paths_with_posix_local_default_scheme(mocker): +def test_can_get_venv_paths_with_posix_local_default_scheme( + mocker: pytest_mock.MockerFixture, +): get_paths = mocker.spy(sysconfig, 'get_paths') # We should never call this, but we patch it to ensure failure if we do get_default_scheme = mocker.patch('sysconfig.get_default_scheme', return_value='posix_local') @@ -68,7 +74,9 @@ def test_can_get_venv_paths_with_posix_local_default_scheme(mocker): assert get_default_scheme.call_count == 0 -def test_executable_missing_post_creation(mocker): +def test_executable_missing_post_creation( + mocker: pytest_mock.MockerFixture, +): venv_create = mocker.patch('venv.EnvBuilder.create') with pytest.raises(RuntimeError, match='Virtual environment creation failed, executable .* missing'): with build.env.DefaultIsolatedEnv(): @@ -80,36 +88,36 @@ def test_isolated_env_abstract(): with pytest.raises(TypeError): build.env.IsolatedEnv() - -def test_isolated_env_has_executable_still_abstract(): - class Env(build.env.IsolatedEnv): + class PartialEnv(build.env.IsolatedEnv): @property def executable(self): raise NotImplementedError with pytest.raises(TypeError): - Env() + PartialEnv() - -def test_isolated_env_has_install_still_abstract(): - class Env(build.env.IsolatedEnv): - def install(self, requirements): - raise NotImplementedError + class PartialEnv(build.env.IsolatedEnv): + def make_extra_environ(self): + return super().make_extra_environ() with pytest.raises(TypeError): - Env() + PartialEnv() @pytest.mark.pypy3323bug -def test_isolated_env_log(mocker, caplog, package_test_flit): - mocker.patch('build.env.run_subprocess') +@pytest.mark.usefixtures('package_test_flit') +def test_isolated_env_log( + caplog: pytest.LogCaptureFixture, + mocker: pytest_mock.MockerFixture, +): caplog.set_level(logging.DEBUG) + mocker.patch('build.env.run_subprocess') with build.env.DefaultIsolatedEnv() as env: env.install(['something']) assert [(record.levelname, record.message) for record in caplog.records] == [ - ('INFO', 'Creating venv isolated environment...'), + ('INFO', 'Creating isolated environment: venv...'), ('INFO', 'Installing packages in isolated environment:\n- something'), ] @@ -130,32 +138,33 @@ def test_default_pip_is_never_too_old(): @pytest.mark.parametrize('pip_version', ['20.2.0', '20.3.0', '21.0.0', '21.0.1']) @pytest.mark.parametrize('arch', ['x86_64', 'arm64']) @pytest.mark.usefixtures('local_pip') -def test_pip_needs_upgrade_mac_os_11(mocker, pip_version, arch): - SimpleNamespace = collections.namedtuple('SimpleNamespace', 'version') - - _subprocess = mocker.patch('build.env.run_subprocess') +def test_pip_needs_upgrade_mac_os_11( + mocker: pytest_mock.MockerFixture, + pip_version: str, + arch: str, +): + run_subprocess = mocker.patch('build.env.run_subprocess') mocker.patch('platform.system', return_value='Darwin') - mocker.patch('platform.machine', return_value=arch) - mocker.patch('platform.mac_ver', return_value=('11.0', ('', '', ''), '')) + mocker.patch('platform.mac_ver', return_value=('11.0', ('', '', ''), arch)) mocker.patch('build._compat.importlib.metadata.distributions', return_value=(SimpleNamespace(version=pip_version),)) min_version = Version('20.3' if arch == 'x86_64' else '21.0.1') with build.env.DefaultIsolatedEnv(): if Version(pip_version) < min_version: - print(_subprocess.call_args_list) - upgrade_call, uninstall_call = _subprocess.call_args_list + upgrade_call, uninstall_call = run_subprocess.call_args_list answer = 'pip>=20.3.0' if arch == 'x86_64' else 'pip>=21.0.1' - assert upgrade_call[0][0][1:] == ['-m', 'pip', 'install', answer] - assert uninstall_call[0][0][1:] == ['-m', 'pip', 'uninstall', 'setuptools', '-y'] + assert upgrade_call[0][0][1:] == ['-Im', 'pip', 'install', answer] + assert uninstall_call[0][0][1:] == ['-Im', 'pip', 'uninstall', '-y', 'setuptools'] else: - (uninstall_call,) = _subprocess.call_args_list - assert uninstall_call[0][0][1:] == ['-m', 'pip', 'uninstall', 'setuptools', '-y'] + (uninstall_call,) = run_subprocess.call_args_list + assert uninstall_call[0][0][1:] == ['-Im', 'pip', 'uninstall', '-y', 'setuptools'] -@pytest.mark.isolated -@pytest.mark.skipif(IS_PYPY3 and sys.platform.startswith('win'), reason='Isolated tests not supported on PyPy3 + Windows') @pytest.mark.parametrize('has_symlink', [True, False] if sys.platform.startswith('win') else [True]) -def test_venv_symlink(mocker, has_symlink): +def test_venv_symlink( + mocker: pytest_mock.MockerFixture, + has_symlink: bool, +): if has_symlink: mocker.patch('os.symlink') mocker.patch('os.unlink') diff --git a/tests/test_main.py b/tests/test_main.py index ecede883..b843174a 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -30,76 +30,81 @@ [ ( [], - [cwd, out, ['wheel'], {}, True, False], + [cwd, out, ['wheel'], {}, True, False, None], 'build_package_via_sdist', ), ( ['-n'], - [cwd, out, ['wheel'], {}, False, False], + [cwd, out, ['wheel'], {}, False, False, None], 'build_package_via_sdist', ), ( ['-s'], - [cwd, out, ['sdist'], {}, True, False], + [cwd, out, ['sdist'], {}, True, False, None], 'build_package', ), ( ['-w'], - [cwd, out, ['wheel'], {}, True, False], + [cwd, out, ['wheel'], {}, True, False, None], 'build_package', ), ( ['-s', '-w'], - [cwd, out, ['sdist', 'wheel'], {}, True, False], + [cwd, out, ['sdist', 'wheel'], {}, True, False, None], 'build_package', ), ( ['source'], - ['source', os.path.join('source', 'dist'), ['wheel'], {}, True, False], + ['source', os.path.join('source', 'dist'), ['wheel'], {}, True, False, None], 'build_package_via_sdist', ), ( ['-o', 'out'], - [cwd, 'out', ['wheel'], {}, True, False], + [cwd, 'out', ['wheel'], {}, True, False, None], 'build_package_via_sdist', ), ( ['source', '-o', 'out'], - ['source', 'out', ['wheel'], {}, True, False], + ['source', 'out', ['wheel'], {}, True, False, None], 'build_package_via_sdist', ), ( ['-x'], - [cwd, out, ['wheel'], {}, True, True], + [cwd, out, ['wheel'], {}, True, True, None], + 'build_package_via_sdist', + ), + ( + ['--env-impl', 'venv'], + [cwd, out, ['wheel'], {}, True, False, 'venv'], 'build_package_via_sdist', ), ( ['-C--flag1', '-C--flag2'], - [cwd, out, ['wheel'], {'--flag1': '', '--flag2': ''}, True, False], + [cwd, out, ['wheel'], {'--flag1': '', '--flag2': ''}, True, False, None], 'build_package_via_sdist', ), ( ['-C--flag=value'], - [cwd, out, ['wheel'], {'--flag': 'value'}, True, False], + [cwd, out, ['wheel'], {'--flag': 'value'}, True, False, None], 'build_package_via_sdist', ), ( ['-C--flag1=value', '-C--flag2=other_value', '-C--flag2=extra_value'], - [cwd, out, ['wheel'], {'--flag1': 'value', '--flag2': ['other_value', 'extra_value']}, True, False], + [cwd, out, ['wheel'], {'--flag1': 'value', '--flag2': ['other_value', 'extra_value']}, True, False, None], 'build_package_via_sdist', ), ], ) def test_parse_args(mocker, cli_args, build_args, hook): - mocker.patch('build.__main__.build_package', return_value=['something']) - mocker.patch('build.__main__.build_package_via_sdist', return_value=['something']) + build_package = mocker.patch('build.__main__.build_package', return_value=['something']) + build_package_via_sdist = mocker.patch('build.__main__.build_package_via_sdist', return_value=['something']) build.__main__.main(cli_args) if hook == 'build_package': - build.__main__.build_package.assert_called_with(*build_args) + build_package.assert_called_with(*build_args) elif hook == 'build_package_via_sdist': - build.__main__.build_package_via_sdist.assert_called_with(*build_args) + build_package_via_sdist.assert_called_with(*build_args) else: # pragma: no cover msg = f'Unknown hook {hook}' raise ValueError(msg) @@ -231,13 +236,13 @@ def test_build_package_via_sdist_invalid_distribution(tmp_dir, package_test_setu pytest.param( [], [ - '* Creating venv isolated environment...', + '* Creating isolated environment: venv...', '* Installing packages in isolated environment:', ' - setuptools >= 42.0.0', '* Getting build dependencies for sdist...', '* Building sdist...', '* Building wheel from sdist', - '* Creating venv isolated environment...', + '* Creating isolated environment: venv...', '* Installing packages in isolated environment:', ' - setuptools >= 42.0.0', '* Getting build dependencies for wheel...', @@ -264,7 +269,7 @@ def test_build_package_via_sdist_invalid_distribution(tmp_dir, package_test_setu pytest.param( ['--wheel'], [ - '* Creating venv isolated environment...', + '* Creating isolated environment: venv...', '* Installing packages in isolated environment:', ' - setuptools >= 42.0.0', '* Getting build dependencies for wheel...', @@ -322,7 +327,7 @@ def test_output(package_test_setuptools, tmp_dir, capsys, args, output): False, 'ERROR ', [ - '* Creating venv isolated environment...', + '* Creating isolated environment: venv...', '* Installing packages in isolated environment:', ' - setuptools >= 42.0.0', ' - this is invalid', @@ -332,7 +337,7 @@ def test_output(package_test_setuptools, tmp_dir, capsys, args, output): True, '\33[91mERROR\33[0m ', [ - '\33[1m* Creating venv isolated environment...\33[0m', + '\33[1m* Creating isolated environment: venv...\33[0m', '\33[1m* Installing packages in isolated environment:\33[0m', ' - setuptools >= 42.0.0', ' - this is invalid', @@ -424,7 +429,7 @@ def raise_called_process_err(*args, **kwargs): assert ( stdout == """\ -* Creating venv isolated environment... +* Creating isolated environment: venv... > test args < stdoutput ERROR Failed to create venv. Maybe try installing virtualenv. From 0c6c26148494be204f71da99c71c29ce2fd4c0c3 Mon Sep 17 00:00:00 2001 From: layday Date: Wed, 6 Mar 2024 17:10:51 +0200 Subject: [PATCH 09/35] Require uv and virtualenv for testing --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 94eb9cdc..71b0ba9e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,6 +50,7 @@ docs = [ "sphinx-issues >= 3.0.0", ] test = [ + "build[uv, virtualenv]", "filelock >= 3", "pytest >= 6.2.4", "pytest-cov >= 2.12", From 5a3609f66395131300cf994eabe7f9bd269b4b26 Mon Sep 17 00:00:00 2001 From: layday Date: Wed, 6 Mar 2024 17:11:37 +0200 Subject: [PATCH 10/35] Add more env tests --- tests/test_env.py | 137 ++++++++++++++++++++++++++++++++++++---------- 1 file changed, 107 insertions(+), 30 deletions(-) diff --git a/tests/test_env.py b/tests/test_env.py index abfc0df4..a4f3fdd9 100644 --- a/tests/test_env.py +++ b/tests/test_env.py @@ -5,6 +5,7 @@ import sys import sysconfig +from pathlib import Path from types import SimpleNamespace import pytest @@ -19,37 +20,17 @@ @pytest.mark.isolated -def test_isolation(): +@pytest.mark.parametrize('env_impl', build.env.ENV_IMPLS) +def test_isolation( + env_impl: build.env.EnvImpl, +): subprocess.check_call([sys.executable, '-c', 'import build.env']) - with build.env.DefaultIsolatedEnv() as env: + with build.env.DefaultIsolatedEnv(env_impl) as env: with pytest.raises(subprocess.CalledProcessError): debug = 'import sys; import os; print(os.linesep.join(sys.path));' subprocess.check_call([env.python_executable, '-c', f'{debug} import build.env']) -@pytest.mark.isolated -@pytest.mark.usefixtures('local_pip') -def test_isolated_environment_install(mocker: pytest_mock.MockerFixture): - with build.env.DefaultIsolatedEnv() as env: - run_subprocess = mocker.patch('build.env.run_subprocess') - - env.install([]) - run_subprocess.assert_not_called() - - env.install(['some', 'requirements']) - run_subprocess.assert_called() - args = run_subprocess.call_args[0][0][:-1] - assert args == [ - env.python_executable, - '-Im', - 'pip', - 'install', - '--use-pep517', - '--no-warn-script-location', - '-r', - ] - - @pytest.mark.skipif(IS_PYPY3, reason='PyPy3 uses get path to create and provision venv') @pytest.mark.skipif(sys.platform != 'darwin', reason='workaround for Apple Python') def test_can_get_venv_paths_with_conflicting_default_scheme( @@ -74,7 +55,7 @@ def test_can_get_venv_paths_with_posix_local_default_scheme( assert get_default_scheme.call_count == 0 -def test_executable_missing_post_creation( +def test_venv_impl_executable_missing_post_creation( mocker: pytest_mock.MockerFixture, ): venv_create = mocker.patch('venv.EnvBuilder.create') @@ -105,7 +86,6 @@ def make_extra_environ(self): @pytest.mark.pypy3323bug -@pytest.mark.usefixtures('package_test_flit') def test_isolated_env_log( caplog: pytest.LogCaptureFixture, mocker: pytest_mock.MockerFixture, @@ -127,10 +107,9 @@ def test_isolated_env_log( def test_default_pip_is_never_too_old(): with build.env.DefaultIsolatedEnv() as env: version = subprocess.check_output( - [env.python_executable, '-c', 'import pip; print(pip.__version__)'], - text=True, + [env.python_executable, '-c', 'import pip; print(pip.__version__, end="")'], encoding='utf-8', - ).strip() + ) assert Version(version) >= Version('19.1') @@ -177,3 +156,101 @@ def test_venv_symlink( build.env._fs_supports_symlink.cache_clear() assert supports_symlink is has_symlink + + +@pytest.mark.parametrize('env_impl', build.env.ENV_IMPLS) +def test_install_short_circuits( + mocker: pytest_mock.MockerFixture, + env_impl: build.env.EnvImpl, +): + with build.env.DefaultIsolatedEnv(env_impl) as env: + install_requirements = mocker.patch.object(env._env_impl_backend, 'install_requirements') + + env.install([]) + install_requirements.assert_not_called() + + env.install(['foo']) + install_requirements.assert_called_once() + + +@pytest.mark.usefixtures('local_pip') +@pytest.mark.parametrize('env_impl', ['virtualenv', 'venv']) +def test_venv_or_virtualenv_impl_install_cmd_well_formed( + mocker: pytest_mock.MockerFixture, + env_impl: build.env.EnvImpl, +): + with build.env.DefaultIsolatedEnv(env_impl) as env: + run_subprocess = mocker.patch('build.env.run_subprocess') + + env.install(['some', 'requirements']) + + run_subprocess.assert_called_once() + call_args = run_subprocess.call_args[0][0][:-1] + assert call_args == [ + env.python_executable, + '-Im', + 'pip', + 'install', + '--use-pep517', + '--no-warn-script-location', + '-r', + ] + + +@pytest.mark.parametrize('verbosity', [0, 1, 9000]) +def test_uv_impl_create_cmd_well_formed( + mocker: pytest_mock.MockerFixture, + verbosity: int, +): + run_subprocess = mocker.patch('build.env.run_subprocess') + + with pytest.raises(RuntimeError, match='Virtual environment creation failed'), \ + build.env.DefaultIsolatedEnv('uv') as env: # fmt: skip + (create_call,) = run_subprocess.call_args_list + cmd_tail = ['venv', env.path] + if verbosity: + cmd_tail += ['-v'] + assert create_call.args[0][1:] == cmd_tail + assert not create_call.kwargs + + +def test_uv_impl_install_cmd_well_formed( + mocker: pytest_mock.MockerFixture, +): + with build.env.DefaultIsolatedEnv('uv') as env: + run_subprocess = mocker.patch('build.env.run_subprocess') + + env.install(['foo']) + + (install_call,) = run_subprocess.call_args_list + assert len(install_call.args) == 1 + assert install_call.args[0][1:] == ['pip', 'install', 'foo'] + assert len(install_call.kwargs) == 1 + assert install_call.kwargs['env']['VIRTUAL_ENV'] == env.path + + +@pytest.mark.parametrize( + ('env_impl', 'pyvenv_key'), + [ + ('virtualenv', 'virtualenv'), + ('uv', 'uv'), + ], +) +def test_venv_creation( + env_impl: build.env.EnvImpl, + pyvenv_key: str, +): + with build.env.DefaultIsolatedEnv(env_impl) as env: + with Path(env.path, 'pyvenv.cfg').open(encoding='utf-8') as pyvenv_cfg: + next(h.rstrip() == pyvenv_key for i in pyvenv_cfg for (h, *_) in (i.partition('='),)) + + +@pytest.mark.network +@pytest.mark.usefixtures('local_pip') +@pytest.mark.parametrize('env_impl', build.env.ENV_IMPLS) +def test_requirement_installation( + package_test_flit: str, + env_impl: build.env.EnvImpl, +): + with build.env.DefaultIsolatedEnv(env_impl) as env: + env.install([f'test-flit @ {Path(package_test_flit).as_uri()}']) From 641170c0af1364b7f18e9dcfa85dad7df77fbc10 Mon Sep 17 00:00:00 2001 From: layday Date: Wed, 6 Mar 2024 17:29:41 +0200 Subject: [PATCH 11/35] Back out of reading `pyvenv.cfg` --- tests/test_env.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/test_env.py b/tests/test_env.py index a4f3fdd9..f501ba97 100644 --- a/tests/test_env.py +++ b/tests/test_env.py @@ -230,19 +230,19 @@ def test_uv_impl_install_cmd_well_formed( @pytest.mark.parametrize( - ('env_impl', 'pyvenv_key'), + ('env_impl', 'backend_cls'), [ - ('virtualenv', 'virtualenv'), - ('uv', 'uv'), + ('venv', build.env._VenvImplBackend), + ('virtualenv', build.env._VirtualenvImplBackend), + ('uv', build.env._UvImplBackend), ], ) def test_venv_creation( env_impl: build.env.EnvImpl, - pyvenv_key: str, + backend_cls: build.env._EnvImplBackend, ): with build.env.DefaultIsolatedEnv(env_impl) as env: - with Path(env.path, 'pyvenv.cfg').open(encoding='utf-8') as pyvenv_cfg: - next(h.rstrip() == pyvenv_key for i in pyvenv_cfg for (h, *_) in (i.partition('='),)) + assert type(env._env_impl_backend) is backend_cls @pytest.mark.network From c6d86d2f0be2d8a97e85944a0c803f3a94f783a0 Mon Sep 17 00:00:00 2001 From: layday Date: Wed, 6 Mar 2024 21:40:04 +0200 Subject: [PATCH 12/35] Create uv envs with venv --- src/build/env.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/src/build/env.py b/src/build/env.py index bc5d2c97..887ba4e9 100644 --- a/src/build/env.py +++ b/src/build/env.py @@ -253,20 +253,17 @@ def create(self, path: str) -> None: class _UvImplBackend(_EnvImplBackend): def create(self, path: str) -> None: - import uv + import venv - self._env_path = path - self._uv_bin_path = uv.find_uv_bin() - - cmd = [self._uv_bin_path, 'venv', self._env_path] - if _ctx.verbosity > 1: - cmd += ['-v'] - run_subprocess(cmd) + venv.EnvBuilder(symlinks=_fs_supports_symlink(), with_pip=False).create(path) self.python_executable, self.scripts_dir, _ = _find_executable_and_scripts(path) + self._env_path = path def install_requirements(self, requirements: Collection[str]) -> None: - cmd = [self._uv_bin_path, 'pip'] + import uv + + cmd = [uv.find_uv_bin(), 'pip'] if _ctx.verbosity > 1: # uv doesn't support doubling up -v unlike pip. cmd += ['-v'] From 2e65e582947c7e6d55457cd69241525ee861e4e5 Mon Sep 17 00:00:00 2001 From: layday Date: Wed, 6 Mar 2024 21:40:19 +0200 Subject: [PATCH 13/35] Do not expose pip implementations --- src/build/env.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/build/env.py b/src/build/env.py index 887ba4e9..78d239c1 100644 --- a/src/build/env.py +++ b/src/build/env.py @@ -20,7 +20,7 @@ from ._util import check_dependency -EnvImpl = typing.Literal['venv', 'virtualenv', 'uv'] +EnvImpl = typing.Literal['venv+uv'] ENV_IMPLS = typing.get_args(EnvImpl) @@ -112,18 +112,17 @@ def __enter__(self) -> DefaultIsolatedEnv: self._env_impl_backend: _EnvImplBackend # uv is opt-in only. - if self.env_impl == 'uv': - _ctx.log(msg_tpl.format('uv')) + if self.env_impl == 'venv+uv': + _ctx.log(msg_tpl.format(self.env_impl)) self._env_impl_backend = _UvImplBackend() - # Use virtualenv when available and the user hasn't explicitly opted into - # venv (as seeding pip is faster than with venv). - elif self.env_impl == 'virtualenv' or (self.env_impl is None and _has_virtualenv()): - _ctx.log(msg_tpl.format('virtualenv')) + # Use virtualenv when available (as seeding pip is faster than with venv). + elif _has_virtualenv(): + _ctx.log(msg_tpl.format('virtualenv+pip')) self._env_impl_backend = _VirtualenvImplBackend() else: - _ctx.log(msg_tpl.format('venv')) + _ctx.log(msg_tpl.format('venv+pip')) self._env_impl_backend = _VenvImplBackend() self._env_impl_backend.create(self._path) From 959f11681831f6ba7f437ed26d1eb5f74e8b5251 Mon Sep 17 00:00:00 2001 From: layday Date: Wed, 6 Mar 2024 21:40:26 +0200 Subject: [PATCH 14/35] Update tests --- tests/conftest.py | 6 +++--- tests/test_env.py | 36 +++++++++++------------------------- tests/test_main.py | 16 ++++++++-------- 3 files changed, 22 insertions(+), 36 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 396f8619..7bf725a0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -127,9 +127,9 @@ def tmp_dir(): shutil.rmtree(path) -@pytest.fixture(autouse=True) -def force_venv(mocker): - mocker.patch.object(build.env, '_has_virtualenv', lambda: False) +@pytest.fixture(autouse=True, params=[False]) +def has_virtualenv(request, mocker): + mocker.patch.object(build.env, '_has_virtualenv', lambda: request.param) def pytest_report_header() -> str: diff --git a/tests/test_env.py b/tests/test_env.py index f501ba97..a2483f11 100644 --- a/tests/test_env.py +++ b/tests/test_env.py @@ -1,4 +1,6 @@ # SPDX-License-Identifier: MIT +from __future__ import annotations + import logging import platform import subprocess @@ -97,7 +99,7 @@ def test_isolated_env_log( env.install(['something']) assert [(record.levelname, record.message) for record in caplog.records] == [ - ('INFO', 'Creating isolated environment: venv...'), + ('INFO', 'Creating isolated environment: venv+pip...'), ('INFO', 'Installing packages in isolated environment:\n- something'), ] @@ -197,27 +199,10 @@ def test_venv_or_virtualenv_impl_install_cmd_well_formed( ] -@pytest.mark.parametrize('verbosity', [0, 1, 9000]) -def test_uv_impl_create_cmd_well_formed( - mocker: pytest_mock.MockerFixture, - verbosity: int, -): - run_subprocess = mocker.patch('build.env.run_subprocess') - - with pytest.raises(RuntimeError, match='Virtual environment creation failed'), \ - build.env.DefaultIsolatedEnv('uv') as env: # fmt: skip - (create_call,) = run_subprocess.call_args_list - cmd_tail = ['venv', env.path] - if verbosity: - cmd_tail += ['-v'] - assert create_call.args[0][1:] == cmd_tail - assert not create_call.kwargs - - def test_uv_impl_install_cmd_well_formed( mocker: pytest_mock.MockerFixture, ): - with build.env.DefaultIsolatedEnv('uv') as env: + with build.env.DefaultIsolatedEnv('venv+uv') as env: run_subprocess = mocker.patch('build.env.run_subprocess') env.install(['foo']) @@ -230,15 +215,16 @@ def test_uv_impl_install_cmd_well_formed( @pytest.mark.parametrize( - ('env_impl', 'backend_cls'), + ('env_impl', 'backend_cls', 'has_virtualenv'), [ - ('venv', build.env._VenvImplBackend), - ('virtualenv', build.env._VirtualenvImplBackend), - ('uv', build.env._UvImplBackend), + (None, build.env._VenvImplBackend, False), + (None, build.env._VirtualenvImplBackend, True), + ('venv+uv', build.env._UvImplBackend, None), ], + indirect=('has_virtualenv',), ) -def test_venv_creation( - env_impl: build.env.EnvImpl, +def test_uv_venv_creation( + env_impl: build.env.EnvImpl | None, backend_cls: build.env._EnvImplBackend, ): with build.env.DefaultIsolatedEnv(env_impl) as env: diff --git a/tests/test_main.py b/tests/test_main.py index b843174a..dff0d129 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -74,8 +74,8 @@ 'build_package_via_sdist', ), ( - ['--env-impl', 'venv'], - [cwd, out, ['wheel'], {}, True, False, 'venv'], + ['--env-impl', 'venv+uv'], + [cwd, out, ['wheel'], {}, True, False, 'venv+uv'], 'build_package_via_sdist', ), ( @@ -236,13 +236,13 @@ def test_build_package_via_sdist_invalid_distribution(tmp_dir, package_test_setu pytest.param( [], [ - '* Creating isolated environment: venv...', + '* Creating isolated environment: venv+pip...', '* Installing packages in isolated environment:', ' - setuptools >= 42.0.0', '* Getting build dependencies for sdist...', '* Building sdist...', '* Building wheel from sdist', - '* Creating isolated environment: venv...', + '* Creating isolated environment: venv+pip...', '* Installing packages in isolated environment:', ' - setuptools >= 42.0.0', '* Getting build dependencies for wheel...', @@ -269,7 +269,7 @@ def test_build_package_via_sdist_invalid_distribution(tmp_dir, package_test_setu pytest.param( ['--wheel'], [ - '* Creating isolated environment: venv...', + '* Creating isolated environment: venv+pip...', '* Installing packages in isolated environment:', ' - setuptools >= 42.0.0', '* Getting build dependencies for wheel...', @@ -327,7 +327,7 @@ def test_output(package_test_setuptools, tmp_dir, capsys, args, output): False, 'ERROR ', [ - '* Creating isolated environment: venv...', + '* Creating isolated environment: venv+pip...', '* Installing packages in isolated environment:', ' - setuptools >= 42.0.0', ' - this is invalid', @@ -337,7 +337,7 @@ def test_output(package_test_setuptools, tmp_dir, capsys, args, output): True, '\33[91mERROR\33[0m ', [ - '\33[1m* Creating isolated environment: venv...\33[0m', + '\33[1m* Creating isolated environment: venv+pip...\33[0m', '\33[1m* Installing packages in isolated environment:\33[0m', ' - setuptools >= 42.0.0', ' - this is invalid', @@ -429,7 +429,7 @@ def raise_called_process_err(*args, **kwargs): assert ( stdout == """\ -* Creating isolated environment: venv... +* Creating isolated environment: venv+pip... > test args < stdoutput ERROR Failed to create venv. Maybe try installing virtualenv. From f775815454f219ca1d7235cdc6c4de6dbe8782b8 Mon Sep 17 00:00:00 2001 From: layday Date: Thu, 7 Mar 2024 17:49:15 +0200 Subject: [PATCH 15/35] Run integration tests against uv --- tests/test_integration.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_integration.py b/tests/test_integration.py index 185e99c3..70118c30 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -79,8 +79,8 @@ def _ignore_folder(base, filenames): ) @pytest.mark.parametrize( 'args', - [[], ['-x', '--no-isolation']], - ids=['isolated', 'no_isolation'], + [[], ['--env-impl', 'venv+uv'], ['-x', '--no-isolation']], + ids=['isolated', 'isolated_venv+uv', 'no_isolation'], ) @pytest.mark.parametrize( 'project', From 7d77fd23e60b32df40303b63583d5f2629a83ecb Mon Sep 17 00:00:00 2001 From: layday Date: Thu, 7 Mar 2024 17:49:34 +0200 Subject: [PATCH 16/35] Merge pip impls --- src/build/env.py | 201 ++++++++++++++++++++++++++-------------------- tests/conftest.py | 15 ++-- tests/test_env.py | 38 ++++----- 3 files changed, 139 insertions(+), 115 deletions(-) diff --git a/src/build/env.py b/src/build/env.py index 78d239c1..ccf378cc 100644 --- a/src/build/env.py +++ b/src/build/env.py @@ -24,6 +24,8 @@ ENV_IMPLS = typing.get_args(EnvImpl) +_EnvImplInclDefault = typing.Literal['venv+pip', 'virtualenv+pip', EnvImpl] + class IsolatedEnv(typing.Protocol): """Isolated build environment ABC.""" @@ -38,51 +40,24 @@ def make_extra_environ(self) -> Mapping[str, str] | None: """Generate additional env vars specific to the isolated environment.""" -@functools.lru_cache(maxsize=None) -def _has_virtualenv() -> bool: - from packaging.requirements import Requirement - - # 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 importlib.util.find_spec('virtualenv') is not None and not any( - Requirement(d[1]).name == 'virtualenv' for d in check_dependency('build[virtualenv]') if len(d) > 1 - ) - - -def _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 _has_valid_pip(version_str: str | None = None, /, **distargs: object) -> bool: +def _has_dependency(name: str, minimum_version_str: str | None = None, /, **distargs: object) -> bool | None: """ - Given a path, see if Pip is present and return True if the version is - sufficient for build, False if it is not. ModuleNotFoundError is thrown if - pip is not present. + 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 - name = 'pip' - try: - pip_distribution = next(iter(importlib.metadata.distributions(name=name, **distargs))) + distribution = next(iter(importlib.metadata.distributions(name=name, **distargs))) except StopIteration: - raise ModuleNotFoundError(name) from None + return None + + if minimum_version_str is None: + return True - return Version(pip_distribution.version) >= Version(version_str or _minimum_pip_version_str()) + return Version(distribution.version) >= Version(minimum_version_str) class DefaultIsolatedEnv(IsolatedEnv): @@ -107,24 +82,15 @@ def __enter__(self) -> DefaultIsolatedEnv: path = os.path.realpath(path) self._path = path - msg_tpl = 'Creating isolated environment: {}...' - self._env_impl_backend: _EnvImplBackend # uv is opt-in only. if self.env_impl == 'venv+uv': - _ctx.log(msg_tpl.format(self.env_impl)) self._env_impl_backend = _UvImplBackend() - - # Use virtualenv when available (as seeding pip is faster than with venv). - elif _has_virtualenv(): - _ctx.log(msg_tpl.format('virtualenv+pip')) - self._env_impl_backend = _VirtualenvImplBackend() - else: - _ctx.log(msg_tpl.format('venv+pip')) - self._env_impl_backend = _VenvImplBackend() + self._env_impl_backend = _DefaultImplBackend() + _ctx.log(f'Creating isolated environment: {self._env_impl_backend.name}...') self._env_impl_backend.create(self._path) except Exception: # cleanup folder if creation fails @@ -181,19 +147,101 @@ def create(self, path: str) -> None: def install_requirements(self, requirements: Collection[str]) -> None: ... + @property + def name(self) -> _EnvImplInclDefault: + ... + + +class _DefaultImplBackend(_EnvImplBackend): + def __init__(self) -> None: + self._create_with_virtualenv = not self._has_valid_outer_pip and self._has_virtualenv -class _PipInstallBackendStub(_EnvImplBackend, typing.Protocol): @functools.cached_property 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. """ - try: - # Version to have added the `--python` option. - return _has_valid_pip('22.3') - except ModuleNotFoundError: - return None + # Version to have added the `--python` option. + return _has_dependency('pip', '22.3') + + @functools.cached_property + 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 + ) + + @staticmethod + 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 @@ -210,44 +258,21 @@ def install_requirements(self, requirements: Collection[str]) -> None: if _ctx.verbosity > 1: cmd += [f'-{"v" * (_ctx.verbosity - 1)}'] - cmd += ['install', '--use-pep517', '--no-warn-script-location', '-r', os.path.abspath(req_file.name)] + cmd += [ + 'install', + '--use-pep517', + '--no-warn-script-location', + '-r', + os.path.abspath(req_file.name), + ] run_subprocess(cmd) finally: os.unlink(req_file.name) - -class _VenvImplBackend(_PipInstallBackendStub): - def create(self, path: str) -> None: - import venv - - try: - venv.EnvBuilder(symlinks=_fs_supports_symlink(), with_pip=not self._has_valid_outer_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 not self._has_valid_outer_pip: - # Get the version of pip in the environment - if not _has_valid_pip(path=[purelib]): - run_subprocess([self.python_executable, '-Im', 'pip', 'install', f'pip>={_minimum_pip_version_str()}']) - run_subprocess([self.python_executable, '-Im', 'pip', 'uninstall', '-y', 'setuptools']) - - -class _VirtualenvImplBackend(_PipInstallBackendStub): - def create(self, path: str) -> None: - import virtualenv - - cmd = [path, '--activators', ''] - cmd += ['--no-seed'] if self._has_valid_outer_pip else ['--no-setuptools', '--no-wheel'] - - result = virtualenv.cli_run(cmd, 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) + @property + def name(self) -> _EnvImplInclDefault: + return 'virtualenv+pip' if self._create_with_virtualenv else 'venv+pip' class _UvImplBackend(_EnvImplBackend): @@ -268,6 +293,10 @@ def install_requirements(self, requirements: Collection[str]) -> None: cmd += ['-v'] run_subprocess([*cmd, 'install', *requirements], env={**os.environ, 'VIRTUAL_ENV': self._env_path}) + @property + def name(self) -> EnvImpl: + return 'venv+uv' + @functools.lru_cache(maxsize=None) def _fs_supports_symlink() -> bool: diff --git a/tests/conftest.py b/tests/conftest.py index 7bf725a0..d340424f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -73,9 +73,15 @@ def pytest_runtest_call(item: pytest.Item): raise RuntimeError(msg) -@pytest.fixture() +@pytest.fixture def local_pip(monkeypatch): - monkeypatch.setattr(build.env._PipInstallBackendStub, '_has_valid_outer_pip', None) + monkeypatch.setattr(build.env._DefaultImplBackend, '_has_valid_outer_pip', None) + + +@pytest.fixture(autouse=True, params=[False]) +def has_virtualenv(request, monkeypatch): + if request.param is not None: + monkeypatch.setattr(build.env._DefaultImplBackend, '_has_virtualenv', request.param) @pytest.fixture(scope='session', autouse=True) @@ -127,11 +133,6 @@ def tmp_dir(): shutil.rmtree(path) -@pytest.fixture(autouse=True, params=[False]) -def has_virtualenv(request, mocker): - mocker.patch.object(build.env, '_has_virtualenv', lambda: request.param) - - def pytest_report_header() -> str: interesting_packages = [ 'build', diff --git a/tests/test_env.py b/tests/test_env.py index a2483f11..2d6b8ee4 100644 --- a/tests/test_env.py +++ b/tests/test_env.py @@ -22,12 +22,9 @@ @pytest.mark.isolated -@pytest.mark.parametrize('env_impl', build.env.ENV_IMPLS) -def test_isolation( - env_impl: build.env.EnvImpl, -): +def test_isolation(): subprocess.check_call([sys.executable, '-c', 'import build.env']) - with build.env.DefaultIsolatedEnv(env_impl) as env: + with build.env.DefaultIsolatedEnv() as env: with pytest.raises(subprocess.CalledProcessError): debug = 'import sys; import os; print(os.linesep.join(sys.path));' subprocess.check_call([env.python_executable, '-c', f'{debug} import build.env']) @@ -57,7 +54,7 @@ def test_can_get_venv_paths_with_posix_local_default_scheme( assert get_default_scheme.call_count == 0 -def test_venv_impl_executable_missing_post_creation( +def test_venv_executable_missing_post_creation( mocker: pytest_mock.MockerFixture, ): venv_create = mocker.patch('venv.EnvBuilder.create') @@ -160,12 +157,10 @@ def test_venv_symlink( assert supports_symlink is has_symlink -@pytest.mark.parametrize('env_impl', build.env.ENV_IMPLS) def test_install_short_circuits( mocker: pytest_mock.MockerFixture, - env_impl: build.env.EnvImpl, ): - with build.env.DefaultIsolatedEnv(env_impl) as env: + with build.env.DefaultIsolatedEnv() as env: install_requirements = mocker.patch.object(env._env_impl_backend, 'install_requirements') env.install([]) @@ -176,12 +171,10 @@ def test_install_short_circuits( @pytest.mark.usefixtures('local_pip') -@pytest.mark.parametrize('env_impl', ['virtualenv', 'venv']) -def test_venv_or_virtualenv_impl_install_cmd_well_formed( +def test_default_impl_install_cmd_well_formed( mocker: pytest_mock.MockerFixture, - env_impl: build.env.EnvImpl, ): - with build.env.DefaultIsolatedEnv(env_impl) as env: + with build.env.DefaultIsolatedEnv() as env: run_subprocess = mocker.patch('build.env.run_subprocess') env.install(['some', 'requirements']) @@ -214,29 +207,30 @@ def test_uv_impl_install_cmd_well_formed( assert install_call.kwargs['env']['VIRTUAL_ENV'] == env.path +@pytest.mark.usefixtures('local_pip') @pytest.mark.parametrize( - ('env_impl', 'backend_cls', 'has_virtualenv'), + ('env_impl', 'resultant_impl_name', 'has_virtualenv'), [ - (None, build.env._VenvImplBackend, False), - (None, build.env._VirtualenvImplBackend, True), - ('venv+uv', build.env._UvImplBackend, None), + (None, 'venv+pip', False), + (None, 'virtualenv+pip', True), + ('venv+uv', 'venv+uv', None), ], indirect=('has_virtualenv',), ) -def test_uv_venv_creation( +def test_env_creation( env_impl: build.env.EnvImpl | None, - backend_cls: build.env._EnvImplBackend, + resultant_impl_name: build.env._EnvImplBackend, ): with build.env.DefaultIsolatedEnv(env_impl) as env: - assert type(env._env_impl_backend) is backend_cls + assert env._env_impl_backend.name == resultant_impl_name @pytest.mark.network @pytest.mark.usefixtures('local_pip') -@pytest.mark.parametrize('env_impl', build.env.ENV_IMPLS) +@pytest.mark.parametrize('env_impl', [None, *build.env.ENV_IMPLS]) def test_requirement_installation( package_test_flit: str, - env_impl: build.env.EnvImpl, + env_impl: build.env.EnvImpl | None, ): with build.env.DefaultIsolatedEnv(env_impl) as env: env.install([f'test-flit @ {Path(package_test_flit).as_uri()}']) From b8672ac7eba5b8f8ab4655fbede0e557fe24ad0a Mon Sep 17 00:00:00 2001 From: layday Date: Thu, 7 Mar 2024 22:23:36 +0200 Subject: [PATCH 17/35] Test with latest version of virtualenv for the time being 20.0.35 emits a host of errors. --- tests/constraints.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/constraints.txt b/tests/constraints.txt index 36446db9..6fae7ac8 100644 --- a/tests/constraints.txt +++ b/tests/constraints.txt @@ -6,5 +6,5 @@ setuptools==56.0.0; python_version == "3.10" setuptools==56.0.0; python_version == "3.11" setuptools==67.8.0; python_version >= "3.12" tomli==1.1.0 -virtualenv==20.0.35 +# virtualenv==20.0.35 wheel==0.36.0 From 36d3f921747c8d18a98640bb3cbbec80817b69ce Mon Sep 17 00:00:00 2001 From: layday Date: Fri, 8 Mar 2024 13:24:19 +0200 Subject: [PATCH 18/35] Do not attempt uv import --- pyproject.toml | 6 ++---- src/build/env.py | 15 ++++++++++----- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 71b0ba9e..1ba6544a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,7 +50,7 @@ docs = [ "sphinx-issues >= 3.0.0", ] test = [ - "build[uv, virtualenv]", + "build[virtualenv]", "filelock >= 3", "pytest >= 6.2.4", "pytest-cov >= 2.12", @@ -62,15 +62,13 @@ test = [ 'setuptools >= 56.0.0; python_version == "3.10"', 'setuptools >= 56.0.0; python_version == "3.11"', 'setuptools >= 67.8.0; python_version >= "3.12"', + "uv >= 0.1.15", ] typing = [ - "build[uv]", "importlib-metadata >= 5.1", "mypy ~= 1.5.0", "tomli", "typing-extensions >= 3.7.4.3", -] -uv = [ "uv >= 0.1.15", ] virtualenv = [ diff --git a/src/build/env.py b/src/build/env.py index ccf378cc..6227f257 100644 --- a/src/build/env.py +++ b/src/build/env.py @@ -279,15 +279,20 @@ class _UvImplBackend(_EnvImplBackend): def create(self, path: str) -> None: import venv - venv.EnvBuilder(symlinks=_fs_supports_symlink(), with_pip=False).create(path) + self._env_path = path + + uv_bin = shutil.which('uv') + if not uv_bin: + msg = 'uv executable missing' + raise RuntimeError(msg) + self._uv_bin = uv_bin + + venv.EnvBuilder(symlinks=_fs_supports_symlink(), with_pip=False).create(path) self.python_executable, self.scripts_dir, _ = _find_executable_and_scripts(path) - self._env_path = path def install_requirements(self, requirements: Collection[str]) -> None: - import uv - - cmd = [uv.find_uv_bin(), 'pip'] + cmd = [self._uv_bin, 'pip'] if _ctx.verbosity > 1: # uv doesn't support doubling up -v unlike pip. cmd += ['-v'] From 7ddc5e3249ded1b04ace0c6b7b878c4b4cc0f470 Mon Sep 17 00:00:00 2001 From: layday Date: Fri, 8 Mar 2024 13:24:41 +0200 Subject: [PATCH 19/35] Warn when using uv with PyPy --- src/build/env.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/build/env.py b/src/build/env.py index 6227f257..6871e586 100644 --- a/src/build/env.py +++ b/src/build/env.py @@ -11,6 +11,7 @@ import sysconfig import tempfile import typing +import warnings from collections.abc import Collection, Mapping @@ -287,6 +288,9 @@ def create(self, path: str) -> None: raise RuntimeError(msg) self._uv_bin = uv_bin + if sys.implementation.name == 'pypy': + msg = 'uv does not officially support PyPY; things might break' + warnings.warn(msg, stacklevel=2) venv.EnvBuilder(symlinks=_fs_supports_symlink(), with_pip=False).create(path) self.python_executable, self.scripts_dir, _ = _find_executable_and_scripts(path) From 3cc7568e66f9c8d0eeb4c49d3aa2f34f50852b85 Mon Sep 17 00:00:00 2001 From: layday Date: Fri, 8 Mar 2024 13:24:59 +0200 Subject: [PATCH 20/35] Update tests --- tests/test_env.py | 31 ++++++++++++++++++++++++------- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/tests/test_env.py b/tests/test_env.py index 2d6b8ee4..62253b26 100644 --- a/tests/test_env.py +++ b/tests/test_env.py @@ -1,8 +1,8 @@ # SPDX-License-Identifier: MIT from __future__ import annotations +import contextlib import logging -import platform import subprocess import sys import sysconfig @@ -18,7 +18,7 @@ import build.env -IS_PYPY3 = platform.python_implementation() == 'PyPy' +IS_PYPY = sys.implementation.name == 'pypy' @pytest.mark.isolated @@ -30,7 +30,7 @@ def test_isolation(): subprocess.check_call([env.python_executable, '-c', f'{debug} import build.env']) -@pytest.mark.skipif(IS_PYPY3, reason='PyPy3 uses get path to create and provision venv') +@pytest.mark.skipif(IS_PYPY, reason='PyPy3 uses get path to create and provision venv') @pytest.mark.skipif(sys.platform != 'darwin', reason='workaround for Apple Python') def test_can_get_venv_paths_with_conflicting_default_scheme( mocker: pytest_mock.MockerFixture, @@ -192,6 +192,7 @@ def test_default_impl_install_cmd_well_formed( ] +@pytest.mark.skipif(IS_PYPY, reason='uv does not declare support for PyPy') def test_uv_impl_install_cmd_well_formed( mocker: pytest_mock.MockerFixture, ): @@ -209,7 +210,7 @@ def test_uv_impl_install_cmd_well_formed( @pytest.mark.usefixtures('local_pip') @pytest.mark.parametrize( - ('env_impl', 'resultant_impl_name', 'has_virtualenv'), + ('env_impl', 'env_impl_display_name', 'has_virtualenv'), [ (None, 'venv+pip', False), (None, 'virtualenv+pip', True), @@ -219,10 +220,15 @@ def test_uv_impl_install_cmd_well_formed( ) def test_env_creation( env_impl: build.env.EnvImpl | None, - resultant_impl_name: build.env._EnvImplBackend, + env_impl_display_name: str, ): - with build.env.DefaultIsolatedEnv(env_impl) as env: - assert env._env_impl_backend.name == resultant_impl_name + with ( + pytest.warns(match='uv does not officially support PyPY; things might break') + if IS_PYPY and env_impl == 'venv+uv' + else contextlib.nullcontext() + ): + with build.env.DefaultIsolatedEnv(env_impl) as env: + assert env._env_impl_backend.name == env_impl_display_name @pytest.mark.network @@ -234,3 +240,14 @@ def test_requirement_installation( ): with build.env.DefaultIsolatedEnv(env_impl) as env: env.install([f'test-flit @ {Path(package_test_flit).as_uri()}']) + + +@pytest.mark.skipif(IS_PYPY, reason='uv does not declare support for PyPy') +def test_uv_missing( + mocker: pytest_mock.MockerFixture, +): + mocker.patch('shutil.which', return_value=None) + + with pytest.raises(RuntimeError, match='uv executable missing'): + with build.env.DefaultIsolatedEnv('venv+uv'): + pass From 3ac6a95edd80e557408156b2c122701cd16d2f1b Mon Sep 17 00:00:00 2001 From: layday Date: Fri, 8 Mar 2024 13:38:39 +0200 Subject: [PATCH 21/35] Update markers for PyPy --- tests/test_env.py | 6 ++++-- tests/test_integration.py | 9 +++++---- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/tests/test_env.py b/tests/test_env.py index 62253b26..23ca186d 100644 --- a/tests/test_env.py +++ b/tests/test_env.py @@ -192,7 +192,7 @@ def test_default_impl_install_cmd_well_formed( ] -@pytest.mark.skipif(IS_PYPY, reason='uv does not declare support for PyPy') +@pytest.mark.skipif(IS_PYPY, reason='uv cannot find PyPy executable') def test_uv_impl_install_cmd_well_formed( mocker: pytest_mock.MockerFixture, ): @@ -238,11 +238,13 @@ def test_requirement_installation( package_test_flit: str, env_impl: build.env.EnvImpl | None, ): + if IS_PYPY and env_impl == 'venv+uv': + pytest.xfail('uv cannot find PyPy executable') + with build.env.DefaultIsolatedEnv(env_impl) as env: env.install([f'test-flit @ {Path(package_test_flit).as_uri()}']) -@pytest.mark.skipif(IS_PYPY, reason='uv does not declare support for PyPy') def test_uv_missing( mocker: pytest_mock.MockerFixture, ): diff --git a/tests/test_integration.py b/tests/test_integration.py index 70118c30..b4b5d398 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -3,7 +3,6 @@ import importlib.util import os import os.path -import platform import re import shutil import subprocess @@ -18,7 +17,7 @@ IS_WINDOWS = sys.platform.startswith('win') -IS_PYPY3 = platform.python_implementation() == 'PyPy' +IS_PYPY = sys.implementation.name == 'pypy' INTEGRATION_SOURCES = { @@ -93,10 +92,12 @@ def _ignore_folder(base, filenames): ], ) @pytest.mark.isolated -def test_build(monkeypatch, project, args, call, tmp_path): +def test_build(request, monkeypatch, project, args, call, tmp_path): + if args == ['--env-impl', 'venv+uv'] and IS_PYPY: + pytest.xfail('uv cannot find PyPy executable') if project in {'build', 'flit'} and '--no-isolation' in args: pytest.xfail(f"can't build {project} without isolation due to missing dependencies") - if project == 'Solaar' and IS_WINDOWS and IS_PYPY3: + if project == 'Solaar' and IS_WINDOWS and IS_PYPY: pytest.xfail('Solaar fails building wheels via sdists on Windows on PyPy 3') monkeypatch.chdir(tmp_path) From 619126be2a53f9e64bdc24f3e6bf0e0c862e15be Mon Sep 17 00:00:00 2001 From: layday Date: Fri, 8 Mar 2024 13:50:15 +0200 Subject: [PATCH 22/35] Fix coverage --- tests/test_env.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_env.py b/tests/test_env.py index 23ca186d..5c2ed194 100644 --- a/tests/test_env.py +++ b/tests/test_env.py @@ -78,7 +78,7 @@ def executable(self): class PartialEnv(build.env.IsolatedEnv): def make_extra_environ(self): - return super().make_extra_environ() + raise NotImplementedError with pytest.raises(TypeError): PartialEnv() From 430ad003a66bc0f48dad931c787ac90f4d576ade Mon Sep 17 00:00:00 2001 From: layday Date: Fri, 8 Mar 2024 14:26:54 +0200 Subject: [PATCH 23/35] Test with original `has_virtualenv` --- tests/test_env.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_env.py b/tests/test_env.py index 5c2ed194..f5f69933 100644 --- a/tests/test_env.py +++ b/tests/test_env.py @@ -214,6 +214,7 @@ def test_uv_impl_install_cmd_well_formed( [ (None, 'venv+pip', False), (None, 'virtualenv+pip', True), + (None, 'virtualenv+pip', None), # Fall-through ('venv+uv', 'venv+uv', None), ], indirect=('has_virtualenv',), From 0680ae7a6f232514729dfae1f3e513bb3b5afdfb Mon Sep 17 00:00:00 2001 From: layday Date: Fri, 8 Mar 2024 14:45:04 +0200 Subject: [PATCH 24/35] Bump minimum version of packaging 19.0 incorrectly interprets Python version constraints resulting in venv being used in pref to virtualenv when an outer pip is too old. --- pyproject.toml | 2 +- tests/constraints.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 1ba6544a..a6d2dbc6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,7 @@ urls.issues = "https://github.com/pypa/build/issues" urls.source = "https://github.com/pypa/build" dependencies = [ - "packaging >= 19.0", + "packaging >= 19.1", "pyproject_hooks", # not actually a runtime dependency, only supplied as there is not "recommended dependency" support 'colorama; os_name == "nt"', diff --git a/tests/constraints.txt b/tests/constraints.txt index 6fae7ac8..89272b73 100644 --- a/tests/constraints.txt +++ b/tests/constraints.txt @@ -1,5 +1,5 @@ importlib-metadata==4.6 -packaging==19.0 +packaging==19.1 pyproject_hooks==1.0 setuptools==42.0.0; python_version < "3.10" setuptools==56.0.0; python_version == "3.10" From 6844fb7f86f4a575d7c46fcea8eefb86c3f3da82 Mon Sep 17 00:00:00 2001 From: layday Date: Fri, 8 Mar 2024 18:29:07 +0200 Subject: [PATCH 25/35] Test pip and uv verbosity flags --- tests/test_env.py | 42 +++++++++++++++++++++++++++++------------- 1 file changed, 29 insertions(+), 13 deletions(-) diff --git a/tests/test_env.py b/tests/test_env.py index f5f69933..b389428e 100644 --- a/tests/test_env.py +++ b/tests/test_env.py @@ -170,40 +170,56 @@ def test_install_short_circuits( install_requirements.assert_called_once() +@pytest.mark.parametrize('verbosity', range(4)) @pytest.mark.usefixtures('local_pip') def test_default_impl_install_cmd_well_formed( mocker: pytest_mock.MockerFixture, + verbosity: int, ): + mocker.patch.object(build.env._ctx, 'verbosity', verbosity) + with build.env.DefaultIsolatedEnv() as env: run_subprocess = mocker.patch('build.env.run_subprocess') env.install(['some', 'requirements']) - run_subprocess.assert_called_once() - call_args = run_subprocess.call_args[0][0][:-1] - assert call_args == [ - env.python_executable, - '-Im', - 'pip', - 'install', - '--use-pep517', - '--no-warn-script-location', - '-r', - ] + run_subprocess.assert_called_once_with( + [ + env.python_executable, + '-Im', + 'pip', + *([f'-{"v" * (verbosity - 1)}'] if verbosity > 1 else []), + 'install', + '--use-pep517', + '--no-warn-script-location', + '-r', + mocker.ANY, + ] + ) +@pytest.mark.parametrize('verbosity', range(4)) @pytest.mark.skipif(IS_PYPY, reason='uv cannot find PyPy executable') def test_uv_impl_install_cmd_well_formed( mocker: pytest_mock.MockerFixture, + verbosity: int, ): + mocker.patch.object(build.env._ctx, 'verbosity', verbosity) + with build.env.DefaultIsolatedEnv('venv+uv') as env: run_subprocess = mocker.patch('build.env.run_subprocess') - env.install(['foo']) + env.install(['some', 'requirements']) (install_call,) = run_subprocess.call_args_list assert len(install_call.args) == 1 - assert install_call.args[0][1:] == ['pip', 'install', 'foo'] + assert install_call.args[0][1:] == [ + 'pip', + *(['-v'] if verbosity > 1 else []), + 'install', + 'some', + 'requirements', + ] assert len(install_call.kwargs) == 1 assert install_call.kwargs['env']['VIRTUAL_ENV'] == env.path From 7d3ecf69dd9303d14f31c49676ab44ad929dde43 Mon Sep 17 00:00:00 2001 From: layday Date: Fri, 8 Mar 2024 18:49:04 +0200 Subject: [PATCH 26/35] Simplify `test_pip_needs_upgrade_mac_os_11` --- tests/test_env.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/tests/test_env.py b/tests/test_env.py index b389428e..90859a8d 100644 --- a/tests/test_env.py +++ b/tests/test_env.py @@ -126,16 +126,18 @@ def test_pip_needs_upgrade_mac_os_11( mocker.patch('platform.mac_ver', return_value=('11.0', ('', '', ''), arch)) mocker.patch('build._compat.importlib.metadata.distributions', return_value=(SimpleNamespace(version=pip_version),)) - min_version = Version('20.3' if arch == 'x86_64' else '21.0.1') - with build.env.DefaultIsolatedEnv(): - if Version(pip_version) < min_version: - upgrade_call, uninstall_call = run_subprocess.call_args_list - answer = 'pip>=20.3.0' if arch == 'x86_64' else 'pip>=21.0.1' - assert upgrade_call[0][0][1:] == ['-Im', 'pip', 'install', answer] - assert uninstall_call[0][0][1:] == ['-Im', 'pip', 'uninstall', '-y', 'setuptools'] + min_pip_version = '20.3.0' if arch == 'x86_64' else '21.0.1' + + with build.env.DefaultIsolatedEnv() as env: + if Version(pip_version) < Version(min_pip_version): + assert run_subprocess.call_args_list == [ + mocker.call([env.python_executable, '-Im', 'pip', 'install', f'pip>={min_pip_version}']), + mocker.call([env.python_executable, '-Im', 'pip', 'uninstall', '-y', 'setuptools']), + ] else: - (uninstall_call,) = run_subprocess.call_args_list - assert uninstall_call[0][0][1:] == ['-Im', 'pip', 'uninstall', '-y', 'setuptools'] + run_subprocess.assert_called_once_with( + [env.python_executable, '-Im', 'pip', 'uninstall', '-y', 'setuptools'], + ) @pytest.mark.parametrize('has_symlink', [True, False] if sys.platform.startswith('win') else [True]) From e04d030106f743976de12a6601987940bcc2254f Mon Sep 17 00:00:00 2001 From: layday Date: Sat, 9 Mar 2024 01:02:52 +0200 Subject: [PATCH 27/35] Restore the uv extra, look for uv under `sys.prefix` --- pyproject.toml | 6 ++++-- src/build/env.py | 11 ++++++++++- tests/test_env.py | 2 ++ 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a6d2dbc6..969f1a5f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,7 +50,7 @@ docs = [ "sphinx-issues >= 3.0.0", ] test = [ - "build[virtualenv]", + "build[uv, virtualenv]", "filelock >= 3", "pytest >= 6.2.4", "pytest-cov >= 2.12", @@ -62,13 +62,15 @@ test = [ 'setuptools >= 56.0.0; python_version == "3.10"', 'setuptools >= 56.0.0; python_version == "3.11"', 'setuptools >= 67.8.0; python_version >= "3.12"', - "uv >= 0.1.15", ] typing = [ + "build[uv]", "importlib-metadata >= 5.1", "mypy ~= 1.5.0", "tomli", "typing-extensions >= 3.7.4.3", +] +uv = [ "uv >= 0.1.15", ] virtualenv = [ diff --git a/src/build/env.py b/src/build/env.py index 6871e586..419ffb04 100644 --- a/src/build/env.py +++ b/src/build/env.py @@ -282,10 +282,19 @@ def create(self, path: str) -> None: self._env_path = path - uv_bin = shutil.which('uv') + # ``uv.find_uv_bin`` will look for uv in the user prefix if it can't + # find it under ``sys.prefix``, essentially potentially rearranging + # the user's $PATH. We'll only look for uv under the prefix of + # the running interpreter for unactivated venvs then defer to $PATH. + uv_bin = shutil.which('uv', path=sysconfig.get_path('scripts')) + + if not uv_bin: + uv_bin = shutil.which('uv') + if not uv_bin: msg = 'uv executable missing' raise RuntimeError(msg) + self._uv_bin = uv_bin if sys.implementation.name == 'pypy': diff --git a/tests/test_env.py b/tests/test_env.py index 90859a8d..283add9f 100644 --- a/tests/test_env.py +++ b/tests/test_env.py @@ -6,6 +6,7 @@ import subprocess import sys import sysconfig +import typing from pathlib import Path from types import SimpleNamespace @@ -64,6 +65,7 @@ def test_venv_executable_missing_post_creation( assert venv_create.call_count == 1 +@typing.no_type_check def test_isolated_env_abstract(): with pytest.raises(TypeError): build.env.IsolatedEnv() From 49d137974f8cc25efe40494d73dedd64acc5486c Mon Sep 17 00:00:00 2001 From: layday Date: Sat, 9 Mar 2024 10:43:43 +0200 Subject: [PATCH 28/35] Fix spelling of 'PyPy' --- src/build/env.py | 2 +- tests/test_env.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/build/env.py b/src/build/env.py index 419ffb04..199d5230 100644 --- a/src/build/env.py +++ b/src/build/env.py @@ -298,7 +298,7 @@ def create(self, path: str) -> None: self._uv_bin = uv_bin if sys.implementation.name == 'pypy': - msg = 'uv does not officially support PyPY; things might break' + msg = 'uv does not officially support PyPy; things might break' warnings.warn(msg, stacklevel=2) venv.EnvBuilder(symlinks=_fs_supports_symlink(), with_pip=False).create(path) diff --git a/tests/test_env.py b/tests/test_env.py index 283add9f..e0dceb94 100644 --- a/tests/test_env.py +++ b/tests/test_env.py @@ -244,7 +244,7 @@ def test_env_creation( env_impl_display_name: str, ): with ( - pytest.warns(match='uv does not officially support PyPY; things might break') + pytest.warns(match='uv does not officially support PyPy; things might break') if IS_PYPY and env_impl == 'venv+uv' else contextlib.nullcontext() ): From bf9681f64fe0d14de44bdf3c84a181c7dd5ae0ce Mon Sep 17 00:00:00 2001 From: layday Date: Sat, 9 Mar 2024 16:48:25 +0200 Subject: [PATCH 29/35] Rm PyPy support warning --- src/build/env.py | 5 ----- tests/test_env.py | 10 ++-------- 2 files changed, 2 insertions(+), 13 deletions(-) diff --git a/src/build/env.py b/src/build/env.py index 199d5230..345191b7 100644 --- a/src/build/env.py +++ b/src/build/env.py @@ -11,7 +11,6 @@ import sysconfig import tempfile import typing -import warnings from collections.abc import Collection, Mapping @@ -297,10 +296,6 @@ def create(self, path: str) -> None: self._uv_bin = uv_bin - if sys.implementation.name == 'pypy': - msg = 'uv does not officially support PyPy; things might break' - warnings.warn(msg, stacklevel=2) - venv.EnvBuilder(symlinks=_fs_supports_symlink(), with_pip=False).create(path) self.python_executable, self.scripts_dir, _ = _find_executable_and_scripts(path) diff --git a/tests/test_env.py b/tests/test_env.py index e0dceb94..9ed9732f 100644 --- a/tests/test_env.py +++ b/tests/test_env.py @@ -1,7 +1,6 @@ # SPDX-License-Identifier: MIT from __future__ import annotations -import contextlib import logging import subprocess import sys @@ -243,13 +242,8 @@ def test_env_creation( env_impl: build.env.EnvImpl | None, env_impl_display_name: str, ): - with ( - pytest.warns(match='uv does not officially support PyPy; things might break') - if IS_PYPY and env_impl == 'venv+uv' - else contextlib.nullcontext() - ): - with build.env.DefaultIsolatedEnv(env_impl) as env: - assert env._env_impl_backend.name == env_impl_display_name + with build.env.DefaultIsolatedEnv(env_impl) as env: + assert env._env_impl_backend.name == env_impl_display_name @pytest.mark.network From 468350e5f07a05ecb1dff93b13b17d95b2ebf600 Mon Sep 17 00:00:00 2001 From: layday Date: Sat, 9 Mar 2024 16:49:09 +0200 Subject: [PATCH 30/35] Fix xfailing test, only xfail on win --- tests/test_env.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/test_env.py b/tests/test_env.py index 9ed9732f..d4bb3d61 100644 --- a/tests/test_env.py +++ b/tests/test_env.py @@ -19,6 +19,7 @@ IS_PYPY = sys.implementation.name == 'pypy' +IS_WINDOWS = sys.platform.startswith('win') @pytest.mark.isolated @@ -248,14 +249,17 @@ def test_env_creation( @pytest.mark.network @pytest.mark.usefixtures('local_pip') -@pytest.mark.parametrize('env_impl', [None, *build.env.ENV_IMPLS]) +@pytest.mark.parametrize( + 'env_impl', + [ + None, + pytest.param('venv+uv', marks=pytest.mark.xfail(IS_PYPY and IS_WINDOWS, reason='uv cannot find PyPy executable')), + ], +) def test_requirement_installation( package_test_flit: str, env_impl: build.env.EnvImpl | None, ): - if IS_PYPY and env_impl == 'venv+uv': - pytest.xfail('uv cannot find PyPy executable') - with build.env.DefaultIsolatedEnv(env_impl) as env: env.install([f'test-flit @ {Path(package_test_flit).as_uri()}']) From deabd67c335c313a679464d46c8b84214dcbe332 Mon Sep 17 00:00:00 2001 From: layday Date: Sat, 9 Mar 2024 23:37:28 +0200 Subject: [PATCH 31/35] Rename `env_impl` param to `installer` --- src/build/__main__.py | 27 ++++++++++++------------ src/build/env.py | 43 +++++++++++++++++++-------------------- tests/conftest.py | 4 ++-- tests/test_env.py | 34 +++++++++++++++---------------- tests/test_integration.py | 6 +++--- tests/test_main.py | 4 ++-- 6 files changed, 58 insertions(+), 60 deletions(-) diff --git a/src/build/__main__.py b/src/build/__main__.py index 86537856..ebbe6b83 100644 --- a/src/build/__main__.py +++ b/src/build/__main__.py @@ -125,9 +125,9 @@ def _build_in_isolated_env( outdir: StrPath, distribution: Distribution, config_settings: ConfigSettings | None, - env_impl: _env.EnvImpl | None, + installer: _env.Installer, ) -> str: - with DefaultIsolatedEnv(env_impl) as env: + with DefaultIsolatedEnv(installer=installer) as env: builder = ProjectBuilder.from_isolated_env(env, srcdir) # first install the build dependencies env.install(builder.build_system_requires) @@ -162,10 +162,10 @@ def _build( distribution: Distribution, config_settings: ConfigSettings | None, skip_dependency_check: bool, - env_impl: _env.EnvImpl | None, + installer: _env.Installer, ) -> str: if isolation: - return _build_in_isolated_env(srcdir, outdir, distribution, config_settings, env_impl) + return _build_in_isolated_env(srcdir, outdir, distribution, config_settings, installer) else: return _build_in_current_env(srcdir, outdir, distribution, config_settings, skip_dependency_check) @@ -219,7 +219,7 @@ def build_package( config_settings: ConfigSettings | None = None, isolation: bool = True, skip_dependency_check: bool = False, - env_impl: _env.EnvImpl | None = None, + installer: _env.Installer = 'pip', ) -> Sequence[str]: """ Run the build process. @@ -233,7 +233,7 @@ def build_package( """ built: list[str] = [] for distribution in distributions: - out = _build(isolation, srcdir, outdir, distribution, config_settings, skip_dependency_check, env_impl) + out = _build(isolation, srcdir, outdir, distribution, config_settings, skip_dependency_check, installer) built.append(os.path.basename(out)) return built @@ -245,7 +245,7 @@ def build_package_via_sdist( config_settings: ConfigSettings | None = None, isolation: bool = True, skip_dependency_check: bool = False, - env_impl: _env.EnvImpl | None = None, + installer: _env.Installer = 'pip', ) -> Sequence[str]: """ Build a sdist and then the specified distributions from it. @@ -263,7 +263,7 @@ def build_package_via_sdist( msg = 'Only binary distributions are allowed but sdist was specified' raise ValueError(msg) - sdist = _build(isolation, srcdir, outdir, 'sdist', config_settings, skip_dependency_check, env_impl) + sdist = _build(isolation, srcdir, outdir, 'sdist', config_settings, skip_dependency_check, installer) sdist_name = os.path.basename(sdist) sdist_out = tempfile.mkdtemp(prefix='build-via-sdist-') @@ -276,7 +276,7 @@ def build_package_via_sdist( _ctx.log(f'Building {_natural_language_list(distributions)} from sdist') srcdir = os.path.join(sdist_out, sdist_name[: -len('.tar.gz')]) for distribution in distributions: - out = _build(isolation, srcdir, outdir, distribution, config_settings, skip_dependency_check, env_impl) + out = _build(isolation, srcdir, outdir, distribution, config_settings, skip_dependency_check, installer) built.append(os.path.basename(out)) finally: shutil.rmtree(sdist_out, ignore_errors=True) @@ -369,10 +369,9 @@ def main_parser() -> argparse.ArgumentParser: 'Build dependencies must be installed separately when this option is used', ) env_group.add_argument( - '--env-impl', - choices=_env.ENV_IMPLS, - help='isolated environment implementation to use. Defaults to virtualenv if installed, ' - ' otherwise venv. uv support is experimental.', + '--installer', + choices=_env.INSTALLERS, + help='Python package installer to use (defaults to pip)', ) parser.add_argument( '--config-setting', @@ -432,7 +431,7 @@ def main(cli_args: Sequence[str], prog: str | None = None) -> None: config_settings, not args.no_isolation, args.skip_dependency_check, - args.env_impl, + args.installer, ) artifact_list = _natural_language_list( ['{underline}{}{reset}{bold}{green}'.format(artifact, **_styles.get()) for artifact in built] diff --git a/src/build/env.py b/src/build/env.py index 345191b7..069d6f9c 100644 --- a/src/build/env.py +++ b/src/build/env.py @@ -20,11 +20,9 @@ from ._util import check_dependency -EnvImpl = typing.Literal['venv+uv'] +Installer = typing.Literal['pip', 'uv'] -ENV_IMPLS = typing.get_args(EnvImpl) - -_EnvImplInclDefault = typing.Literal['venv+pip', 'virtualenv+pip', EnvImpl] +INSTALLERS = typing.get_args(Installer) class IsolatedEnv(typing.Protocol): @@ -67,9 +65,10 @@ class DefaultIsolatedEnv(IsolatedEnv): def __init__( self, - env_impl: EnvImpl | None = None, + *, + installer: Installer = 'pip', ) -> None: - self.env_impl = env_impl + self.installer: Installer = installer def __enter__(self) -> DefaultIsolatedEnv: try: @@ -82,16 +81,16 @@ def __enter__(self) -> DefaultIsolatedEnv: path = os.path.realpath(path) self._path = path - self._env_impl_backend: _EnvImplBackend + self._env_backend: _EnvBackend # uv is opt-in only. - if self.env_impl == 'venv+uv': - self._env_impl_backend = _UvImplBackend() + if self.installer == 'uv': + self._env_backend = _UvBackend() else: - self._env_impl_backend = _DefaultImplBackend() + self._env_backend = _PipBackend() - _ctx.log(f'Creating isolated environment: {self._env_impl_backend.name}...') - self._env_impl_backend.create(self._path) + _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()) @@ -111,14 +110,14 @@ def path(self) -> str: @property def python_executable(self) -> str: """The python executable of the isolated build environment.""" - return self._env_impl_backend.python_executable + 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_impl_backend.scripts_dir, path]) + 'PATH': os.pathsep.join([self._env_backend.scripts_dir, path]) if path is not None - else self._env_impl_backend.scripts_dir + else self._env_backend.scripts_dir } def install(self, requirements: Collection[str]) -> None: @@ -134,10 +133,10 @@ def install(self, requirements: Collection[str]) -> None: return _ctx.log('Installing packages in isolated environment:\n' + '\n'.join(f'- {r}' for r in sorted(requirements))) - self._env_impl_backend.install_requirements(requirements) + self._env_backend.install_requirements(requirements) -class _EnvImplBackend(typing.Protocol): # pragma: no cover +class _EnvBackend(typing.Protocol): # pragma: no cover python_executable: str scripts_dir: str @@ -148,11 +147,11 @@ def install_requirements(self, requirements: Collection[str]) -> None: ... @property - def name(self) -> _EnvImplInclDefault: + def display_name(self) -> str: ... -class _DefaultImplBackend(_EnvImplBackend): +class _PipBackend(_EnvBackend): def __init__(self) -> None: self._create_with_virtualenv = not self._has_valid_outer_pip and self._has_virtualenv @@ -271,11 +270,11 @@ def install_requirements(self, requirements: Collection[str]) -> None: os.unlink(req_file.name) @property - def name(self) -> _EnvImplInclDefault: + def display_name(self) -> str: return 'virtualenv+pip' if self._create_with_virtualenv else 'venv+pip' -class _UvImplBackend(_EnvImplBackend): +class _UvBackend(_EnvBackend): def create(self, path: str) -> None: import venv @@ -307,7 +306,7 @@ def install_requirements(self, requirements: Collection[str]) -> None: run_subprocess([*cmd, 'install', *requirements], env={**os.environ, 'VIRTUAL_ENV': self._env_path}) @property - def name(self) -> EnvImpl: + def display_name(self) -> str: return 'venv+uv' diff --git a/tests/conftest.py b/tests/conftest.py index d340424f..4302416d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -75,13 +75,13 @@ def pytest_runtest_call(item: pytest.Item): @pytest.fixture def local_pip(monkeypatch): - monkeypatch.setattr(build.env._DefaultImplBackend, '_has_valid_outer_pip', None) + monkeypatch.setattr(build.env._PipBackend, '_has_valid_outer_pip', None) @pytest.fixture(autouse=True, params=[False]) def has_virtualenv(request, monkeypatch): if request.param is not None: - monkeypatch.setattr(build.env._DefaultImplBackend, '_has_virtualenv', request.param) + monkeypatch.setattr(build.env._PipBackend, '_has_virtualenv', request.param) @pytest.fixture(scope='session', autouse=True) diff --git a/tests/test_env.py b/tests/test_env.py index d4bb3d61..c3cb374a 100644 --- a/tests/test_env.py +++ b/tests/test_env.py @@ -165,7 +165,7 @@ def test_install_short_circuits( mocker: pytest_mock.MockerFixture, ): with build.env.DefaultIsolatedEnv() as env: - install_requirements = mocker.patch.object(env._env_impl_backend, 'install_requirements') + install_requirements = mocker.patch.object(env._env_backend, 'install_requirements') env.install([]) install_requirements.assert_not_called() @@ -210,7 +210,7 @@ def test_uv_impl_install_cmd_well_formed( ): mocker.patch.object(build.env._ctx, 'verbosity', verbosity) - with build.env.DefaultIsolatedEnv('venv+uv') as env: + with build.env.DefaultIsolatedEnv(installer='uv') as env: run_subprocess = mocker.patch('build.env.run_subprocess') env.install(['some', 'requirements']) @@ -230,37 +230,37 @@ def test_uv_impl_install_cmd_well_formed( @pytest.mark.usefixtures('local_pip') @pytest.mark.parametrize( - ('env_impl', 'env_impl_display_name', 'has_virtualenv'), + ('installer', 'env_backend_display_name', 'has_virtualenv'), [ - (None, 'venv+pip', False), - (None, 'virtualenv+pip', True), - (None, 'virtualenv+pip', None), # Fall-through - ('venv+uv', 'venv+uv', None), + ('pip', 'venv+pip', False), + ('pip', 'virtualenv+pip', True), + ('pip', 'virtualenv+pip', None), # Fall-through + ('uv', 'venv+uv', None), ], indirect=('has_virtualenv',), ) -def test_env_creation( - env_impl: build.env.EnvImpl | None, - env_impl_display_name: str, +def test_venv_creation( + installer: build.env.Installer, + env_backend_display_name: str, ): - with build.env.DefaultIsolatedEnv(env_impl) as env: - assert env._env_impl_backend.name == env_impl_display_name + with build.env.DefaultIsolatedEnv(installer=installer) as env: + assert env._env_backend.display_name == env_backend_display_name @pytest.mark.network @pytest.mark.usefixtures('local_pip') @pytest.mark.parametrize( - 'env_impl', + 'installer', [ None, - pytest.param('venv+uv', marks=pytest.mark.xfail(IS_PYPY and IS_WINDOWS, reason='uv cannot find PyPy executable')), + pytest.param('uv', marks=pytest.mark.xfail(IS_PYPY and IS_WINDOWS, reason='uv cannot find PyPy executable')), ], ) def test_requirement_installation( package_test_flit: str, - env_impl: build.env.EnvImpl | None, + installer: build.env.Installer, ): - with build.env.DefaultIsolatedEnv(env_impl) as env: + with build.env.DefaultIsolatedEnv(installer=installer) as env: env.install([f'test-flit @ {Path(package_test_flit).as_uri()}']) @@ -270,5 +270,5 @@ def test_uv_missing( mocker.patch('shutil.which', return_value=None) with pytest.raises(RuntimeError, match='uv executable missing'): - with build.env.DefaultIsolatedEnv('venv+uv'): + with build.env.DefaultIsolatedEnv(installer='uv'): pass diff --git a/tests/test_integration.py b/tests/test_integration.py index b4b5d398..341c1d49 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -78,8 +78,8 @@ def _ignore_folder(base, filenames): ) @pytest.mark.parametrize( 'args', - [[], ['--env-impl', 'venv+uv'], ['-x', '--no-isolation']], - ids=['isolated', 'isolated_venv+uv', 'no_isolation'], + [[], ['--installer', 'uv'], ['-x', '--no-isolation']], + ids=['isolated_pip', 'isolated_uv', 'no_isolation'], ) @pytest.mark.parametrize( 'project', @@ -93,7 +93,7 @@ def _ignore_folder(base, filenames): ) @pytest.mark.isolated def test_build(request, monkeypatch, project, args, call, tmp_path): - if args == ['--env-impl', 'venv+uv'] and IS_PYPY: + if args == ['--installer', 'uv'] and IS_WINDOWS and IS_PYPY: pytest.xfail('uv cannot find PyPy executable') if project in {'build', 'flit'} and '--no-isolation' in args: pytest.xfail(f"can't build {project} without isolation due to missing dependencies") diff --git a/tests/test_main.py b/tests/test_main.py index dff0d129..7b17efeb 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -74,8 +74,8 @@ 'build_package_via_sdist', ), ( - ['--env-impl', 'venv+uv'], - [cwd, out, ['wheel'], {}, True, False, 'venv+uv'], + ['--installer', 'uv'], + [cwd, out, ['wheel'], {}, True, False, 'uv'], 'build_package_via_sdist', ), ( From 376607935ada30763f95fc2c9cda893e5bfffc96 Mon Sep 17 00:00:00 2001 From: layday Date: Sat, 9 Mar 2024 23:48:32 +0200 Subject: [PATCH 32/35] Defer to `uv.find_uv_bin` --- src/build/env.py | 24 +++++------------------- tests/test_env.py | 10 ---------- 2 files changed, 5 insertions(+), 29 deletions(-) diff --git a/src/build/env.py b/src/build/env.py index 069d6f9c..92fcfdea 100644 --- a/src/build/env.py +++ b/src/build/env.py @@ -279,27 +279,13 @@ def create(self, path: str) -> None: import venv self._env_path = path - - # ``uv.find_uv_bin`` will look for uv in the user prefix if it can't - # find it under ``sys.prefix``, essentially potentially rearranging - # the user's $PATH. We'll only look for uv under the prefix of - # the running interpreter for unactivated venvs then defer to $PATH. - uv_bin = shutil.which('uv', path=sysconfig.get_path('scripts')) - - if not uv_bin: - uv_bin = shutil.which('uv') - - if not uv_bin: - msg = 'uv executable missing' - raise RuntimeError(msg) - - self._uv_bin = uv_bin - - venv.EnvBuilder(symlinks=_fs_supports_symlink(), with_pip=False).create(path) - self.python_executable, self.scripts_dir, _ = _find_executable_and_scripts(path) + 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'] + import uv + + cmd = [uv.find_uv_bin(), 'pip'] if _ctx.verbosity > 1: # uv doesn't support doubling up -v unlike pip. cmd += ['-v'] diff --git a/tests/test_env.py b/tests/test_env.py index c3cb374a..5c27ca10 100644 --- a/tests/test_env.py +++ b/tests/test_env.py @@ -262,13 +262,3 @@ def test_requirement_installation( ): with build.env.DefaultIsolatedEnv(installer=installer) as env: env.install([f'test-flit @ {Path(package_test_flit).as_uri()}']) - - -def test_uv_missing( - mocker: pytest_mock.MockerFixture, -): - mocker.patch('shutil.which', return_value=None) - - with pytest.raises(RuntimeError, match='uv executable missing'): - with build.env.DefaultIsolatedEnv(installer='uv'): - pass From 1408af42ae90f2760bb7ebbbe771fe9bc1ec749e Mon Sep 17 00:00:00 2001 From: layday Date: Sat, 9 Mar 2024 23:51:03 +0200 Subject: [PATCH 33/35] Fix test param --- tests/test_env.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_env.py b/tests/test_env.py index 5c27ca10..54f2339e 100644 --- a/tests/test_env.py +++ b/tests/test_env.py @@ -252,7 +252,7 @@ def test_venv_creation( @pytest.mark.parametrize( 'installer', [ - None, + 'pip', pytest.param('uv', marks=pytest.mark.xfail(IS_PYPY and IS_WINDOWS, reason='uv cannot find PyPy executable')), ], ) From c5040f4d28b3555fd2ad57f31ed6adfb6284b9f8 Mon Sep 17 00:00:00 2001 From: layday Date: Sun, 10 Mar 2024 11:07:16 +0200 Subject: [PATCH 34/35] Restore uv `$PATH` lookup with logging message --- src/build/env.py | 17 ++++++++++++++--- tests/test_env.py | 26 ++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/src/build/env.py b/src/build/env.py index 92fcfdea..23b8ed7c 100644 --- a/src/build/env.py +++ b/src/build/env.py @@ -279,13 +279,24 @@ 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): + self._uv_bin = shutil.which('uv') + if self._uv_bin is None: + msg = 'uv executable not found' + raise RuntimeError(msg) from None + + _ctx.log(f'Using external uv from {self._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: - import uv - - cmd = [uv.find_uv_bin(), 'pip'] + cmd = [self._uv_bin, 'pip'] if _ctx.verbosity > 1: # uv doesn't support doubling up -v unlike pip. cmd += ['-v'] diff --git a/tests/test_env.py b/tests/test_env.py index 54f2339e..7bfd8c3c 100644 --- a/tests/test_env.py +++ b/tests/test_env.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +import shutil import subprocess import sys import sysconfig @@ -262,3 +263,28 @@ def test_requirement_installation( ): with build.env.DefaultIsolatedEnv(installer=installer) as env: env.install([f'test-flit @ {Path(package_test_flit).as_uri()}']) + + +def test_external_uv_detection_success( + caplog: pytest.LogCaptureFixture, + mocker: pytest_mock.MockerFixture, +): + mocker.patch.dict(sys.modules, {'uv': None}) + + with build.env.DefaultIsolatedEnv(installer='uv'): + pass + + assert any( + r.message == f'Using external uv from {shutil.which("uv", path=sysconfig.get_path("scripts"))}' for r in caplog.records + ) + + +def test_external_uv_detection_failure( + mocker: pytest_mock.MockerFixture, +): + mocker.patch.dict(sys.modules, {'uv': None}) + mocker.patch('shutil.which', return_value=None) + + with pytest.raises(RuntimeError, match='uv executable not found'): + with build.env.DefaultIsolatedEnv(installer='uv'): + pass From a27bb8566a6f7cac247d7132b3a89f2dc325e435 Mon Sep 17 00:00:00 2001 From: layday Date: Sun, 10 Mar 2024 11:18:58 +0200 Subject: [PATCH 35/35] Help mypy narrow --- src/build/env.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/build/env.py b/src/build/env.py index 23b8ed7c..348162e3 100644 --- a/src/build/env.py +++ b/src/build/env.py @@ -285,12 +285,13 @@ def create(self, path: str) -> None: self._uv_bin = uv.find_uv_bin() except (ModuleNotFoundError, FileNotFoundError): - self._uv_bin = shutil.which('uv') - if self._uv_bin is None: + 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 {self._uv_bin}') + _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)