Repository: pypa/build
Pull request #144 (2020-10-17 12:24:52)

Refactor to simplify the env package
Create the isolated build environment upfront to avoid executable, pip executable or path not yet set. Remove unused code segments.

Signed-off-by: Bernát Gábor <bgabor8@bloomberg.net>

As promised in https://github.com/pypa/build/pull/140 this is the follow-up PR to clean up the env code a bit and avoid some redundant type casts. There have been many code segments we don't need actually anymore with the switch to venv/virtualenv. While doing it I've taken the liberty to add a few tests to get 100% coverage for the module.

FFY00: I am fine with the refactoring, as well as moving the environment creation to `__init__`, but what warrants breaking the API? Is there any good motivation behind that or is it just a nice to have? I'd greatly prefer to keep API compatibility.
gaborbernat: > I am fine with the refactoring, as well as moving the environment creation to `__init__`, but what warrants breaking the API?

We haven't yet officially released the tool yet (hence version ``0.0.4``) and as such, I don't think we are in a state yet where we must ensure API compatibility with the earlier version. Seconds I feel like the API was created as optimized for the old way of isolation builds, and as such a bit incompatible with where we landed now. 

The biggest problem with the current API is my comment above:

> We could put this to be a class method but wasn't sure if it's a good idea to annotate a class method with a contextmanager. And I don't think this class makes sense without always using __enter__ right after creation.

The earlier API IMHO is not good with the constraint that this class must be entered to be used. It allowed people to do:

```
env = IsolatedBuildEnv.for_x()

# here basically any method call on env is a really bad idea and undefined behavior, and as such IMHO the API itself should not allow it rather than adding defensive error raises at runtime

with env:
	pass 
```

The new API achieves exactly this.
FFY00: > The earlier API IMHO is not good with the constraint that this class must be entered to be used. It allowed people to do:
> 
> ```python
> env = IsolatedBuildEnv.for_x()
> 
> # here basically any method call on env is a really bad idea 
> # and undefined behavior, and as such IMHO the API itself should
> # not allow it rather than adding defensive error raises at runtime
> 
> with env:
> 	pass 
> ```
> 
> The new API achieves exactly this.
> 
> By the way how much of the API you consider public? I'd personally restrict it to just the main method, until people provide use cases where they'd need more.

I agree, and that is what I suggested in the code comment.

Overall I do not like the unbundling of the environment creation from `IsolatedEnvironment`. Let's keep it there, please :blush:
FFY00: Sorry, misread your comment.
gaborbernat: > > The earlier API IMHO is not good with the constraint that this class must be entered to be used. It allowed people to do:
> > 
> > ```python
> > env = IsolatedBuildEnv.for_x()
> > 
> > # here basically any method call on env is a really bad idea 
> > # and undefined behavior, and as such IMHO the API itself should
> > # not allow it rather than adding defensive error raises at runtime
> > 
> > with env:
> > 	pass 
> > ```
> > 
> > The new API achieves exactly this.
> > 
> > By the way how much of the API you consider public? I'd personally restrict it to just the main method, until people provide use cases where they'd need more.
> 
> I agree, and that is what I suggested in the code comment.
> 
> Overall I do not like the unbundling of the environment creation from `IsolatedEnvironment`. Let's keep it there, please :blush:

But calling env.build without entering the context of the class is also broken behavior... 
FFY00: > here basically any method call on env is a really bad idea 
> and undefined behavior, and as such IMHO the API itself should
> not allow it rather than adding defensive error raises at runtime

The only public method in `IsolatedEnvironment` is `install`, which can be called outside the environment. Could you elaborate?

gaborbernat: I'll amend the PR in the morning.
FFY00: @gaborbernat I hope I am not being too pushy, we can keep discussing this if you want.
gaborbernat: > What is this `env.build`?

My point was that achieving the workaround for https://github.com/pypa/pep517/pull/93 we patched the ``sys.executable`` within our context manager of the class. If one would call ``builder.build`` without using the context manager (but just creates the environment) things can still be misused. That being said with the merge and release of that PR this is no longer the case, so I've now adopted the release of https://github.com/pypa/pep517/pull/93 into this PR to avoid this problem.
gaborbernat: @FFY00 this is now ready for review.
gaborbernat: @FFY00 @pganssle made the requested changes 👍 please review again.

Patch:
diff --git a/setup.cfg b/setup.cfg
index 7a4c89c..02cfdb2 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -26,7 +26,7 @@ project_urls =
 packages = find:
 install_requires =
     packaging
