Skip to content

Commit

Permalink
env: refactor IsolatedEnv
Browse files Browse the repository at this point in the history
The `IsolatedEnv` is made responsible for env creation to allow pip
to implement custom env creation logic.

`IsolatedEnvBuilder` takes an `IsolatedEnv` class as an argument
so that bringing your own `IsolatedEnv` won't require re-implementing
the builder.
  • Loading branch information
layday committed Sep 28, 2021
1 parent cccaf93 commit 5911a46
Showing 1 changed file with 53 additions and 64 deletions.
117 changes: 53 additions & 64 deletions src/build/env.py
Expand Up @@ -38,26 +38,34 @@
class IsolatedEnv(metaclass=abc.ABCMeta):
"""Abstract base of isolated build environments, as required by the build project."""

@abc.abstractmethod
def __init__(self, path: str, logging_fn: Callable[[str], None]) -> None:
"""
:param path: The path where the environment will be created.
:param logging_fn: Logging function.
"""

@property
@abc.abstractmethod
def executable(self) -> str:
"""The executable of the isolated build environment."""
raise NotImplementedError
def python_executable(self) -> str:
"""The Python executable of the isolated environment."""

@property
@abc.abstractmethod
def scripts_dir(self) -> str:
"""The scripts directory of the isolated build environment."""
raise NotImplementedError
"""The scripts directory of the isolated environment."""

@abc.abstractmethod
def install(self, requirements: Iterable[str]) -> None:
def create(self) -> None:
"""Create the isolated environment."""

@abc.abstractmethod
def install_packages(self, requirements: Iterable[str]) -> None:
"""
Install packages from PEP 508 requirements in the isolated build environment.
Install packages from PEP 508 requirements in the isolated environment.
:param requirements: PEP 508 requirements
"""
raise NotImplementedError


@functools.lru_cache(maxsize=None)
Expand All @@ -82,10 +90,13 @@ def _subprocess(cmd: List[str]) -> None:


class IsolatedEnvBuilder:
"""Builder object for isolated environments."""
"""Create and dispose of isolated build environments."""

def __init__(self) -> None:
self._path: Optional[str] = None
def __init__(self, isolated_env_class: Optional[Type[IsolatedEnv]] = None) -> None:
if isolated_env_class is not None:
self._isolated_env_class = isolated_env_class
else:
self._isolated_env_class = _DefaultIsolatedEnv

def __enter__(self) -> IsolatedEnv:
"""
Expand All @@ -95,19 +106,9 @@ def __enter__(self) -> IsolatedEnv:
"""
self._path = tempfile.mkdtemp(prefix='build-env-')
try:
# use virtualenv when available (as it's faster than venv)
if _should_use_virtualenv():
self.log('Creating virtualenv isolated environment...')
executable, scripts_dir = _create_isolated_env_virtualenv(self._path)
else:
self.log('Creating venv isolated environment...')
executable, scripts_dir = _create_isolated_env_venv(self._path)
return _IsolatedEnvVenvPip(
path=self._path,
python_executable=executable,
scripts_dir=scripts_dir,
log=self.log,
)
isolated_env = self._isolated_env_class(self._path, self.log)
isolated_env.create()
return isolated_env
except Exception: # cleanup folder if creation fails
self.__exit__(*sys.exc_info())
raise
Expand All @@ -122,72 +123,57 @@ def __exit__(
: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
if os.path.exists(self._path): # in case the user already deleted skip remove
shutil.rmtree(self._path)

@staticmethod
def log(message: str) -> None:
"""
Prints message
Log a message.
The default implementation uses the logging module but this function can be
overwritten by users to have a different implementation.
:param msg: Message to output
:param msg: Message to log
"""
if sys.version_info >= (3, 8):
_logger.log(logging.INFO, message, stacklevel=2)
else:
_logger.log(logging.INFO, message)


class _IsolatedEnvVenvPip(IsolatedEnv):
class _DefaultIsolatedEnv(IsolatedEnv):
"""
Isolated build environment context manager
An isolated environment which combines virtualenv and venv with pip.
Non-standard paths injected directly to sys.path will still be passed to the environment.
"""

def __init__(
self,
path: str,
python_executable: str,
scripts_dir: str,
log: Callable[[str], None],
) -> None:
"""
:param path: The path where the environment exists
:param python_executable: The python executable within the environment
:param log: Log function
"""
def __init__(self, path: str, logging_fn: Callable[[str], None]) -> None:
self._path = path
self._python_executable = python_executable
self._scripts_dir = scripts_dir
self._log = log

@property
def path(self) -> str:
"""The location of the isolated build environment."""
return self._path
self._log = logging_fn

@property
def executable(self) -> str:
"""The python executable of the isolated build environment."""
def python_executable(self) -> str:
return self._python_executable

@property
def scripts_dir(self) -> str:
return self._scripts_dir

def install(self, requirements: Iterable[str]) -> None:
"""
Install packages from PEP 508 requirements in the isolated build environment.
def create(self) -> None:
# use virtualenv when available (as it's faster than venv)
if _should_use_virtualenv():
self._log('Creating virtualenv isolated environment...')
self._python_executable, self._scripts_dir = _create_isolated_env_virtualenv(self._path)
else:
self._log('Creating venv isolated environment...')
self._python_executable, self._scripts_dir = _create_isolated_env_venv(self._path)

:param requirements: PEP 508 requirement specification to install
def install_packages(self, requirements: Iterable[str]) -> None:
# 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.

:note: Passing non-PEP 508 strings will result in undefined behavior, you *should not* rely on it. It is
merely an implementation detail, it may change any time without warning.
"""
if not requirements:
return

Expand All @@ -199,7 +185,7 @@ def install(self, requirements: Iterable[str]) -> None:
req_file.write(os.linesep.join(requirements))
try:
cmd = [
self.executable,
self._python_executable,
'-Im',
'pip',
'install',
Expand Down Expand Up @@ -255,8 +241,12 @@ def _create_isolated_env_venv(path: str) -> Tuple[str, str]:
import venv

venv.EnvBuilder(with_pip=True, symlinks=_fs_supports_symlink()).create(path)
executable, script_dir, purelib = _find_executable_and_scripts(path)
executable, scripts_dir, purelib = _find_executable_and_scripts(path)
_post_create_isolated_env_venv(executable, purelib)
return executable, scripts_dir


def _post_create_isolated_env_venv(executable: str, purelib: str) -> None:
# Get the version of pip in the environment
pip_distribution = next(iter(metadata.distributions(name='pip', path=[purelib]))) # type: ignore[no-untyped-call]
current_pip_version = packaging.version.Version(pip_distribution.version)
Expand All @@ -275,15 +265,14 @@ def _create_isolated_env_venv(path: str) -> Tuple[str, str]:

# Avoid the setuptools from ensurepip to break the isolation
_subprocess([executable, '-m', 'pip', 'uninstall', 'setuptools', '-y'])
return executable, script_dir


def _find_executable_and_scripts(path: str) -> Tuple[str, str, str]:
"""
Detect the Python executable and script folder of a virtual environment.
Detect the Python executable and sysconfig paths of a venv.
:param path: The location of the virtual environment
:return: The Python executable, script folder, and purelib folder
:param path: venv path
:return: The Python executable, scripts directory, and purelib directory
"""
config_vars = sysconfig.get_config_vars().copy() # globally cached, copy before altering it
config_vars['base'] = path
Expand Down

0 comments on commit 5911a46

Please sign in to comment.