Skip to content

Commit c7ae06c

Browse files
committed
Fixes #3055 Uninstall causes paths to exceed MAX_PATH limit
1 parent 9c0a1da commit c7ae06c

File tree

2 files changed

+115
-16
lines changed

2 files changed

+115
-16
lines changed

src/pip/_internal/req/req_uninstall.py

+56-15
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,
1919
)
20-
from pip._internal.utils.temp_dir import TempDirectory
20+
from pip._internal.utils.temp_dir import TempDirectory, AdjacentTempDirectory
2121

2222
logger = logging.getLogger(__name__)
2323

@@ -86,16 +86,49 @@ def compact(paths):
8686
sep = os.path.sep
8787
short_paths = set()
8888
for path in sorted(paths, key=len):
89-
should_add = any(
89+
should_skip = any(
9090
path.startswith(shortpath.rstrip("*")) and
9191
path[len(shortpath.rstrip("*").rstrip(sep))] == sep
9292
for shortpath in short_paths
9393
)
94-
if not should_add:
94+
if not should_skip:
9595
short_paths.add(path)
9696
return short_paths
9797

9898

99+
def compress_for_rename(paths):
100+
"""Returns a set containing the paths that need to be renamed.
101+
102+
This set may include directories when the original sequence of paths
103+
included every file on disk.
104+
"""
105+
remaining = set(paths)
106+
unchecked = sorted(set(os.path.split(p)[0] for p in remaining), key=len)
107+
wildcards = set()
108+
109+
def norm_join(*a):
110+
return os.path.normcase(os.path.join(*a))
111+
112+
for root in unchecked:
113+
if any(root.startswith(w) for w in wildcards):
114+
# This directory has already been handled.
115+
continue
116+
117+
all_files = set()
118+
all_subdirs = set()
119+
for dirname, subdirs, files in os.walk(root):
120+
all_subdirs.update(norm_join(root, dirname, d) for d in subdirs)
121+
all_files.update(norm_join(root, dirname, f) for f in files)
122+
# If all the files we found are in our remaining set of files to
123+
# remove, then remove them from the latter set and add a wildcard
124+
# for the directory.
125+
if len(all_files - remaining) == 0:
126+
remaining.difference_update(all_files)
127+
wildcards.add(root + os.sep)
128+
129+
return remaining | wildcards
130+
131+
99132
def compress_for_output_listing(paths):
100133
"""Returns a tuple of 2 sets of which paths to display to user
101134
@@ -153,7 +186,7 @@ def __init__(self, dist):
153186
self._refuse = set()
154187
self.pth = {}
155188
self.dist = dist
156-
self.save_dir = TempDirectory(kind="uninstall")
189+
self._save_dirs = []
157190
self._moved_paths = []
158191

159192
def _permitted(self, path):
@@ -193,9 +226,17 @@ def add_pth(self, pth_file, entry):
193226
self._refuse.add(pth_file)
194227

195228
def _stash(self, path):
196-
return os.path.join(
197-
self.save_dir.path, os.path.splitdrive(path)[1].lstrip(os.path.sep)
198-
)
229+
best = None
230+
for save_dir in self._save_dirs:
231+
if not path.startswith(save_dir.original + os.sep):
232+
continue
233+
if not best or len(save_dir.original) > len(best.original):
234+
best = save_dir
235+
if best is None:
236+
best = AdjacentTempDirectory(os.path.dirname(path))
237+
best.create()
238+
self._save_dirs.append(best)
239+
return os.path.join(best.path, os.path.relpath(path, best.original))
199240

200241
def remove(self, auto_confirm=False, verbose=False):
201242
"""Remove paths in ``self.paths`` with confirmation (unless
@@ -215,12 +256,10 @@ def remove(self, auto_confirm=False, verbose=False):
215256

216257
with indent_log():
217258
if auto_confirm or self._allowed_to_proceed(verbose):
218-
self.save_dir.create()
219-
220-
for path in sorted(compact(self.paths)):
259+
for path in sorted(compact(compress_for_rename(self.paths))):
221260
new_path = self._stash(path)
222261
logger.debug('Removing file or directory %s', path)
223-
self._moved_paths.append(path)
262+
self._moved_paths.append((path, new_path))
224263
renames(path, new_path)
225264
for pth in self.pth.values():
226265
pth.remove()
@@ -251,28 +290,30 @@ def _display(msg, paths):
251290
_display('Would remove:', will_remove)
252291
_display('Would not remove (might be manually added):', will_skip)
253292
_display('Would not remove (outside of prefix):', self._refuse)
293+
if verbose:
294+
_display('Will actually move:', compress_for_rename(self.paths))
254295

255296
return ask('Proceed (y/n)? ', ('y', 'n')) == 'y'
256297

257298
def rollback(self):
258299
"""Rollback the changes previously made by remove()."""
259-
if self.save_dir.path is None:
300+
if not self._save_dirs:
260301
logger.error(
261302
"Can't roll back %s; was not uninstalled",
262303
self.dist.project_name,
263304
)
264305
return False
265306
logger.info('Rolling back uninstall of %s', self.dist.project_name)
266-
for path in self._moved_paths:
267-
tmp_path = self._stash(path)
307+
for path, tmp_path in self._moved_paths:
268308
logger.debug('Replacing %s', path)
269309
renames(tmp_path, path)
270310
for pth in self.pth.values():
271311
pth.rollback()
272312

273313
def commit(self):
274314
"""Remove temporary save dir: rollback will no longer be possible."""
275-
self.save_dir.cleanup()
315+
for save_dir in self._save_dirs:
316+
save_dir.cleanup()
276317
self._moved_paths = []
277318

278319
@classmethod

src/pip/_internal/utils/temp_dir.py

+59-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
from __future__ import absolute_import
22

3+
import itertools
34
import logging
45
import os.path
6+
import shutil
57
import tempfile
68

79
from pip._internal.utils.misc import rmtree
@@ -58,7 +60,7 @@ def __exit__(self, exc, value, tb):
5860
self.cleanup()
5961

6062
def create(self):
61-
"""Create a temporary directory and store it's path in self.path
63+
"""Create a temporary directory and store its path in self.path
6264
"""
6365
if self.path is not None:
6466
logger.debug(
@@ -80,3 +82,59 @@ def cleanup(self):
8082
if self.path is not None and os.path.exists(self.path):
8183
rmtree(self.path)
8284
self.path = None
85+
86+
87+
class AdjacentTempDirectory(TempDirectory):
88+
"""Helper class that creates a temporary directory adjacent to a real one.
89+
90+
Attributes:
91+
original
92+
The original directory to create a temp directory for.
93+
path
94+
After calling create() or entering, contains the full
95+
path to the temporary directory.
96+
delete
97+
Whether the directory should be deleted when exiting
98+
(when used as a contextmanager)
99+
100+
"""
101+
# The characters that may be used to name the temp directory
102+
LEADING_CHARS = "-~.+=%0123456789"
103+
104+
def __init__(self, original, delete=None):
105+
super(AdjacentTempDirectory, self).__init__(delete=delete)
106+
self.original = original.rstrip('/\\')
107+
108+
@classmethod
109+
def _generate_names(cls, name):
110+
"""Generates a series of temporary names.
111+
112+
The algorithm replaces the leading characters in the name
113+
with ones that are valid filesystem characters, but are not
114+
valid package names (for both Python and pip definitions of
115+
package).
116+
"""
117+
for i in range(1, len(name)):
118+
for candidate in itertools.permutations(cls.LEADING_CHARS, i):
119+
yield ''.join(candidate) + name[i:]
120+
121+
def create(self):
122+
root, name = os.path.split(self.original)
123+
os.makedirs(root, exist_ok=True)
124+
for candidate in self._generate_names(name):
125+
path = os.path.join(root, candidate)
126+
try:
127+
os.mkdir(path)
128+
except OSError:
129+
pass
130+
else:
131+
self.path = os.path.realpath(path)
132+
break
133+
134+
if not self.path:
135+
# Final fallback on the default behavior.
136+
self.path = os.path.realpath(
137+
tempfile.mkdtemp(prefix="pip-{}-".format(self.kind))
138+
)
139+
logger.debug("Created temporary directory: {}".format(self.path))
140+

0 commit comments

Comments
 (0)