-    pep517
+    pep517>=0.9.1
     toml
     importlib-metadata;python_version < "3.8"
     typing;python_version<"3"
@@ -50,6 +50,9 @@ test =
     pytest
     pytest-cov
     pytest-mock
+typing =
+    mypy==0.790
+    typing-extensions>=3.7.4.3
 
 [options.packages.find]
 where = src
@@ -78,6 +81,11 @@ omit =
     *bin/pyproject-build
     *bin\pyproject-build.exe
 
+[coverage:report]
+exclude_lines =
+    \#\s*pragma: no cover
+    ^\s*raise NotImplementedError\b
+
 [coverage:paths]
 source =
     src
diff --git a/src/build/__init__.py b/src/build/__init__.py
index 885a926..d6d3455 100644
--- a/src/build/__init__.py
+++ b/src/build/__init__.py
@@ -10,14 +10,12 @@ import difflib
 import os
 import sys
 import warnings
-
 from typing import Dict, Iterator, List, Mapping, Optional, Set, Union
 
 import pep517.wrappers
 import toml
 import toml.decoder
 
-
 if sys.version_info < (3,):
     FileNotFoundError = IOError
     PermissionError = OSError
@@ -118,9 +116,14 @@ def _working_directory(path):  # type: (str) -> Iterator[None]
 
 
 class ProjectBuilder(object):
-    def __init__(self, srcdir='.', config_settings=None):  # type: (str, Optional[ConfigSettings]) -> None
+    def __init__(self, srcdir='.', config_settings=None, python_executable=sys.executable):
+        # type: (str, Optional[ConfigSettings], str) -> None
         """
-        :param srcdir: Source directory
+        Create a project builder.
+
+        :param srcdir: the source directory
+        :param config_settings: config settings for the build backend
+        :param python_executable: the python executable where the backend lives
         """
         self.srcdir = os.path.abspath(srcdir)
         self.config_settings = config_settings if config_settings else {}
@@ -152,7 +155,10 @@ class ProjectBuilder(object):
         self._backend = self._build_system['build-backend']
 
         self.hook = pep517.wrappers.Pep517HookCaller(
-            self.srcdir, self._backend, backend_path=self._build_system.get('backend-path')
+            self.srcdir,
+            self._backend,
+            backend_path=self._build_system.get('backend-path'),
+            python_executable=python_executable,
         )
 
     @property
diff --git a/src/build/__main__.py b/src/build/__main__.py
index 2d0aafe..a353f9d 100644
--- a/src/build/__main__.py
+++ b/src/build/__main__.py
@@ -9,8 +9,7 @@ import warnings
 from typing import List, Optional, TextIO, Type, Union
 
 from build import BuildBackendException, BuildException, ConfigSettings, ProjectBuilder
-from build.env import IsolatedEnvironment
-
+from build.env import IsolatedEnvBuilder
 
 __all__ = ['build', 'main', 'main_parser']
 
@@ -40,8 +39,10 @@ def _error(msg, code=1):  # type: (str, int) -> None  # pragma: no cover
     exit(code)
 
 
-def _build_in_isolated_env(builder, outdir, distributions):  # type: (ProjectBuilder, str, List[str]) -> None
-    with IsolatedEnvironment.for_current() as env:
+def _build_in_isolated_env(builder, outdir, distributions):
+    # type: (ProjectBuilder, str, List[str]) -> None
+    with IsolatedEnvBuilder() as env:
+        builder.hook.python_executable = env.executable
         env.install(builder.build_dependencies)
         for distribution in distributions:
             builder.build(distribution, outdir)
