Repository: pypa/build
Pull request #751 (2024-03-05 21:41:30)

env: add support for uv

layday: It looks like `uv venv` doesn't work with PyPy on Windows.
gaborbernat: Yeah https://github.com/astral-sh/uv/issues/2096, there's no official support for PyPy today, and what works, it works by chance.
webknjaz: @layday have you considered exposing an API for pluggable build env provisioners?
webknjaz: > Can you go into more detail?

I mean, it'd be nice if third parties could stick their bits of build env provisioning logic through some standardized interface. This could be useful for various downstreams and probably other tools that act as frontends.
layday: Isolated envs are already easily pluggable in code (see the `IsolatedEnv` protocol and `ProjectBuilder.from_isolated_env`), but I don't see us wanting to go down the path of exposing a plug-in interface for isolated env selection through the CLI.
layday: So, this adds an `--env-impl` flag to the CLI, with the only accepted value being `venv+uv`. Old semantics apply if omitted.

I've modified the venv selection order slightly so that venv is preferred whenever a valid outer pip is present for a minor speed boost.  `virtualenv` continues to be preferred with an outdated pip.

The `DefaultIsolatedEnv` is now tested with all venv and installer combos (`virtualenv` used to be mocked). This revealed the `venv` module was preferred with `packaging` 19.0 even when a valid `virtualenv` was present.  The culprit was a marker evaluation bug in `packaging` which was fixed in 19.1.

Finally, I've added a setuptools existence check before attempting to uninstall the ensurepip copy of setuptools - it was dropped in Python 3.12.  Again, this should make using venv without an outer pip slightly faster.
layday: Currently, we're only looking for a `uv` executable on `$PATH` (not attempting an import), but see the discussion happening at https://github.com/wntrblm/nox/pull/795#discussion_r1517325469.

Patch:
diff --git a/pyproject.toml b/pyproject.toml
index 0b58724..969f1a5 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"',
@@ -50,6 +50,7 @@ docs = [
   "sphinx-issues >= 3.0.0",
 ]
 test = [
+  "build[uv, virtualenv]",
   "filelock >= 3",
   "pytest >= 6.2.4",
   "pytest-cov >= 2.12",
@@ -63,11 +64,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",
 ]
diff --git a/src/build/__main__.py b/src/build/__main__.py
index b8cf170..ebbe6b8 100644
--- a/src/build/__main__.py
+++ b/src/build/__main__.py
@@ -22,6 +22,7 @@ from typing import NoReturn, TextIO
 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,
+    installer: _env.Installer,
 ) -> str:
-    with DefaultIsolatedEnv() 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)
@@ -160,9 +162,10 @@ def _build(
     distribution: Distribution,
     config_settings: ConfigSettings | None,
     skip_dependency_check: bool,
+    installer: _env.Installer,
 ) -> str:
     if isolation:
-        return _build_in_isolated_env(srcdir, outdir, distribution, config_settings)
+        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)
 
@@ -216,6 +219,7 @@ def build_package(
     config_settings: ConfigSettings | None = None,
     isolation: bool = True,
     skip_dependency_check: bool = False,
+    installer: _env.Installer = 'pip',
 ) -> 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, installer)
         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,
+    installer: _env.Installer = 'pip',
 ) -> 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, installer)
 
     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, installer)
                     built.append(os.path.basename(out))
             finally:
                 shutil.rmtree(sdist_out, ignore_errors=True)
