Skip to content

Commit 09ff69f

Browse files
committed
gh-300: Improve purge handling of unrecognized files
1 parent 526f036 commit 09ff69f

4 files changed

Lines changed: 107 additions & 3 deletions

File tree

src/manage/fsutils.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,16 @@ def ensure_tree(path, overwrite_files=True):
2121
path.parent.mkdir(parents=True, exist_ok=True)
2222

2323

24-
def _rglob(root):
24+
def _rglob(root, follow_links=True):
2525
q = [root]
2626
while q:
2727
r = q.pop(0)
2828
for f in os.scandir(r):
2929
p = r / f.name
30-
if f.is_dir():
30+
is_dir = f.is_dir(follow_symlinks=follow_links)
31+
if not follow_links and f.is_junction():
32+
is_dir = False
33+
if is_dir:
3134
q.append(p)
3235
yield p, None
3336
else:
@@ -92,6 +95,13 @@ def rmtree(path, after_5s_warning=None, remove_ext_first=()):
9295

9396
if isinstance(path, (str, bytes)):
9497
path = Path(path)
98+
if os.path.islink(path):
99+
unlink(path, after_5s_warning=after_5s_warning)
100+
return
101+
if os.path.isjunction(path):
102+
LOGGER.debug("Removing junction without traversing it: %s", path)
103+
_rmdir(path, on_fail=lambda p: LOGGER.warn("Failed to remove %s", p))
104+
return
95105
if not path.is_dir():
96106
if path.is_file():
97107
unlink(path)
@@ -131,7 +141,7 @@ def rmtree(path, after_5s_warning=None, remove_ext_first=()):
131141

132142
to_rmdir = [path]
133143
to_unlink = []
134-
for d, f in _rglob(path):
144+
for d, f in _rglob(path, follow_links=False):
135145
if after_5s_warning and (time.monotonic() - start) > 5:
136146
LOGGER.warn(after_5s_warning)
137147
after_5s_warning = None

src/manage/uninstall_command.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ def execute(cmd):
8686
if not cmd.ask_yn("Uninstall all runtimes?"):
8787
LOGGER.debug("END uninstall_command.execute")
8888
return
89+
installs_in_use = set()
8990
for i in installed:
9091
LOGGER.info("Purging %s from %s", i["display-name"], i["prefix"])
9192
try:
@@ -97,6 +98,7 @@ def execute(cmd):
9798
except FilesInUseError:
9899
LOGGER.warn("Unable to purge %s because it is still in use.",
99100
i["display-name"])
101+
installs_in_use.add(Path(i["prefix"]))
100102
continue
101103
LOGGER.info("Purging saved downloads from %s", cmd.download_dir)
102104
rmtree(cmd.download_dir, after_5s_warning=warn_msg.format("cached downloads"))
@@ -106,6 +108,14 @@ def execute(cmd):
106108
for _, cleanup in SHORTCUT_HANDLERS.values():
107109
if cleanup:
108110
cleanup(cmd, [])
111+
unknown = [p for p in _iterdir(cmd.install_dir)
112+
if p not in installs_in_use]
113+
if unknown:
114+
LOGGER.info("Purging unrecognized files from %s", cmd.install_dir)
115+
for p in unknown:
116+
rmtree(p, after_5s_warning=warn_msg.format(
117+
"unrecognized files"
118+
))
109119
LOGGER.debug("END uninstall_command.execute")
110120
return
111121

tests/test_fsutils.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import pytest
22
import shutil
3+
import _winapi
34

45
from copy import copy
56

@@ -58,6 +59,36 @@ def test_rmtree(tree):
5859
assert not tree.exists()
5960

6061

62+
def test_rmtree_junction(tmp_path):
63+
target = tmp_path / "target"
64+
target.mkdir()
65+
target_file = target / "preserve.txt"
66+
target_file.write_bytes(b"preserve")
67+
junction = tmp_path / "junction"
68+
_winapi.CreateJunction(str(target), str(junction))
69+
70+
rmtree(junction)
71+
72+
assert not junction.exists()
73+
assert target_file.read_bytes() == b"preserve"
74+
75+
76+
def test_rmtree_nested_junction(tmp_path):
77+
target = tmp_path / "target"
78+
target.mkdir()
79+
target_file = target / "preserve.txt"
80+
target_file.write_bytes(b"preserve")
81+
root = tmp_path / "root"
82+
root.mkdir()
83+
junction = root / "junction"
84+
_winapi.CreateJunction(str(target), str(junction))
85+
86+
rmtree(root)
87+
88+
assert not root.exists()
89+
assert target_file.read_bytes() == b"preserve"
90+
91+
6192
def test_atomic_unlink(tree):
6293
files = [tree / "c/d", tree / "b"]
6394
assert all([f.is_file() for f in files])

tests/test_uninstall_command.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from pathlib import Path
66

77
from manage import uninstall_command as UC
8+
from manage.exceptions import FilesInUseError
89

910

1011
def test_purge_global_dir(monkeypatch, registry, tmp_path):
@@ -25,3 +26,55 @@ def test_null_purge(fake_config):
2526
cmd.confirm = False
2627
cmd.purge = True
2728
UC.execute(cmd)
29+
30+
31+
def test_purge_unknown_files(fake_config):
32+
cmd = fake_config
33+
cmd.args = ["--purge"]
34+
cmd.confirm = False
35+
cmd.purge = True
36+
37+
unknown_file = cmd.install_dir / "unknown.txt"
38+
unknown_file.write_bytes(b"unknown")
39+
broken_runtime = cmd.install_dir / "broken-runtime"
40+
broken_runtime.mkdir()
41+
(broken_runtime / "__install__.json").write_text("invalid")
42+
43+
UC.execute(cmd)
44+
45+
assert not unknown_file.exists()
46+
assert not broken_runtime.exists()
47+
48+
49+
def test_purge_preserves_runtime_in_use(fake_config, monkeypatch):
50+
cmd = fake_config
51+
cmd.args = ["--purge"]
52+
cmd.confirm = False
53+
cmd.purge = True
54+
55+
runtime = cmd.install_dir / "runtime"
56+
runtime.mkdir()
57+
executable = runtime / "python.exe"
58+
executable.write_bytes(b"in use")
59+
cmd.installs = [{
60+
"display-name": "Runtime in use",
61+
"prefix": runtime,
62+
}]
63+
64+
unknown_dir = cmd.install_dir / "unknown"
65+
unknown_dir.mkdir()
66+
(unknown_dir / "file.txt").write_bytes(b"unknown")
67+
68+
rmtree = UC.rmtree
69+
70+
def locked_rmtree(path, *args, **kwargs):
71+
if Path(path) == runtime:
72+
raise FilesInUseError([executable])
73+
return rmtree(path, *args, **kwargs)
74+
75+
monkeypatch.setattr(UC, "rmtree", locked_rmtree)
76+
77+
UC.execute(cmd)
78+
79+
assert executable.is_file()
80+
assert not unknown_dir.exists()

0 commit comments

Comments
 (0)