@@ -75,7 +76,6 @@ def build(srcdir, outdir, distributions, config_settings=None, isolation=True, s
 
     try:
         builder = ProjectBuilder(srcdir, config_settings)
-
         if isolation:
             _build_in_isolated_env(builder, outdir, distributions)
         else:
diff --git a/src/build/_compat.py b/src/build/_compat.py
new file mode 100644
index 0000000..3a5da79
--- /dev/null
+++ b/src/build/_compat.py
@@ -0,0 +1,42 @@
+import abc
+import sys
+from typing import Callable, TypeVar
+
+_T = TypeVar('_T')
+
+
+def add_metaclass(metaclass):  # type: (type) -> Callable[[_T], _T]
+    """Class decorator for creating a class with a metaclass (borrowed from six code)."""
+
+    def wrapper(cls):  # type: (_T) -> _T
+        orig_vars = cls.__dict__.copy()
+        slots = orig_vars.get('__slots__')
+        if slots is not None:
+            if isinstance(slots, str):  # pragma: no cover
+                slots = [slots]  # pragma: no cover
+            for slots_var in slots:  # pragma: no cover
+                orig_vars.pop(slots_var)  # pragma: no cover
+        orig_vars.pop('__dict__', None)
+        orig_vars.pop('__weakref__', None)
+        if hasattr(cls, '__qualname__'):
+            orig_vars['__qualname__'] = cls.__qualname__  # type: ignore
+        return metaclass(cls.__name__, cls.__bases__, orig_vars)  # type: ignore
+
+    return wrapper
+
+
+if sys.version_info[0] == 2:
+    abstractproperty = abc.abstractproperty
+else:
+    from typing import Any, cast
+
+    F = TypeVar('F', bound=Callable[..., Any])
+
+    def abstractproperty(func):  # type: (F) -> F
+        return cast(F, property(abc.abstractmethod(func)))
+
+
+__all__ = (
+    'abstractproperty',
+    'add_metaclass',
+)
diff --git a/src/build/env.py b/src/build/env.py
index 760957e..0c1be7e 100644
--- a/src/build/env.py
+++ b/src/build/env.py
@@ -1,3 +1,7 @@
+"""
+Creates and manages isolated build environments.
+"""
+import abc
 import os
 import platform
 import shutil
@@ -5,237 +9,207 @@ import subprocess
 import sys
 import sysconfig
 import tempfile
-import types
-import typing
-from typing import Dict, Iterable, List, Optional, Sequence, Type
+from types import TracebackType
+from typing import Iterable, Optional, Tuple, Type
 
-if sys.version_info[0] == 2:
-    FileExistsError = OSError
+from ._compat import abstractproperty, add_metaclass
 
 try:
     import pip
 except ImportError:  # pragma: no cover
     pip = None  # pragma: no cover
 
-_HAS_SYMLINK = None  # type: Optional[bool]
 
+@add_metaclass(abc.ABCMeta)
+class IsolatedEnv(object):
+    """Abstract base of isolated build environments, as required by the build project."""
 
-def _fs_supports_symlink():  # type: () -> bool
-    if not hasattr(os, 'symlink'):
-        return False
+    @abstractproperty
+    def executable(self):  # type: () -> str
+        """Return the executable of the isolated build environment."""
+        raise NotImplementedError
 
-    if sys.platform.startswith(('aix', 'darwin', 'freebsd', 'linux')):
-        return True
-    else:
-        with tempfile.NamedTemporaryFile(prefix='TmP') as tmp_file:
-            temp_dir = os.path.dirname(tmp_file.name)
-            dest = os.path.join(temp_dir, '{}-{}'.format(tmp_file.name, 'b'))
-            try:
-                os.symlink(tmp_file.name, dest)
-                return True
-            except (OSError, NotImplementedError):
-                return False
+    @abc.abstractmethod
+    def install(self, requirements):  # type: (Iterable[str]) -> None
+        """
+        Install PEP-508 requirements into the isolated build environment.
+
+        :param requirements: PEP-508 requirements
+        """
+        raise NotImplementedError
+
+
+class IsolatedEnvBuilder(object):
+    def __init__(self):  # type: () -> None
+        """Builder object for isolated environment."""
+        self._path = None  # type: Optional[str]
+
+    def __enter__(self):  # type: () -> IsolatedEnv
+        """
+        Creates an isolated build environment.
+
+        :return: the isolated build environment
+        """
+        self._path = tempfile.mkdtemp(prefix='build-env-')
+        try:
+            executable, pip_executable = _create_isolated_env(self._path)
+            return _IsolatedEnvVenvPip(path=self._path, python_executable=executable, pip_executable=pip_executable)
+        except Exception:  # cleanup folder if creation fails
+            self.__exit__(*sys.exc_info())
+            raise
 
+    def __exit__(self, exc_type, exc_val, exc_tb):
+        # type: (Optional[Type[BaseException]], Optional[BaseException], Optional[TracebackType]) -> None
+        """
+        Delete the created isolated build environment.
 
-def fs_supports_symlink():  # type: () -> bool
-    global _HAS_SYMLINK
-    if _HAS_SYMLINK is None:
-        _HAS_SYMLINK = _fs_supports_symlink()
-    return _HAS_SYMLINK
+        :param exc_type: the type of exception raised (if any)
+        :param exc_val: the value of exception raised (if any)
+        :param exc_tb: the traceback of exception raised (if any)
+        """
+        if self._path is not None and os.path.exists(self._path):  # in case the user already deleted skip remove
+            shutil.rmtree(self._path)
 
 
-class IsolatedEnvironment(object):
+class _IsolatedEnvVenvPip(IsolatedEnv):
     """
     Isolated build environment context manager
 
     Non-standard paths injected directly to sys.path still be passed to the environment.
     """
 
-    _MANIPULATE_PATHS = ('purelib', 'platlib')
-
-    def __init__(self, remove_paths, _executable=sys.executable):  # type: (Sequence[str], str) -> None
-        """
-        :param remove_paths: Import paths that should be removed from the environment
+    def __init__(self, path, python_executable, pip_executable):
+        # type: (str, str, str) -> None
         """
-        self._env = {}  # type: Dict[str, Optional[str]]
-        self._env_vars = {}  # type: Dict[str, str]
-        self._path = None  # type: Optional[str]
-        self._remove_paths = []  # type: List[str]
-        self._executable = _executable
-        self._old_executable = None  # type: Optional[str]
-        self._pip_executable = None  # type: Optional[str]
+        Define an isolated build environment.
 
-        # normalize paths so that we can compare them -- required on case insensitive systems
-        for path in remove_paths:
-            self._remove_paths.append(os.path.normcase(path))
+        :param path: the path where the environment exists
+        :param python_executable: the python executable within the environment
+        :param pip_executable: an executable that allows installing packages within the environment
+        """
+        self._path = path
+        self._pip_executable = pip_executable
+        self._python_executable = python_executable
 
     @property
     def path(self):  # type: () -> str
-        if not self._path:
-            raise RuntimeError("{} context environment hasn't been entered yet".format(self.__class__.__name__))
-
+        """:return: the location of the isolated build environment"""
         return self._path
 
     @property
     def executable(self):  # type: () -> str
-        return self._executable
+        """:return: the python executable of the isolated build environment"""
+        return self._python_executable
 
-    @classmethod
-    def for_current(cls):  # type: () -> IsolatedEnvironment
-        """
-        Creates an isolated environment for the current interpreter
+    def install(self, requirements):  # type: (Iterable[str]) -> None
         """
-        remove_paths = os.environ.get('PYTHONPATH', '').split(os.pathsep)
-        # also remove the pth file adding this project (when installed via --develop install)
-        for path in sys.path:
-            if os.path.exists(os.path.join(path, 'build.egg-info')):
-                remove_paths.append(path)  # pragma: no cover
+        Installs the specified PEP 508 requirements on the environment
 
-        return cls(remove_paths)
+        :param requirements: PEP-508 requirement specification to install
 
-    def _replace_env(self, key, new):  # type: (str, Optional[str]) -> None
-        """
-        Replaces an environment variable
+        :note: Passing non PEP 508 strings will result in undefined behavior, you *should not* rely on it. It is \
+               merely an implementation detail, it may change any time without warning.
         """
-        if not new:  # pragma: no cover
+        if not requirements:
             return
 
-        self._env[key] = os.environ.get(key, None)
-        os.environ[key] = new
+        with tempfile.NamedTemporaryFile('w+', prefix='build-reqs-', suffix='.txt', delete=False) as req_file:
+            req_file.write(os.linesep.join(requirements))
+        try:
+            cmd = [
+                self._pip_executable,
+                # on python2 if isolation is achieved via environment variables, we need to ignore those while calling
+                # host python (otherwise pip would not be available within it)
+                '-{}m'.format('E' if self._pip_executable == self.executable and sys.version_info[0] == 2 else ''),
+                'pip',
+                'install',
+                '--prefix',
+                self.path,
+                '--ignore-installed',
+                '--no-warn-script-location',
+                '-r',
+                os.path.abspath(req_file.name),
+            ]
+            subprocess.check_call(cmd)
+        finally:
+            os.unlink(req_file.name)
 
-    def _pop_env(self, key):  # type: (str) -> None
-        """
-        Removes an environment variable
-        """
-        self._env[key] = os.environ.pop(key, None)
 
-    def _restore_env(self):  # type: () -> None
-        """
-        Restores the initial environment variables
-        """
-        for key, val in self._env.items():
-            if val is None:
-                os.environ.pop(key, None)
-            else:
-                os.environ[key] = val
+if sys.version_info[0] == 2:  # noqa: C901 # disable if too complex
 
-    def _get_env_path(self, path):  # type: (str) -> Optional[str]
-        """
-        Returns sysconfig path from our environment
+    def _create_isolated_env(path):  # type: (str) -> Tuple[str, str]
         """
-        return sysconfig.get_path(path, vars=self._env_vars)
-
-    def _create_env_venv(self):  # type: () -> None
-        import venv
-
-        venv.EnvBuilder(with_pip=False).create(self.path)
+        On Python 2 we use the virtualenv package to provision a virtual environment.
 
-        env_scripts = self._get_env_path('scripts')
-        if not env_scripts:
-            raise RuntimeError("Couldn't get environment scripts path")
-        exe = 'pypy3' if platform.python_implementation() == 'PyPy' else 'python'
-        if os.name == 'nt':
-            exe = '{}.exe'.format(exe)
-
-        self._executable = os.path.join(self.path, env_scripts, exe)
-        if not os.path.exists(self._executable):
-            raise RuntimeError('Virtual environment creation failed, executable {} missing'.format(self._executable))
-
-        # Scenario 1: pip is available (either installed or via pth file) within the python executable alongside
-        # this projects environment: in this case we should be able to import it
-        if pip is not None:
-            self._pip_executable = sys.executable
-            return
-        # Scenario 2: this project is installed into a virtual environment that has no pip, but the matching system has
-        # Scenario 3: there's a pip executable on PATH
-        # Scenario 4: no pip can be found, we might be able to provision one into the isolated build env via ensurepip
-        cmd = [self.executable, '-Im', 'ensurepip', '--upgrade', '--default-pip']
-        try:
-            subprocess.check_call(cmd, cwd=self.path)
-        except subprocess.CalledProcessError:  # pragma: no cover
-            pass  # pragma: no cover
-        # avoid the setuptools from ensurepip to break the isolation
-        subprocess.check_call([self.executable, '-Im', 'pip', 'uninstall', 'setuptools', '-y'])
-        self._pip_executable = self.executable
-
-    def _create_env_virtualenv(self):  # type: () -> None
+        :param path: the folder where to create the isolated build environment
+        :return: the isolated build environment executable, and the pip to use to install packages into it
+        """
         from virtualenv import cli_run
 
-        cmd = [str(self.path), '--no-setuptools', '--no-wheel', '--activators', '']
+        cmd = [str(path), '--no-setuptools', '--no-wheel', '--activators', '']
         if pip is not None:
             cmd.append('--no-pip')
         result = cli_run(cmd, setup_logging=False)
-        self._executable = str(result.creator.exe)
-        self._pip_executable = self._executable
-        if pip is not None:
-            self._pip_executable = sys.executable
+        executable = str(result.creator.exe)
+        pip_executable = executable if pip is None else sys.executable
+        return executable, pip_executable
 
-    def _create_isolated_env(self):  # type: () -> None
-        if sys.version_info[0] == 2:
-            self._create_env_virtualenv()
 
-        else:
-            self._create_env_venv()
+else:
 
-    def __enter__(self):  # type: () -> IsolatedEnvironment
+    def _create_isolated_env(path):  # type: (str) -> Tuple[str, str]
         """
-        Set up the environment
+        On Python 3 we use the venv package from the standard library, and if host python has no pip the ensurepip
+        package to provision one into the created virtual environment.
 
-        The environment path should be empty
+        :param path: the folder where to create the isolated build environment
+        :return: the isolated build environment executable, and the pip to use to install packages into it
         """
-        self._path = tempfile.mkdtemp(prefix='build-env-')
-        self._env_vars = {
-            'base': self.path,
-            'platbase': self.path,
-        }
-
-        self._create_isolated_env()
+        import venv
 
-        self._pop_env('PIP_REQUIRE_VIRTUALENV')
-        # address https://github.com/pypa/pep517/pull/93
-        self._old_executable, sys.executable = sys.executable, self._executable
+        venv.EnvBuilder(with_pip=False).create(path)
+        executable = _find_executable(path)
 
-        return self
+        # Scenario 1: pip is available (either installed or via pth file) within the python executable alongside
+        # this projects environment: in this case we should be able to import it
+        if pip is not None:
+            pip_executable = sys.executable
+        else:
+            # Scenario 2: this project is installed into a virtual environment that has no pip, but the system has
+            # Scenario 3: there's a pip executable on PATH
+            # Scenario 4: no pip can be found, we might be able to provision one into the build env via ensurepip
+            cmd = [executable, '-Im', 'ensurepip', '--upgrade', '--default-pip']
+            try:
+                subprocess.check_call(cmd, cwd=path)
+            except subprocess.CalledProcessError:  # pragma: no cover
+                pass  # pragma: no cover
+            # avoid the setuptools from ensurepip to break the isolation
+            subprocess.check_call([executable, '-Im', 'pip', 'uninstall', 'setuptools', '-y'])
+            pip_executable = executable
+        return executable, pip_executable
 
-    def __exit__(self, typ, value, traceback):
-        # type: (Optional[Type[BaseException]], Optional[BaseException], Optional[types.TracebackType]) -> None
+    def _find_executable(path):  # type: (str) -> str
         """
-        Restores the everything to the original state
-        """
-        if self._old_executable is not None:
-            sys.executable = self._old_executable
-        if self.path and os.path.isdir(self.path):
-            shutil.rmtree(self.path)
-
-        self._restore_env()
+        Detect the executable within a virtual environment.
 
-    def install(self, requirements):  # type: (Iterable[str]) -> None
+        :param path: the location of the virtual environment
+        :return: the python executable
         """
-        Installs the specified PEP 508 requirements on the environment
+        config_vars = sysconfig.get_config_vars()
+        config_vars['base'] = path
+        env_scripts = sysconfig.get_path('scripts', vars=config_vars)
+        if not env_scripts:
+            raise RuntimeError("Couldn't get environment scripts path")
+        exe = 'pypy3' if platform.python_implementation() == 'PyPy' else 'python'
+        if os.name == 'nt':
+            exe = '{}.exe'.format(exe)
+        executable = os.path.join(path, env_scripts, exe)
+        if not os.path.exists(executable):
+            raise RuntimeError('Virtual environment creation failed, executable {} missing'.format(executable))
+        return executable
 
-        Passing non PEP 508 strings will result in undefined behavior, you
-        *should not* rely on it. It is merely an implementation detail, it may
-        change any time without warning.
-        """
-        if not requirements:
-            return
 
-        with tempfile.NamedTemporaryFile('w+', prefix='build-reqs-', suffix='.txt', delete=False) as req_file:
-            req_file.write(os.linesep.join(requirements))
-            req_file.close()
-            cmd = [
-                typing.cast(str, self._pip_executable),
-                # on python2 if isolation is achieved via environment variables, we need to ignore those while calling
-                # host python (otherwise pip would not be available within it)
-                '-{}m'.format('E' if self._pip_executable == self._executable and sys.version_info[0] == 2 else ''),
-                'pip',
-                'install',
-                '--prefix',
-                self.path,
-                '--ignore-installed',
-                '--no-warn-script-location',
-                '-r',
-                os.path.abspath(req_file.name),
-            ]
-            subprocess.check_call(cmd)
-            os.unlink(req_file.name)
+__all__ = (
+    'IsolatedEnvBuilder',
+    'IsolatedEnv',
+)
diff --git a/tox.ini b/tox.ini
index e32bb0d..31dff1b 100644
--- a/tox.ini
+++ b/tox.ini
@@ -52,8 +52,7 @@ commands =
 
 [testenv:type]
 description = run type check on code base
-deps =
-    mypy==0.782
+extras = typing
 commands =
     mypy --python-version 3.8 src/build
     mypy --python-version 2.7 src/build


Test patch:
diff --git a/tests/test_env.py b/tests/test_env.py
index 969eff4..9a588ac 100644
--- a/tests/test_env.py
+++ b/tests/test_env.py
@@ -1,8 +1,8 @@
 # SPDX-License-Identifier: MIT
 import json
 import os
-import os.path
 import platform
+import shutil
 import subprocess
 import sys
 
@@ -14,23 +14,15 @@ import build.env
 @pytest.mark.isolated
 def test_isolation():
     subprocess.check_call([sys.executable, '-c', 'import build.env'])
-    with build.env.IsolatedEnvironment.for_current() as env:
+    with build.env.IsolatedEnvBuilder() as env:
         with pytest.raises(subprocess.CalledProcessError):
             debug = 'import sys; import os; print(os.linesep.join(sys.path));'
             subprocess.check_call([env.executable, '-c', '{} import build.env'.format(debug)])
 
 
-@pytest.mark.isolated
-def test_isolated_environment_setup_require_virtualenv(mocker):
-    mocker.patch.dict(os.environ, {'PIP_REQUIRE_VIRTUALENV': 'true'})
-    with build.env.IsolatedEnvironment.for_current():
-        assert 'PIP_REQUIRE_VIRTUALENV' not in os.environ
-    assert os.environ['PIP_REQUIRE_VIRTUALENV'] == 'true'
-
-
 @pytest.mark.isolated
 def test_isolated_environment_install(mocker):
-    with build.env.IsolatedEnvironment.for_current() as env:
+    with build.env.IsolatedEnvBuilder() as env:
         mocker.patch('subprocess.check_call')
 
         env.install([])
@@ -42,7 +34,7 @@ def test_isolated_environment_install(mocker):
         args = subprocess.check_call.call_args[0][0][:-1]
         assert args == [
             env._pip_executable,
-            '-{}m'.format('E' if env._pip_executable == env._executable and sys.version_info[0] == 2 else ''),
+            '-{}m'.format('E' if env._pip_executable == env._python_executable and sys.version_info[0] == 2 else ''),
             'pip',
             'install',
             '--prefix',
@@ -53,19 +45,12 @@ def test_isolated_environment_install(mocker):
         ]
 
 
-def test_uninitialised_isolated_environment():
-    env = build.env.IsolatedEnvironment.for_current()
-
-    with pytest.raises(RuntimeError):
-        env.path
-
-
 @pytest.mark.isolated
 def test_create_isolated_build_host_with_no_pip(tmp_path, capfd, mocker):
     mocker.patch.object(build.env, 'pip', None)
     expected = {'pip', 'greenlet', 'readline', 'cffi'} if platform.python_implementation() == 'PyPy' else {'pip'}
 
-    with build.env.IsolatedEnvironment.for_current() as isolated_env:
+    with build.env.IsolatedEnvBuilder() as isolated_env:
         cmd = [isolated_env.executable, '-m', 'pip', 'list', '--format', 'json']
         packages = {p['name'] for p in json.loads(subprocess.check_output(cmd, universal_newlines=True))}
         assert packages == expected
@@ -80,9 +65,61 @@ def test_create_isolated_build_host_with_no_pip(tmp_path, capfd, mocker):
 
 @pytest.mark.isolated
 def test_create_isolated_build_has_with_pip(tmp_path, capfd, mocker):
-    with build.env.IsolatedEnvironment.for_current() as isolated_env:
+    with build.env.IsolatedEnvBuilder() as isolated_env:
         pass
     assert isolated_env._pip_executable == sys.executable
     out, err = capfd.readouterr()
     assert not out
     assert not err
+
+
+@pytest.mark.skipif(sys.version_info[0] == 2, reason='venv module used on Python 3 only')
+def test_fail_to_get_script_path(mocker):
+    get_path = mocker.patch('sysconfig.get_path', return_value=None)
+    with pytest.raises(RuntimeError, match="Couldn't get environment scripts path"):
+        env = build.env.IsolatedEnvBuilder()
+        with env:
+            pass
+    assert not os.path.exists(env._path)
+    assert get_path.call_count == 1
+
+
+@pytest.mark.skipif(sys.version_info[0] == 2, reason='venv module used on Python 3 only')
+def test_executable_missing_post_creation(mocker):
+    import sysconfig
+
+    original_get_path = sysconfig.get_path
+
+    def _get_path(name, vars):  # noqa
+        shutil.rmtree(vars['base'])
+        return original_get_path(name, vars=vars)
+
+    get_path = mocker.patch('sysconfig.get_path', side_effect=_get_path)
+    with pytest.raises(RuntimeError, match='Virtual environment creation failed, executable .* missing'):
+        with build.env.IsolatedEnvBuilder():
+            pass
+    assert get_path.call_count == 1
+
+
+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):  # noqa
+        @property
+        def executable(self):
+            raise NotImplementedError
+
+    with pytest.raises(TypeError):
+        Env()
+
+
+def test_isolated_env_has_install_still_abstract():
+    class Env(build.env.IsolatedEnv):  # noqa
+        def install(self, requirements):
+            raise NotImplementedError
+
+    with pytest.raises(TypeError):
+        Env()
diff --git a/tests/test_main.py b/tests/test_main.py
index bc93a51..bca03a1 100644
--- a/tests/test_main.py
+++ b/tests/test_main.py
@@ -87,39 +87,56 @@ def test_prog():
 
 
 @pytest.mark.isolated