@@ -329,13 +334,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(
@@ -351,16 +360,23 @@ 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(
+        '--installer',
+        choices=_env.INSTALLERS,
+        help='Python package installer to use (defaults to pip)',
+    )
     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 '
@@ -384,11 +400,10 @@ 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:
-        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
@@ -398,14 +413,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:
@@ -414,7 +425,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.installer,
         )
         artifact_list = _natural_language_list(
             ['{underline}{}{reset}{bold}{green}'.format(artifact, **_styles.get()) for artifact in built]
diff --git a/src/build/_ctx.py b/src/build/_ctx.py
index fae1bd14..a60c2ff 100644
--- a/src/build/_ctx.py
+++ b/src/build/_ctx.py
@@ -5,7 +5,7 @@ import logging
 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 run_subprocess(cmd: Sequence[StrPath]) -> 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 run_subprocess(cmd: Sequence[StrPath]) -> 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
diff --git a/src/build/env.py b/src/build/env.py
index 6cf47c1..348162e 100644
--- a/src/build/env.py
+++ b/src/build/env.py
@@ -11,7 +11,6 @@ import sys
 import sysconfig
 import tempfile
 import typing
-import warnings
 
 from collections.abc import Collection, Mapping
 
@@ -21,6 +20,11 @@ from ._exceptions import FailedProcessError
 from ._util import check_dependency
 
 
+Installer = typing.Literal['pip', 'uv']
+
+INSTALLERS = typing.get_args(Installer)
+
+
 class IsolatedEnv(typing.Protocol):
     """Isolated build environment ABC."""
 
@@ -34,89 +38,60 @@ class IsolatedEnv(typing.Protocol):
         """Generate additional env vars specific to the isolated environment."""
 
 
-@functools.lru_cache(maxsize=None)
-def _should_use_virtualenv() -> bool:
-    import packaging.requirements
-
-    # virtualenv might be incompatible if it was installed separately
-    # from build. This verifies that virtualenv and all of its
-    # dependencies are installed as specified by build.
-    return 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
-    )
-
-
-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'
-
-    # 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_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.
     """
-
-    import packaging.version
+    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
 
-    current_pip_version = packaging.version.Version(pip_distribution.version)
+    if minimum_version_str is None:
+        return True
 
-    return current_pip_version >= packaging.version.Version(__version or _minimum_pip_version())
+    return Version(distribution.version) >= Version(minimum_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,
+        *,
+        installer: Installer = 'pip',
+    ) -> None:
+        self.installer: Installer = installer
 
     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
+
+            self._env_backend: _EnvBackend
+
+            # uv is opt-in only.
+            if self.installer == 'uv':
+                self._env_backend = _UvBackend()
             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)
+                self._env_backend = _PipBackend()
+
+            _ctx.log(f'Creating isolated environment: {self._env_backend.display_name}...')
+            self._env_backend.create(self._path)
+
         except Exception:  # cleanup folder if creation fails
             self.__exit__(*sys.exc_info())
             raise
@@ -135,11 +110,15 @@ class DefaultIsolatedEnv(IsolatedEnv):
     @property
     def python_executable(self) -> str:
         """The python executable of the isolated build environment."""
-        return self._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._scripts_dir, path]) if path is not None else self._scripts_dir}
+        return {
+            'PATH': os.pathsep.join([self._env_backend.scripts_dir, path])
+            if path is not None
+            else self._env_backend.scripts_dir
+        }
 
     def install(self, requirements: Collection[str]) -> None:
         """
@@ -154,14 +133,123 @@ class DefaultIsolatedEnv(IsolatedEnv):
             return
 
         _ctx.log('Installing packages in isolated environment:\n' + '\n'.join(f'- {r}' for r in sorted(requirements)))
+        self._env_backend.install_requirements(requirements)
+
+
+class _EnvBackend(typing.Protocol):  # pragma: no cover
+    python_executable: str
+    scripts_dir: str
+
+    def create(self, path: str) -> None:
+        ...
+
+    def install_requirements(self, requirements: Collection[str]) -> None:
+        ...
+
+    @property
+    def display_name(self) -> str:
+        ...
+
 
+class _PipBackend(_EnvBackend):
+    def __init__(self) -> None:
+        self._create_with_virtualenv = not self._has_valid_outer_pip and self._has_virtualenv
+
+    @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.
+        """
+        # 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
-        # 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']
@@ -177,35 +265,54 @@ class DefaultIsolatedEnv(IsolatedEnv):
                 os.path.abspath(req_file.name),
             ]
             run_subprocess(cmd)
+
         finally:
             os.unlink(req_file.name)
 
+    @property
+    def display_name(self) -> str:
+        return 'virtualenv+pip' if self._create_with_virtualenv else 'venv+pip'
 
-def _create_isolated_env_virtualenv(path: str) -> tuple[str, str]:
-    """
-    We optionally can use the virtualenv package to provision a virtual environment.
 
