Skip to content

Commit

Permalink
installer: use venv and improve error handling
Browse files Browse the repository at this point in the history
This change drops the dependency on `virtualenv` for the installer,
replacing environment creation with the built-in `venv` module available
in Python >= 3.2.

In addition, this change also improves error handling for subprocess
commands.

The following behavioural changes are also introduced.

- An error log is written to a secure temporary file with stdout and
  tracebacks on error.
- If an existing virtual environment exists prior to installation, this
  is saved, and used for recovery if installation fails.
  • Loading branch information
abn committed May 24, 2021
1 parent 4ec09d4 commit 5ec9f35
Showing 1 changed file with 121 additions and 71 deletions.
192 changes: 121 additions & 71 deletions install-poetry.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@
It does, in order:
- Downloads the virtualenv package to a temporary directory and add it to sys.path.
- Creates a virtual environment in the correct OS data dir which will be
- `%APPDATA%\\pypoetry` on Windows
- ~/Library/Application Support/pypoetry on MacOS
- `${XDG_DATA_HOME}/pypoetry` (or `~/.local/share/pypoetry` if it's not set) on UNIX systems
- In `${POETRY_HOME}` if it's set.
- Update virtual environment pip to latest.
- Installs the latest or given version of Poetry inside this virtual environment.
- Installs a `poetry` script in the Python user directory (or `${POETRY_HOME/bin}` if `POETRY_HOME` is set).
"""
Expand Down Expand Up @@ -219,21 +219,6 @@ def _get_win_folder_with_ctypes(csidl_name):
_get_win_folder = _get_win_folder_from_registry


@contextmanager
def temporary_directory(*args, **kwargs):
try:
from tempfile import TemporaryDirectory
except ImportError:
name = tempfile.mkdtemp(*args, **kwargs)

yield name

shutil.rmtree(name)
else:
with TemporaryDirectory(*args, **kwargs) as name:
yield name


PRE_MESSAGE = """# Welcome to {poetry}!
This will download and install the latest version of {poetry},
Expand Down Expand Up @@ -277,6 +262,64 @@ def temporary_directory(*args, **kwargs):
POST_MESSAGE_CONFIGURE_WINDOWS = """"""


class PoetryInstallationError(RuntimeError):
def __init__(self, return_code: int = 0, log: Optional[str] = None):
super(PoetryInstallationError, self).__init__()
self.return_code = return_code
self.log = log


class VirtualEnvironment:
def __init__(self, path: Path) -> None:
self._path = path
self._python = self._path.joinpath(
"Scripts/python.exe" if WINDOWS else "bin/python"
)

@property
def path(self):
return self._path

@classmethod
def make(cls, target: Path) -> "VirtualEnvironment":
import venv

builder = venv.EnvBuilder(clear=True, with_pip=True, symlinks=False)
builder.ensure_directories(target)
builder.create(target)

env = cls(target)

# we do this here to ensure that outdated system default pip does not trigger older bugs
env.pip("install", "--upgrade", "pip")

return cls(target)

@staticmethod
def run(*args, **kwargs) -> subprocess.CompletedProcess:
completed_process = subprocess.run(
args,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
**{
"env": {},
**kwargs,
},
)
if completed_process.returncode != 0:
raise PoetryInstallationError(
return_code=completed_process.returncode,
log=completed_process.stdout.decode(),
)
return completed_process

def python(self, *args, **kwargs) -> subprocess.CompletedProcess:
return self.run(self._python, *args, **kwargs)

def pip(self, *args, **kwargs) -> subprocess.CompletedProcess:
return self.python("-m", "pip", "--isolated", *args, **kwargs)


class Cursor:
def __init__(self) -> None:
self._output = sys.stdout
Expand Down Expand Up @@ -416,10 +459,9 @@ def run(self) -> int:
try:
self.install(version)
except subprocess.CalledProcessError as e:
print(colorize("error", "An error has occured: {}".format(str(e))))
print(e.output.decode())

return e.returncode
raise PoetryInstallationError(
return_code=e.returncode, log=e.output.decode()
)

self._write("")
self.display_post_message(version)
Expand All @@ -436,21 +478,21 @@ def install(self, version, upgrade=False):
)
)

env_path = self.make_env(version)
self.install_poetry(version, env_path)
self.make_bin(version)
with self.make_env(version) as env:
self.install_poetry(version, env)
self.make_bin(version)