-def test_build(mocker, test_flit_path):
-    mocker.patch('importlib.import_module', autospec=True)
-    mocker.patch('build.ProjectBuilder.check_dependencies')
-    mocker.patch('build.ProjectBuilder.build')
+def test_build_isolated(mocker, test_flit_path):
+    build_cmd = mocker.patch('build.ProjectBuilder.build')
     mocker.patch('build.__main__._error')
-    mocker.patch('build.env.IsolatedEnvironment.install')
+    install = mocker.patch('build.env._IsolatedEnvVenvPip.install')
 
-    build.ProjectBuilder.check_dependencies.side_effect = [[], ['something'], [], []]
-    build.env.IsolatedEnvironment._path = mocker.Mock()
-
-    # isolation=True
     build.__main__.build(test_flit_path, '.', ['sdist'])
-    build.ProjectBuilder.build.assert_called_with('sdist', '.')
 
+    build_cmd.assert_called_with('sdist', '.')
+    install.assert_called_with({'flit_core >=2,<3'})
+
+
+def test_build_no_isolation_check_deps_empty(mocker, test_flit_path):
     # check_dependencies = []
+    build_cmd = mocker.patch('build.ProjectBuilder.build')
+    mocker.patch('build.ProjectBuilder.check_dependencies', return_value=[])
+
     build.__main__.build(test_flit_path, '.', ['sdist'], isolation=False)
