Skip to content

Commit 87e6e0c

Browse files
puneetdixit200Deepak kudi
andauthored
Restore package after interrupted reinstall (#1829)
Co-authored-by: Deepak kudi <deepakkudi23@adsl-172-10-9-116.dsl.sndg02.sbcglobal.net>
1 parent 8321e58 commit 87e6e0c

4 files changed

Lines changed: 165 additions & 40 deletions

File tree

changelog.d/966.bugfix.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Restore the original package if `pipx reinstall-all` is interrupted during reinstall.

src/pipx/commands/reinstall.py

Lines changed: 103 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,73 @@
11
import sys
22
from collections.abc import Sequence
33
from pathlib import Path
4+
from tempfile import mkdtemp
45

56
from packaging.utils import canonicalize_name
67

8+
from pipx.commands.common import add_suffix
79
from pipx.commands.inject import inject_dep
810
from pipx.commands.install import install
9-
from pipx.commands.uninstall import uninstall
11+
from pipx.commands.uninstall import _get_venv_resource_paths
1012
from pipx.constants import (
1113
EXIT_CODE_OK,
1214
EXIT_CODE_REINSTALL_INVALID_PYTHON,
1315
EXIT_CODE_REINSTALL_VENV_NONEXISTENT,
16+
MAN_SECTIONS,
1417
ExitCode,
1518
)
16-
from pipx.emojis import error, sleep
17-
from pipx.util import PipxError
19+
from pipx.emojis import error, sleep, stars
20+
from pipx.util import PipxError, rmdir, safe_unlink
1821
from pipx.venv import Venv, VenvContainer
1922

2023

24+
def _create_reinstall_backup(venv_dir: Path) -> Path:
25+
backup_dir = Path(mkdtemp(prefix=f".{venv_dir.name}-", suffix="-pipx-reinstall", dir=venv_dir.parent))
26+
backup_dir.rmdir()
27+
venv_dir.rename(backup_dir)
28+
return backup_dir
29+
30+
31+
def _restore_reinstall_backup(venv_dir: Path, restore_venv_dir: Path, backup_dir: Path) -> None:
32+
rmdir(venv_dir)
33+
backup_dir.rename(restore_venv_dir)
34+
35+
36+
def _get_reinstall_resource_paths(venv: Venv, local_bin_dir: Path, local_man_dir: Path) -> set[Path]:
37+
resource_paths = _get_venv_resource_paths("app", venv, venv.bin_path, local_bin_dir)
38+
for man_section in MAN_SECTIONS:
39+
resource_paths |= _get_venv_resource_paths("man", venv, venv.man_path / man_section, local_man_dir / man_section)
40+
return resource_paths
41+
42+
43+
def _get_expected_reinstall_resource_paths(venv: Venv, local_bin_dir: Path, local_man_dir: Path) -> set[Path]:
44+
resource_paths: set[Path] = set()
45+
for package_info in venv.package_metadata.values():
46+
if package_info.include_apps:
47+
for app_path in package_info.app_paths:
48+
resource_paths.add(local_bin_dir / add_suffix(app_path.name, package_info.suffix))
49+
for man_path in package_info.man_paths:
50+
resource_paths.add(local_man_dir / man_path.parent.name / man_path.name)
51+
if package_info.include_dependencies:
52+
for app_paths in package_info.app_paths_of_dependencies.values():
53+
for app_path in app_paths:
54+
resource_paths.add(local_bin_dir / add_suffix(app_path.name, package_info.suffix))
55+
for man_paths in package_info.man_paths_of_dependencies.values():
56+
for man_path in man_paths:
57+
resource_paths.add(local_man_dir / man_path.parent.name / man_path.name)
58+
return resource_paths
59+
60+
61+
def _remove_stale_reinstall_resources(resource_paths: set[Path]) -> None:
62+
for path in sorted(resource_paths):
63+
try:
64+
safe_unlink(path)
65+
if path.is_symlink():
66+
path.unlink()
67+
except FileNotFoundError:
68+
pass
69+
70+
2171
def reinstall(
2272
*,
2373
venv_dir: Path,
@@ -59,51 +109,66 @@ def reinstall(
59109
if venv.pipx_metadata.main_package.pinned:
60110
raise PipxError(f"{error} Package {venv_dir} is pinned. Run `pipx unpin {venv_dir.name}` to unpin it first.")
61111

62-
uninstall(venv_dir, local_bin_dir, local_man_dir, verbose)
112+
old_resource_paths = _get_reinstall_resource_paths(venv, local_bin_dir, local_man_dir)
113+
original_venv_dir = venv_dir
114+
reinstall_backup_dir = _create_reinstall_backup(venv_dir)
115+
print(f"uninstalled {venv.name}! {stars}")
63116

64117
# in case legacy original dir name
65118
venv_dir = venv_dir.with_name(canonicalize_name(venv_dir.name))
66119

67-
# install main package first
68-
install(
69-
venv_dir,
70-
[venv.main_package_name],
71-
[package_or_url],
72-
local_bin_dir,
73-
local_man_dir,
74-
python,
75-
venv.pipx_metadata.main_package.pip_args,
76-
venv.pipx_metadata.venv_args,
77-
verbose,
78-
force=True,
79-
reinstall=True,
80-
include_dependencies=venv.pipx_metadata.main_package.include_dependencies,
81-
preinstall_packages=[],
82-
suffix=venv.pipx_metadata.main_package.suffix,
83-
python_flag_passed=python_flag_passed,
84-
backend=backend or venv.pipx_metadata.backend,
85-
env_backend=env_backend,
86-
)
87-
88-
# now install injected packages
89-
for injected_name, injected_package in venv.pipx_metadata.injected_packages.items():
90-
if injected_package.package_or_url is None:
91-
# This should never happen, but package_or_url is type
92-
# Optional[str] so mypy thinks it could be None
93-
raise PipxError(f"Internal Error injecting package {injected_package} into {venv.name}")
94-
inject_dep(
120+
try:
121+
# install main package first
122+
install(
95123
venv_dir,
96-
injected_name,
97-
injected_package.package_or_url,
98-
injected_package.pip_args,
99-
verbose=verbose,
100-
include_apps=injected_package.include_apps,
101-
include_dependencies=injected_package.include_dependencies,
124+
[venv.main_package_name],
125+
[package_or_url],
126+
local_bin_dir,
127+
local_man_dir,
128+
python,
129+
venv.pipx_metadata.main_package.pip_args,
130+
venv.pipx_metadata.venv_args,
131+
verbose,
102132
force=True,
133+
reinstall=True,
134+
include_dependencies=venv.pipx_metadata.main_package.include_dependencies,
135+
preinstall_packages=[],
136+
suffix=venv.pipx_metadata.main_package.suffix,
137+
python_flag_passed=python_flag_passed,
103138
backend=backend or venv.pipx_metadata.backend,
104139
env_backend=env_backend,
105140
)
106141

142+
# now install injected packages
143+
for injected_name, injected_package in venv.pipx_metadata.injected_packages.items():
144+
if injected_package.package_or_url is None:
145+
# This should never happen, but package_or_url is type
146+
# Optional[str] so mypy thinks it could be None
147+
raise PipxError(f"Internal Error injecting package {injected_package} into {venv.name}")
148+
inject_dep(
149+
venv_dir,
150+
injected_name,
151+
injected_package.package_or_url,
152+
injected_package.pip_args,
153+
verbose=verbose,
154+
include_apps=injected_package.include_apps,
155+
include_dependencies=injected_package.include_dependencies,
156+
force=True,
157+
backend=backend or venv.pipx_metadata.backend,
158+
env_backend=env_backend,
159+
)
160+
161+
new_resource_paths = _get_expected_reinstall_resource_paths(
162+
Venv(venv_dir, verbose=verbose), local_bin_dir, local_man_dir
163+
)
164+
_remove_stale_reinstall_resources(old_resource_paths - new_resource_paths)
165+
except (Exception, KeyboardInterrupt):
166+
_restore_reinstall_backup(venv_dir, original_venv_dir, reinstall_backup_dir)
167+
print(f"{error} Reinstall failed; restored {venv.name}.", file=sys.stderr)
168+
raise
169+
else:
170+
rmdir(reinstall_backup_dir)
171+
107172
# Any failure to install will raise PipxError, otherwise success
108173
return EXIT_CODE_OK
109174

tests/test_reinstall.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import sys
2+
from dataclasses import replace
23

34
import pytest
45

5-
from helpers import PIPX_METADATA_LEGACY_VERSIONS, mock_legacy_venv, run_pipx_cli, skip_if_windows
6+
from helpers import PIPX_METADATA_LEGACY_VERSIONS, app_name, mock_legacy_venv, run_pipx_cli, skip_if_windows
7+
from pipx import paths, util
8+
from pipx.pipx_metadata_file import PipxMetadata
69

710

811
def test_reinstall(pipx_temp_env, capsys):
@@ -56,6 +59,34 @@ def test_reinstall_specifier(pipx_temp_env, capsys):
5659
assert "installed package pylint 3.0.4" in captured.out
5760

5861

62+
@skip_if_windows
63+
def test_reinstall_removes_stale_apps_after_success(pipx_temp_env, capsys):
64+
assert not run_pipx_cli(["install", "pycowsay"])
65+
capsys.readouterr()
66+
67+
venv_dir = paths.ctx.venvs / "pycowsay"
68+
stale_app_name = app_name("removed-cowsay")
69+
stale_app_path = util.get_venv_paths(venv_dir)[0] / stale_app_name
70+
stale_app_path.write_text("#!/bin/sh\n")
71+
stale_app_path.chmod(0o755)
72+
73+
stale_exposed_path = paths.ctx.bin_dir / stale_app_name
74+
stale_exposed_path.symlink_to(stale_app_path)
75+
76+
metadata = PipxMetadata(venv_dir)
77+
metadata.main_package = replace(
78+
metadata.main_package,
79+
apps=[*metadata.main_package.apps, stale_app_name],
80+
app_paths=[*metadata.main_package.app_paths, stale_app_path],
81+
)
82+
metadata.write()
83+
84+
assert not run_pipx_cli(["reinstall", "--python", sys.executable, "pycowsay"])
85+
86+
assert not stale_exposed_path.exists()
87+
assert not stale_exposed_path.is_symlink()
88+
89+
5990
def test_reinstall_with_path(pipx_temp_env, capsys, tmp_path):
6091
path = tmp_path / "some" / "path"
6192

tests/test_reinstall_all.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1+
import importlib
12
import sys
23

34
import pytest
45

56
from helpers import PIPX_METADATA_LEGACY_VERSIONS, mock_legacy_venv, run_pipx_cli
6-
from pipx import shared_libs
7+
from pipx import paths, shared_libs
78

89

910
def test_reinstall_all(pipx_temp_env, capsys):
@@ -49,3 +50,30 @@ def test_reinstall_all_triggers_shared_libs_upgrade(pipx_temp_env, caplog, capsy
4950

5051
assert not run_pipx_cli(["reinstall-all"])
5152
assert "Upgrading shared libraries in" in caplog.text
53+
54+
55+
def test_reinstall_all_restores_package_after_keyboard_interrupt(pipx_temp_env, monkeypatch, capsys):
56+
reinstall_module = importlib.import_module("pipx.commands.reinstall")
57+
58+
assert not run_pipx_cli(["install", "pycowsay"])
59+
capsys.readouterr()
60+
61+
venv_dir = paths.ctx.venvs / "pycowsay"
62+
metadata_before = (venv_dir / "pipx_metadata.json").read_text()
63+
64+
def interrupting_install(replacement_venv_dir, *args, **kwargs):
65+
assert not venv_dir.exists()
66+
replacement_venv_dir.mkdir()
67+
(replacement_venv_dir / "partial-install").write_text("new")
68+
raise KeyboardInterrupt
69+
70+
monkeypatch.setattr(reinstall_module, "install", interrupting_install)
71+
72+
assert run_pipx_cli(["reinstall-all", "--python", sys.executable])
73+
captured = capsys.readouterr()
74+
75+
assert "Reinstall failed; restored pycowsay." in captured.err
76+
assert venv_dir.exists()
77+
assert (venv_dir / "pipx_metadata.json").read_text() == metadata_before
78+
assert not (venv_dir / "partial-install").exists()
79+
assert not any(path.name.endswith("-pipx-reinstall") for path in paths.ctx.venvs.iterdir())

0 commit comments

Comments
 (0)