Skip to content

Commit c31f19f

Browse files
committed
Fixes pypa#6169: AdjacentTempDirectory should fail on unwritable directory
1 parent b5dd279 commit c31f19f

File tree

6 files changed

+163
-42
lines changed

6 files changed

+163
-42
lines changed

.gitignore

+4
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,13 @@ tests/data/common_wheels/
3131
# Misc
3232
*~
3333
.*.sw?
34+
.env/
3435

3536
# For IntelliJ IDEs (basically PyCharm)
3637
.idea/
3738

39+
# For Visual Studio Code
40+
.vscode/
41+
3842
# Scratch Pad for experiments
3943
.scratch/

news/6169.bugfix

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
``AdjacentTempDirectory`` fails on unwritable directory instead of locking up the uninstall command.

news/6194.bugfix

+1-1
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
Make failed uninstalls roll back more reliably and better at avoiding naming conflicts.
1+
Make failed uninstalls roll back more reliably and better at avoiding naming conflicts.

src/pip/_internal/req/req_uninstall.py

+122-39
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
FakeFile, ask, dist_in_usersite, dist_is_local, egg_link_path, is_local,
1818
normalize_path, renames, rmtree,
1919
)
20-
from pip._internal.utils.temp_dir import AdjacentTempDirectory
20+
from pip._internal.utils.temp_dir import AdjacentTempDirectory, TempDirectory
2121

2222
logger = logging.getLogger(__name__)
2323

@@ -183,6 +183,112 @@ def compress_for_output_listing(paths):
183183
return will_remove, will_skip
184184

185185

186+
class StashedUninstallPathSet(object):
187+
"""A set of file rename operations to stash files while
188+
tentatively uninstalling them."""
189+
def __init__(self):
190+
# Mapping from source file root to [Adjacent]TempDirectory
191+
# for files under that directory.
192+
self._save_dirs = {}
193+
# (old path, new path) tuples for each move that may need
194+
# to be undone.
195+
self._moves = []
196+
197+
def _add_root(self, path):
198+
"""Create or return the stash directory for a given path.
199+
200+
For internal use only. External users should call add_root()"""
201+
key = os.path.normcase(path).rstrip("/\\")
202+
203+
if key in self._save_dirs:
204+
return key, self._save_dirs[key]
205+
206+
save_dir = AdjacentTempDirectory(path)
207+
try:
208+
save_dir.create()
209+
except OSError:
210+
save_dir = TempDirectory(kind='uninstall')
211+
save_dir.create()
212+
213+
self._save_dirs[key] = save_dir
214+
return key, save_dir
215+
216+
def add_root(self, path):
217+
"""Adds a root directory that we will be moving files from."""
218+
if not os.path.isdir(path):
219+
raise ValueError("Roots must be directories")
220+
221+
# Keep return values internal
222+
self._add_root(path)
223+
224+
def _get_stash_path(self, path):
225+
"""Finds a place to stash the path
226+
227+
If no root has been provided, one will be created for the directory
228+
passed."""
229+
path = os.path.normcase(path)
230+
head, old_head = os.path.dirname(path), None
231+
best_head, best_save_dir = None, None
232+
233+
while head != old_head:
234+
save_dir = self._save_dirs.get(head)
235+
if save_dir:
236+
break
237+
head, old_head = os.path.dirname(head), head
238+
239+
if not best_save_dir:
240+
head = path if os.path.isdir(path) else os.path.dirname(path)
241+
best_head, best_save_dir = self._add_root(head)
242+
243+
relpath = os.path.relpath(path, best_head)
244+
if not relpath or relpath == os.path.curdir:
245+
return best_save_dir.path
246+
return os.path.join(best_save_dir.path, relpath)
247+
248+
def stash(self, path):
249+
"""Stashes a file somewhere out of the way."""
250+
new_path = self._get_stash_path(path)
251+
self._moves.append((path, new_path))
252+
if os.path.isdir(path) and os.path.isdir(new_path):
253+
# If we're moving a directory, we need to
254+
# remove the destination first or else it will be
255+
# moved to inside the existing directory.
256+
# We just created new_path ourselves, so it will
257+
# be removable.
258+
os.rmdir(new_path)
259+
renames(path, new_path)
260+
261+
def commit(self):
262+
"""Commits the uninstall by removing stashed files."""
263+
for _, save_dir in self._save_dirs.items():
264+
save_dir.cleanup()
265+
self._moves = []
266+
self._save_dirs = {}
267+
268+
def rollback(self):
269+
"""Undoes the uninstall by moving stashed files back."""
270+
for p in self._moves:
271+
logging.info("Moving to %s\n from %s", *p)
272+
273+
for new_path, path in self._moves:
274+
try:
275+
logger.debug('Replacing %s from %s', new_path, path)
276+
if os.path.isfile(new_path):
277+
os.unlink(new_path)
278+
elif os.path.isdir(new_path):
279+
rmtree(new_path)
280+
renames(path, new_path)
281+
except OSError as ex:
282+
logger.error("Failed to restore %s", new_path)
283+
logger.debug("Exception: %s", ex)
284+
285+
self.commit()
286+
287+
@property
288+
def can_rollback(self):
289+
return bool(self._moves)
290+
291+
186292
class UninstallPathSet(object):
187293
"""A set of file paths to be removed in the uninstallation of a
188294
requirement."""
@@ -191,8 +297,7 @@ def __init__(self, dist):
191297
self._refuse = set()
192298
self.pth = {}
193299
self.dist = dist
194-
self._save_dirs = []
195-
self._moved_paths = []
300+
self._moved_paths = StashedUninstallPathSet()
196301