-    build.ProjectBuilder.build.assert_called_with('sdist', '.')
-    build.env.IsolatedEnvironment.install.assert_called_with({'flit_core >=2,<3'})
 
+    build_cmd.assert_called_with('sdist', '.')
+
+
+def test_build_no_isolation_with_check_deps(mocker, test_flit_path):
     # check_dependencies = ['something']
+    error = mocker.patch('build.__main__._error')
+    build_cmd = mocker.patch('build.ProjectBuilder.build')
+    mocker.patch('build.ProjectBuilder.check_dependencies', return_value=['something'])
+
     build.__main__.build(test_flit_path, '.', ['sdist'], isolation=False)
-    build.ProjectBuilder.build.assert_called_with('sdist', '.')
-    build.__main__._error.assert_called_with('Missing dependencies:\n\tsomething')
 
-    build.ProjectBuilder.build.side_effect = [build.BuildException, build.BuildBackendException]
-    build.__main__._error.reset_mock()
+    build_cmd.assert_called_with('sdist', '.')
+    error.assert_called_with('Missing dependencies:\n\tsomething')
+
+
+@pytest.mark.isolated
+def test_build_raises_build_exception(mocker, test_flit_path):
+    error = mocker.patch('build.__main__._error')
+    mocker.patch('build.ProjectBuilder.build', side_effect=build.BuildException)
+    mocker.patch('build.env._IsolatedEnvVenvPip.install')
 
