Skip to content

Commit 75536e9

Browse files
abnneersighted
andauthored
installer: use venv and improve error handling (#4099)
* installer: improve error handling and logging Co-authored-by: Bjorn Neergaard <bjorn@neersighted.com> * installer: default to using in-built venv module This change refactors virtual environment creation and introduces a new wrapper to handle command execution etc. If venv module is not available, in cases like that of the ubuntu distribution where the module is not part of the default python install, the virtualenv package is used by fetching the appropriate zipapp for the package for the python runtime. Resolves: #4089 Resolves: #4093 * ci/installer: upload failure logs * installer: preserve existing environment on failure This change ensures that if creation of a new environment fails, any previously existing environment is restored. Co-authored-by: Bjorn Neergaard <bjorn@neersighted.com>
1 parent 02b8003 commit 75536e9

2 files changed

Lines changed: 152 additions & 90 deletions

File tree

.github/workflows/installer.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,13 @@ jobs:
4949
shell: bash
5050
run: python install-poetry.py -y
5151

52+
- name: Upload Failure Log
53+
uses: actions/upload-artifact@v2
54+
if: failure()
55+
with:
56+
name: poetry-installer-error.log
57+
path: poetry-installer-error-*.log
58+
5259
- name: Verify Installation
5360
shell: bash
5461
run: |

install-poetry.py

Lines changed: 145 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,15 @@
33
44
It does, in order:
55
6-
- Downloads the virtualenv package to a temporary directory and add it to sys.path.
7-
- Creates a virtual environment in the correct OS data dir which will be
6+
- Creates a virtual environment using venv (or virtualenv zipapp) in the correct OS data dir which will be
87
- `%APPDATA%\\pypoetry` on Windows
98
- ~/Library/Application Support/pypoetry on MacOS
109
- `${XDG_DATA_HOME}/pypoetry` (or `~/.local/share/pypoetry` if it's not set) on UNIX systems
1110
- In `${POETRY_HOME}` if it's set.
1211
- Installs the latest or given version of Poetry inside this virtual environment.
1312
- Installs a `poetry` script in the Python user directory (or `${POETRY_HOME/bin}` if `POETRY_HOME` is set).
13+
- On failure, the error log is written to poetry-installer-error-*.log and any previously existing environment
14+
is restored.
1415
"""
1516

1617
import argparse
@@ -219,21 +220,6 @@ def _get_win_folder_with_ctypes(csidl_name):
219220
_get_win_folder = _get_win_folder_from_registry
220221

221222

222-
@contextmanager
223-
def temporary_directory(*args, **kwargs):
224-
try:
225-
from tempfile import TemporaryDirectory
226-
except ImportError:
227-
name = tempfile.mkdtemp(*args, **kwargs)
228-
229-
yield name
230-
231-
shutil.rmtree(name)
232-
else:
233-
with TemporaryDirectory(*args, **kwargs) as name:
234-
yield name
235-
236-
237223
PRE_MESSAGE = """# Welcome to {poetry}!
238224
239225
This will download and install the latest version of {poetry},
@@ -277,6 +263,83 @@ def temporary_directory(*args, **kwargs):
277263
POST_MESSAGE_CONFIGURE_WINDOWS = """"""
278264

279265

266+
class PoetryInstallationError(RuntimeError):
267+
def __init__(self, return_code: int = 0, log: Optional[str] = None):
268+
super(PoetryInstallationError, self).__init__()
269+
self.return_code = return_code
270+
self.log = log
271+
272+
273+
class VirtualEnvironment:
274+
def __init__(self, path: Path) -> None:
275+
self._path = path
276+
# str is required for compatibility with subprocess run on CPython <= 3.7 on Windows
277+
self._python = str(
278+
self._path.joinpath("Scripts/python.exe" if WINDOWS else "bin/python")
279+
)
280+
281+
@property
282+
def path(self):
283+
return self._path
284+
285+
@classmethod
286+
def make(cls, target: Path) -> "VirtualEnvironment":
287+
try:
288+
import venv
289+
290+
builder = venv.EnvBuilder(clear=True, with_pip=True, symlinks=False)
291+
builder.ensure_directories(target)
292+
builder.create(target)
293+
except ImportError:
294+
# fallback to using virtualenv package if venv is not available, eg: ubuntu
295+
python_version = f"{sys.version_info.major}.{sys.version_info.minor}"
296+
virtualenv_bootstrap_url = (
297+
f"https://bootstrap.pypa.io/virtualenv/{python_version}/virtualenv.pyz"
298+
)
299+
300+
with tempfile.TemporaryDirectory(prefix="poetry-installer") as temp_dir:
301+
virtualenv_pyz = Path(temp_dir) / "virtualenv.pyz"
302+
request = Request(
303+
virtualenv_bootstrap_url, headers={"User-Agent": "Python Poetry"}
304+
)
305+
virtualenv_pyz.write_bytes(urlopen(request).read())
306+
cls.run(
307+
sys.executable, virtualenv_pyz, "--clear", "--always-copy", target
308+
)
309+
310+
# We add a special file so that Poetry can detect
311+
# its own virtual environment
312+
target.joinpath("poetry_env").touch()
313+
314+
env = cls(target)
315+
316+
# we do this here to ensure that outdated system default pip does not trigger older bugs
317+
env.pip("install", "--disable-pip-version-check", "--upgrade", "pip")
318+
319+
return env
320+
321+
@staticmethod
322+
def run(*args, **kwargs) -> subprocess.CompletedProcess:
323+
completed_process = subprocess.run(
324+
args,
325+
stdout=subprocess.PIPE,
326+
stderr=subprocess.STDOUT,
327+
**kwargs,
328+
)
329+
if completed_process.returncode != 0:
330+
raise PoetryInstallationError(
331+
return_code=completed_process.returncode,
332+
log=completed_process.stdout.decode(),
333+
)
334+
return completed_process
335+
336+
def python(self, *args, **kwargs) -> subprocess.CompletedProcess:
337+
return self.run(self._python, *args, **kwargs)
338+
339+
def pip(self, *args, **kwargs) -> subprocess.CompletedProcess:
340+
return self.python("-m", "pip", "--isolated", *args, **kwargs)
341+
342+
280343
class Cursor:
281344
def __init__(self) -> None:
282345
self._output = sys.stdout
@@ -439,12 +502,10 @@ def _is_self_upgrade_supported(x):
439502
try:
440503
self.install(version)
441504
except subprocess.CalledProcessError as e:
442-
print(
443-
colorize("error", f"\nAn error has occurred: {e}\n{e.stdout.decode()}")
505+
raise PoetryInstallationError(
506+
return_code=e.returncode, log=e.output.decode()
444507
)
445508

446-
return e.returncode
447-
448509
self._write("")
449510
self.display_post_message(version)
450511

@@ -460,21 +521,13 @@ def install(self, version, upgrade=False):
460521
)
461522
)
462523

463-
env_path = self.make_env(version)
464-
self.install_poetry(version, env_path)
465-
self.make_bin(version)
524+
with self.make_env(version) as env:
525+
self.install_poetry(version, env)
526+
self.make_bin(version, env)
527+
self._data_dir.joinpath("VERSION").write_text(version)
528+
self._install_comment(version, "Done")
466529

467-
self._overwrite(
468-
"Installing {} ({}): {}".format(
469-
colorize("info", "Poetry"),
470-
colorize("b", version),
471-
colorize("success", "Done"),
472-
)
473-
)
474-
475-
self._data_dir.joinpath("VERSION").write_text(version)
476-
477-
return 0
530+
return 0
478531

479532
def uninstall(self) -> int:
480533
if not self._data_dir.exists():
@@ -504,81 +557,70 @@ def uninstall(self) -> int:
504557

505558
return 0
506559

507-
def make_env(self, version: str) -> Path:
560+
def _install_comment(self, version: str, message: str):
508561
self._overwrite(
509562
"Installing {} ({}): {}".format(
510563
colorize("info", "Poetry"),
511564
colorize("b", version),
512-
colorize("comment", "Creating environment"),
565+
colorize("comment", message),
513566
)
514567
)
515568

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

518-
with temporary_directory() as tmp_dir:
519-
subprocess.run(
520-
[sys.executable, "-m", "pip", "install", "virtualenv", "-t", tmp_dir],
521-
stdout=subprocess.PIPE,
522-
stderr=subprocess.STDOUT,
523-
check=True,
524-
)
525-
526-
sys.path.insert(0, tmp_dir)
527-
528-
import virtualenv
574+
if env_path.exists():
575+
self._install_comment(version, "Saving existing environment")
576+
if env_path_saved.exists():
577+
shutil.rmtree(env_path_saved)
578+
shutil.move(env_path, env_path_saved)
529579

530-
virtualenv.cli_run([str(env_path), "--clear"])
531-
532-
# We add a special file so that Poetry can detect
533-
# its own virtual environment
534-
env_path.joinpath("poetry_env").touch()
580+
try:
581+
self._install_comment(version, "Creating environment")
582+
yield VirtualEnvironment.make(env_path)
583+
except Exception as e: # noqa
584+
if env_path.exists():
585+
self._install_comment(
586+
version, "An error occurred. Removing partial environment."
587+
)
588+
shutil.rmtree(env_path)
535589

536-
return env_path
590+
if env_path_saved.exists():
591+
self._install_comment(
592+
version, "Restoring previously saved environment."
593+
)
594+
shutil.move(env_path_saved, env_path)
537595

538-
def make_bin(self, version: str) -> None:
539-
self._overwrite(
540-
"Installing {} ({}): {}".format(
541-
colorize("info", "Poetry"),
542-
colorize("b", version),
543-
colorize("comment", "Creating script"),
544-
)
545-
)
596+
raise e
597+
else:
598+
if env_path_saved.exists():
599+
shutil.rmtree(env_path_saved, ignore_errors=True)
546600

601+
def make_bin(self, version: str, env: VirtualEnvironment) -> None:
602+
self._install_comment(version, "Creating script")
547603
self._bin_dir.mkdir(parents=True, exist_ok=True)
548604

549605
script = "poetry"
550-
target_script = "venv/bin/poetry"
606+
script_bin = "bin"
551607
if WINDOWS:
552608
script = "poetry.exe"
553-
target_script = "venv/Scripts/poetry.exe"
609+
script_bin = "Scripts"
610+
target_script = env.path.joinpath(script_bin, script)
554611

555612
if self._bin_dir.joinpath(script).exists():
556613
self._bin_dir.joinpath(script).unlink()
557614

558615
try:
559-
self._bin_dir.joinpath(script).symlink_to(
560-
self._data_dir.joinpath(target_script)
561-
)
616+
self._bin_dir.joinpath(script).symlink_to(target_script)
562617
except OSError:
563618
# This can happen if the user
564619
# does not have the correct permission on Windows
565-
shutil.copy(
566-
self._data_dir.joinpath(target_script), self._bin_dir.joinpath(script)
567-
)
620+
shutil.copy(target_script, self._bin_dir.joinpath(script))
568621

569-
def install_poetry(self, version: str, env_path: Path) -> None:
570-
self._overwrite(
571-
"Installing {} ({}): {}".format(
572-
colorize("info", "Poetry"),
573-
colorize("b", version),
574-
colorize("comment", "Installing Poetry"),
575-
)
576-
)
577-
578-
if WINDOWS:
579-
python = env_path.joinpath("Scripts/python.exe")
580-
else:
581-
python = env_path.joinpath("bin/python")
622+
def install_poetry(self, version: str, env: VirtualEnvironment) -> None:
623+
self._install_comment(version, "Installing Poetry")
582624

583625
if self._git:
584626
specification = "git+" + version
@@ -587,12 +629,7 @@ def install_poetry(self, version: str, env_path: Path) -> None:
587629
else:
588630
specification = f"poetry=={version}"
589631

590-
subprocess.run(
591-
[str(python), "-m", "pip", "install", specification],
592-
stdout=subprocess.PIPE,
593-
stderr=subprocess.STDOUT,
594-
check=True,
595-
)
632+
env.pip("install", specification)
596633

597634
def display_pre_message(self) -> None:
598635
kwargs = {
@@ -831,7 +868,25 @@ def main():
831868
if args.uninstall or string_to_bool(os.getenv("POETRY_UNINSTALL", "0")):
832869
return installer.uninstall()
833870

834-
return installer.run()
871+
try:
872+
return installer.run()
873+
except PoetryInstallationError as e:
874+
installer._write(colorize("error", "Poetry installation failed.")) # noqa
875+
876+
if e.log is not None:
877+
import traceback
878+
879+
_, path = tempfile.mkstemp(
880+
suffix=".log",
881+
prefix="poetry-installer-error-",
882+
dir=str(Path.cwd()),
883+
text=True,
884+
)
885+
installer._write(colorize("error", f"See {path} for error logs.")) # noqa
886+
text = f"{e.log}\nTraceback:\n\n{''.join(traceback.format_tb(e.__traceback__))}"
887+
Path(path).write_text(text)
888+
889+
return e.return_code
835890

836891

837892
if __name__ == "__main__":

0 commit comments

Comments
 (0)