From 5ec9f35f9890cb207a35a90e0c54e933dfab9357 Mon Sep 17 00:00:00 2001 From: Arun Babu Neelicattu Date: Tue, 25 May 2021 00:07:40 +0200 Subject: [PATCH] installer: use venv and improve error handling 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. --- install-poetry.py | 192 +++++++++++++++++++++++++++++----------------- 1 file changed, 121 insertions(+), 71 deletions(-) diff --git a/install-poetry.py b/install-poetry.py index ba67d3df8c8..fafdf9c7754 100644 --- a/install-poetry.py +++ b/install-poetry.py @@ -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). """ @@ -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}, @@ -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 @@ -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) @@ -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(): @@ -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" @@ -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 @@ -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 = { @@ -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__":