-    :param path: The path where to create the isolated build environment
-    :return: The Python executable and script folder
-    """
-    import virtualenv
+class _UvBackend(_EnvBackend):
+    def create(self, path: str) -> None:
+        import venv
 
-    if _valid_global_pip():
-        cmd = [path, '--no-seed', '--activators', '']
-    else:
-        cmd = [path, '--no-setuptools', '--no-wheel', '--activators', '']
+        self._env_path = path
+
+        try:
+            import uv
+
+            self._uv_bin = uv.find_uv_bin()
+        except (ModuleNotFoundError, FileNotFoundError):
+            uv_bin = shutil.which('uv')
+            if uv_bin is None:
+                msg = 'uv executable not found'
+                raise RuntimeError(msg) from None
+
+            _ctx.log(f'Using external uv from {uv_bin}')
+            self._uv_bin = uv_bin
 
-    result = virtualenv.cli_run(cmd, setup_logging=False)
-    executable = str(result.creator.exe)
-    script_dir = str(result.creator.script_dir)
-    return executable, script_dir
+        venv.EnvBuilder(symlinks=_fs_supports_symlink(), with_pip=False).create(self._env_path)
+        self.python_executable, self.scripts_dir, _ = _find_executable_and_scripts(self._env_path)
+
+    def install_requirements(self, requirements: Collection[str]) -> None:
+        cmd = [self._uv_bin, 'pip']
+        if _ctx.verbosity > 1:
+            # uv doesn't support doubling up -v unlike pip.
+            cmd += ['-v']
+        run_subprocess([*cmd, 'install', *requirements], env={**os.environ, 'VIRTUAL_ENV': self._env_path})
+
+    @property
+    def display_name(self) -> str:
+        return 'venv+uv'
 
 
 @functools.lru_cache(maxsize=None)
 def _fs_supports_symlink() -> bool:
     """Return True if symlinks are supported"""
     # Using definition used by venv.main()
-    if not sys.platform.startswith('win'):
+    if os.name != 'nt':
         return True
 
     # Windows may support symlinks (setting in Windows 10)
@@ -219,38 +326,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,7 +360,8 @@ 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')
+
+    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)


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index 7076bdc..4302416 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, '_valid_global_pip', lambda: 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._PipBackend, '_has_virtualenv', request.param)
 
 
 @pytest.fixture(scope='session', autouse=True)
@@ -127,11 +133,6 @@ def tmp_dir():
     shutil.rmtree(path)
 
 
-@pytest.fixture(autouse=True)
-def force_venv(mocker):
-    mocker.patch.object(build.env, '_should_use_virtualenv', lambda: False)
-
-
 def pytest_report_header() -> str:
     interesting_packages = [
         'build',
diff --git a/tests/constraints.txt b/tests/constraints.txt
index 36446db..89272b7 100644
--- a/tests/constraints.txt
+++ b/tests/constraints.txt
@@ -1,10 +1,10 @@
 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"
 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
diff --git a/tests/test_env.py b/tests/test_env.py
index 93b6a6d..7bfd8c3 100644
--- a/tests/test_env.py
+++ b/tests/test_env.py
@@ -1,19 +1,26 @@
 # SPDX-License-Identifier: MIT
-import collections
+from __future__ import annotations
+
 import logging
-import platform
+import shutil
 import subprocess
 import sys
 import sysconfig
+import typing
+
+from pathlib import Path
+from types import SimpleNamespace
 
 import pytest
+import pytest_mock
 
 from packaging.version import Version
 
 import build.env
 
 
-IS_PYPY3 = platform.python_implementation() == 'PyPy'
+IS_PYPY = sys.implementation.name == 'pypy'
+IS_WINDOWS = sys.platform.startswith('win')
 
 
 @pytest.mark.isolated
@@ -25,32 +32,11 @@ def test_isolation():
             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):
-    with build.env.DefaultIsolatedEnv() as env:
-        mocker.patch('build.env.run_subprocess')
-
-        env.install([])
-        build.env.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]
-        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(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):
+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 +44,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 +56,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_venv_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():
@@ -76,40 +66,40 @@ def test_executable_missing_post_creation(mocker):
     assert venv_create.call_count == 1
 
 
+@typing.no_type_check
 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):
+    class PartialEnv(build.env.IsolatedEnv):
+        def make_extra_environ(self):
             raise NotImplementedError
 
     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')
+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+pip...'),
         ('INFO', 'Installing packages in isolated environment:\n- something'),
     ]
 
@@ -119,10 +109,9 @@ def test_isolated_env_log(mocker, caplog, package_test_flit):
 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')
 
 
@@ -130,32 +119,35 @@ 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
-            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']
+    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,) = _subprocess.call_args_list
-            assert uninstall_call[0][0][1:] == ['-m', 'pip', 'uninstall', 'setuptools', '-y']
+            run_subprocess.assert_called_once_with(
+                [env.python_executable, '-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')
@@ -168,3 +160,131 @@ def test_venv_symlink(mocker, has_symlink):
     build.env._fs_supports_symlink.cache_clear()
 
     assert supports_symlink is has_symlink
+
+
+def test_install_short_circuits(
+    mocker: pytest_mock.MockerFixture,
+):
+    with build.env.DefaultIsolatedEnv() as env:
+        install_requirements = mocker.patch.object(env._env_backend, 'install_requirements')
+
+        env.install([])
+        install_requirements.assert_not_called()
+
+        env.install(['foo'])
+        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_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(installer='uv') as env:
+        run_subprocess = mocker.patch('build.env.run_subprocess')
+
+        env.install(['some', 'requirements'])
+
+        (install_call,) = run_subprocess.call_args_list
+        assert len(install_call.args) == 1
+        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
+
+
+@pytest.mark.usefixtures('local_pip')
+@pytest.mark.parametrize(
+    ('installer', 'env_backend_display_name', 'has_virtualenv'),
+    [
+        ('pip', 'venv+pip', False),
+        ('pip', 'virtualenv+pip', True),
+        ('pip', 'virtualenv+pip', None),  # Fall-through
+        ('uv', 'venv+uv', None),
+    ],
+    indirect=('has_virtualenv',),
+)
+def test_venv_creation(
+    installer: build.env.Installer,
+    env_backend_display_name: str,
+):
+    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(
+    'installer',
+    [
+        'pip',
+        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,
+    installer: build.env.Installer,
+):
+    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
diff --git a/tests/test_integration.py b/tests/test_integration.py
index 185e99c..341c1d4 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 @@ import build.__main__
 
 
 IS_WINDOWS = sys.platform.startswith('win')
-IS_PYPY3 = platform.python_implementation() == 'PyPy'
+IS_PYPY = sys.implementation.name == 'pypy'
 
 
 INTEGRATION_SOURCES = {
@@ -79,8 +78,8 @@ def get_project(name, tmp_path):
 )
 @pytest.mark.parametrize(
     'args',
-    [[], ['-x', '--no-isolation']],
-    ids=['isolated', 'no_isolation'],
+    [[], ['--installer', 'uv'], ['-x', '--no-isolation']],
+    ids=['isolated_pip', 'isolated_uv', 'no_isolation'],
 )
 @pytest.mark.parametrize(
     'project',
@@ -93,10 +92,12 @@ def get_project(name, tmp_path):
     ],
 )
 @pytest.mark.isolated
-def test_build(monkeypatch, project, args, call, tmp_path):
+def test_build(request, monkeypatch, project, args, call, tmp_path):
+    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")
-    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)
diff --git a/tests/test_main.py b/tests/test_main.py
index ecede88..7b17efe 100644
--- a/tests/test_main.py
+++ b/tests/test_main.py
@@ -30,76 +30,81 @@ ANSI_STRIP = re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])')
     [
         (
             [],
-            [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',
+        ),
+        (
+            ['--installer', 'uv'],
+            [cwd, out, ['wheel'], {}, True, False, 'uv'],
             '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+pip...',
                 '* 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+pip...',
                 '* 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+pip...',
                 '* 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+pip...',
                 '* 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+pip...\33[0m',
                 '\33[1m* Installing packages in isolated environment:\33[0m',
                 '  - setuptools >= 42.0.0',
                 '  - this is invalid',
@@ -424,7 +429,7 @@ def test_venv_fail(monkeypatch, package_test_flit, tmp_dir, capsys):
     assert (
         stdout
         == """\
-* Creating venv isolated environment...
+* Creating isolated environment: venv+pip...
 > test args
 < stdoutput
 ERROR Failed to create venv. Maybe try installing virtualenv.