-    # BuildException
     build.__main__.build(test_flit_path, '.', ['sdist'])
-    build.__main__._error.assert_called_with('')
 
-    build.__main__._error.reset_mock()
+    error.assert_called_with('')
+
+
+@pytest.mark.isolated
+def test_build_raises_build_backend_exception(mocker, test_flit_path):
+    error = mocker.patch('build.__main__._error')
+    mocker.patch('build.ProjectBuilder.build', side_effect=build.BuildBackendException)
+    mocker.patch('build.env._IsolatedEnvVenvPip.install')
 
-    # BuildBackendException
     build.__main__.build(test_flit_path, '.', ['sdist'])
-    build.__main__._error.assert_called_with('')
+
+    error.assert_called_with('')
diff --git a/tests/test_projectbuilder.py b/tests/test_projectbuilder.py
index 640c25d..0c0a036 100644
--- a/tests/test_projectbuilder.py
+++ b/tests/test_projectbuilder.py
@@ -88,16 +88,18 @@ def test_init(mocker, test_flit_path, legacy_path, test_no_permission, test_bad_
         'setuptools.build_meta:__legacy__': None,
     }
     mocker.patch('importlib.import_module', modules.get)
-    mocker.patch('pep517.wrappers.Pep517HookCaller')
+    caller = mocker.patch('pep517.wrappers.Pep517HookCaller')
 
     # correct flit pyproject.toml
     build.ProjectBuilder(test_flit_path)
-    pep517.wrappers.Pep517HookCaller.assert_called_with(test_flit_path, 'flit_core.buildapi', backend_path=None)
-    pep517.wrappers.Pep517HookCaller.reset_mock()
+    caller.assert_called_with(test_flit_path, 'flit_core.buildapi', backend_path=None, python_executable=sys.executable)
+    caller.reset_mock()
 
     # FileNotFoundError
     build.ProjectBuilder(legacy_path)
-    pep517.wrappers.Pep517HookCaller.assert_called_with(legacy_path, 'setuptools.build_meta:__legacy__', backend_path=None)
+    caller.assert_called_with(
+        legacy_path, 'setuptools.build_meta:__legacy__', backend_path=None, python_executable=sys.executable
+    )
 
     # PermissionError
     if sys.version_info[0] != 2 and os.name != 'nt':  # can't correctly set the permissions required for this