197302
def _permitted(self, path):
198303
"""
@@ -230,22 +335,6 @@ def add_pth(self, pth_file, entry):
230335
else:
231336
self._refuse.add(pth_file)
232337

233-
def _stash(self, path):
234-
best = None
235-
for save_dir in self._save_dirs:
236-
if not path.startswith(save_dir.original + os.sep):
237-
continue
238-
if not best or len(save_dir.original) > len(best.original):
239-
best = save_dir
240-
if best is None:
241-
best = AdjacentTempDirectory(os.path.dirname(path))
242-
best.create()
243-
self._save_dirs.append(best)
244-
relpath = os.path.relpath(path, best.original)
245-
if not relpath or relpath == os.path.curdir:
246-
return best.path
247-
return os.path.join(best.path, relpath)
248-
249338
def remove(self, auto_confirm=False, verbose=False):
250339
"""Remove paths in ``self.paths`` with confirmation (unless
251340
``auto_confirm`` is True)."""
@@ -264,18 +353,18 @@ def remove(self, auto_confirm=False, verbose=False):
264353

265354
with indent_log():
266355
if auto_confirm or self._allowed_to_proceed(verbose):
267-
for path in sorted(compact(compress_for_rename(self.paths))):
268-
new_path = self._stash(path)
356+
moved = self._moved_paths
357+
358+
for_rename = compress_for_rename(self.paths)
359+
360+
for path in for_rename:
361+
if os.path.isdir(path):
362+
moved.add_root(path)
363+
364+
for path in sorted(compact(for_rename)):
365+
moved.stash(path)
269366
logger.debug('Removing file or directory %s', path)
270-
self._moved_paths.append((path, new_path))
271-
if os.path.isdir(path) and os.path.isdir(new_path):
272-
# If we're moving a directory, we need to
273-
# remove the destination first or else it will be
274-
# moved to inside the existing directory.
275-
# We just created new_path ourselves, so it will
276-
# be removable.
277-
os.rmdir(new_path)
278-
renames(path, new_path)
367+
279368
for pth in self.pth.values():
280369
pth.remove()
281370

@@ -312,28 +401,22 @@ def _display(msg, paths):
312401

313402
def rollback(self):
314403
"""Rollback the changes previously made by remove()."""
315-
if not self._save_dirs:
404+
if not self._moved_paths.can_rollback:
316405
logger.error(
317406
"Can't roll back %s; was not uninstalled",
318407
self.dist.project_name,
319408
)
320409
return False
321410
logger.info('Rolling back uninstall of %s', self.dist.project_name)
322-
for path, tmp_path in self._moved_paths:
323-
logger.debug('Replacing %s', path)
324-
if os.path.isdir(tmp_path) and os.path.isdir(path):
325-
rmtree(path)
326-
renames(tmp_path, path)
411+
self._moved_paths.rollback()
327412
for pth in self.pth.values():
328413
pth.rollback()
329414
for save_dir in self._save_dirs:
330415
save_dir.cleanup()
331416

332417
def commit(self):
333418
"""Remove temporary save dir: rollback will no longer be possible."""
334-
for save_dir in self._save_dirs:
335-
save_dir.cleanup()
336-
self._moved_paths = []
419+
self._moved_paths.commit()
337420

338421
@classmethod
339422
def from_dist(cls, dist):

src/pip/_internal/utils/temp_dir.py

+5-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import absolute_import
22

3+
import errno
34
import itertools
45
import logging
56
import os.path
@@ -132,8 +133,10 @@ def create(self):
132133
path = os.path.join(root, candidate)
133134
try:
134135
os.mkdir(path)
135-
except OSError:
136-
pass
136+
except OSError as ex:
137+
# Continue if the name exists already
138+
if ex.errno != errno.EEXIST:
139+
raise
137140
else:
138141
self.path = os.path.realpath(path)
139142
break

tests/unit/test_utils.py

+30
Original file line numberDiff line numberDiff line change
@@ -578,6 +578,36 @@ def names():
578578
for x, y in zip(some_names, expected_names):
579579
assert x == y
580580

581+
def test_adjacent_directory_exists(self):
582+
name = "ABC"
583+
block_name, expect_name = itertools.islice(
584+
AdjacentTempDirectory._generate_names(name), 2)
585+
with TempDirectory() as tmp_dir:
586+
original = os.path.join(tmp_dir.path, name)
587+
blocker = os.path.join(tmp_dir.path, block_name)
588+
589+
ensure_dir(original)
590+
ensure_dir(blocker)
591+
592+
with AdjacentTempDirectory(original) as atmp_dir:
593+
assert expect_name == os.path.split(atmp_dir.path)[1]
594+
595+
def test_adjacent_directory_permission_error(self, monkeypatch):
596+
name = "ABC"
597+
598+
def raising_mkdir(*args, **kwargs):
599+
raise OSError("Unknown OSError")
600+
601+
with TempDirectory() as tmp_dir:
602+
original = os.path.join(tmp_dir.path, name)
603+
604+
ensure_dir(original)
605+
monkeypatch.setattr("os.mkdir", raising_mkdir)
606+
607+
with pytest.raises(OSError):
608+
with AdjacentTempDirectory(original):
609+
pass
610+
581611

582612
class TestGlibc(object):
583613
def test_manylinux_check_glibc_version(self):

0 commit comments

Comments
 (0)