self._overwrite(
"Installing {} ({}): {}".format(
colorize("info", "Poetry"),
colorize("b", version),
colorize("success", "Done"),
self._overwrite(
"Installing {} ({}): {}".format(
colorize("info", "Poetry"),
colorize("b", version),
colorize("success", "Done"),
)
)
)

self._data_dir.joinpath("VERSION").write_text(version)
self._data_dir.joinpath("VERSION").write_text(version)

return 0
return 0

def uninstall(self) -> int:
if not self._data_dir.exists():
Expand Down Expand Up @@ -480,41 +522,49 @@ def uninstall(self) -> int:

return 0

def make_env(self, version: str) -> Path:
def _install_comment(self, version: str, message: str):
self._overwrite(
"Installing {} ({}): {}".format(
colorize("info", "Poetry"),
colorize("b", version),
colorize("comment", "Creating environment"),
colorize("comment", message),
)
)

@contextmanager
def make_env(self, version: str) -> VirtualEnvironment:
env_path = self._data_dir.joinpath("venv")
env_path_saved = env_path.with_suffix(".save")

with temporary_directory() as tmp_dir:
subprocess.call(
[sys.executable, "-m", "pip", "install", "virtualenv", "-t", tmp_dir],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
)

sys.path.insert(0, tmp_dir)
if env_path.exists():
self._install_comment(version, "Saving existing environment")
if env_path_saved.exists():
shutil.rmtree(env_path_saved)
shutil.move(env_path, env_path_saved)

import virtualenv
try:
self._install_comment(version, "Creating environment")
yield VirtualEnvironment.make(env_path)
except Exception as e: # noqa
if env_path.exists():
self._install_comment(
version, "An error occurred. Removing partial environment."
)
shutil.rmtree(env_path)

virtualenv.cli_run([str(env_path), "--clear"])
if env_path_saved.exists():
self._install_comment(
version, "Restoring previously saved environment."
)
shutil.move(env_path_saved, env_path)

return env_path
raise e
else:
if env_path_saved.exists():
shutil.rmtree(env_path_saved, ignore_errors=True)

def make_bin(self, version: str) -> None:
self._overwrite(
"Installing {} ({}): {}".format(
colorize("info", "Poetry"),
colorize("b", version),
colorize("comment", "Creating script"),
)
)

self._install_comment(version, "Creating script")
self._bin_dir.mkdir(parents=True, exist_ok=True)

script = "poetry"
Expand All @@ -537,19 +587,8 @@ def make_bin(self, version: str) -> None:
self._data_dir.joinpath(target_script), self._bin_dir.joinpath(script)
)

def install_poetry(self, version: str, env_path: Path) -> None:
self._overwrite(
"Installing {} ({}): {}".format(
colorize("info", "Poetry"),
colorize("b", version),
colorize("comment", "Installing Poetry"),
)
)

if WINDOWS:
python = env_path.joinpath("Scripts/python.exe")
else:
python = env_path.joinpath("bin/python")
def install_poetry(self, version: str, env: VirtualEnvironment) -> None:
self._install_comment(version, "Installing Poetry")

if self._git:
specification = "git+" + version
Expand All @@ -558,11 +597,7 @@ def install_poetry(self, version: str, env_path: Path) -> None:
else:
specification = f"poetry=={version}"

subprocess.run(
[str(python), "-m", "pip", "install", specification],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
)
env.pip("install", specification)

def display_pre_message(self) -> None:
kwargs = {
Expand Down Expand Up @@ -801,7 +836,22 @@ def main():
if args.uninstall or string_to_bool(os.getenv("POETRY_UNINSTALL", "0")):
return installer.uninstall()

return installer.run()
try:
return installer.run()
except PoetryInstallationError as e:
installer._write(colorize("error", "Poetry installation failed.")) # noqa

if e.log is not None:
import traceback

_, path = tempfile.mkstemp(
suffix=".log", prefix="poetry-installer-error-", text=True
)
installer._write(colorize("error", f"See {path} for error logs.")) # noqa
text = f"{e.log}\nTraceback:\n\n{''.join(traceback.format_tb(e.__traceback__))}"
Path(path).write_text(text)

return e.return_code


if __name__ == "__main__":
Expand Down

0 comments on commit 5ec9f35

Please sign in to comment.