req_uninstall.py 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195
  1. from __future__ import absolute_import
  2. import logging
  3. import os
  4. import tempfile
  5. from pip.compat import uses_pycache, WINDOWS, cache_from_source
  6. from pip.exceptions import UninstallationError
  7. from pip.utils import rmtree, ask, is_local, renames, normalize_path
  8. from pip.utils.logging import indent_log
  9. logger = logging.getLogger(__name__)
  10. class UninstallPathSet(object):
  11. """A set of file paths to be removed in the uninstallation of a
  12. requirement."""
  13. def __init__(self, dist):
  14. self.paths = set()
  15. self._refuse = set()
  16. self.pth = {}
  17. self.dist = dist
  18. self.save_dir = None
  19. self._moved_paths = []
  20. def _permitted(self, path):
  21. """
  22. Return True if the given path is one we are permitted to
  23. remove/modify, False otherwise.
  24. """
  25. return is_local(path)
  26. def add(self, path):
  27. head, tail = os.path.split(path)
  28. # we normalize the head to resolve parent directory symlinks, but not
  29. # the tail, since we only want to uninstall symlinks, not their targets
  30. path = os.path.join(normalize_path(head), os.path.normcase(tail))
  31. if not os.path.exists(path):
  32. return
  33. if self._permitted(path):
  34. self.paths.add(path)
  35. else:
  36. self._refuse.add(path)
  37. # __pycache__ files can show up after 'installed-files.txt' is created,
  38. # due to imports
  39. if os.path.splitext(path)[1] == '.py' and uses_pycache:
  40. self.add(cache_from_source(path))
  41. def add_pth(self, pth_file, entry):
  42. pth_file = normalize_path(pth_file)
  43. if self._permitted(pth_file):
  44. if pth_file not in self.pth:
  45. self.pth[pth_file] = UninstallPthEntries(pth_file)
  46. self.pth[pth_file].add(entry)
  47. else:
  48. self._refuse.add(pth_file)
  49. def compact(self, paths):
  50. """Compact a path set to contain the minimal number of paths
  51. necessary to contain all paths in the set. If /a/path/ and
  52. /a/path/to/a/file.txt are both in the set, leave only the
  53. shorter path."""
  54. short_paths = set()
  55. for path in sorted(paths, key=len):
  56. if not any([
  57. (path.startswith(shortpath) and
  58. path[len(shortpath.rstrip(os.path.sep))] == os.path.sep)
  59. for shortpath in short_paths]):
  60. short_paths.add(path)
  61. return short_paths
  62. def _stash(self, path):
  63. return os.path.join(
  64. self.save_dir, os.path.splitdrive(path)[1].lstrip(os.path.sep))
  65. def remove(self, auto_confirm=False):
  66. """Remove paths in ``self.paths`` with confirmation (unless
  67. ``auto_confirm`` is True)."""
  68. if not self.paths:
  69. logger.info(
  70. "Can't uninstall '%s'. No files were found to uninstall.",
  71. self.dist.project_name,
  72. )
  73. return
  74. logger.info(
  75. 'Uninstalling %s-%s:',
  76. self.dist.project_name, self.dist.version
  77. )
  78. with indent_log():
  79. paths = sorted(self.compact(self.paths))
  80. if auto_confirm:
  81. response = 'y'
  82. else:
  83. for path in paths:
  84. logger.info(path)
  85. response = ask('Proceed (y/n)? ', ('y', 'n'))
  86. if self._refuse:
  87. logger.info('Not removing or modifying (outside of prefix):')
  88. for path in self.compact(self._refuse):
  89. logger.info(path)
  90. if response == 'y':
  91. self.save_dir = tempfile.mkdtemp(suffix='-uninstall',
  92. prefix='pip-')
  93. for path in paths:
  94. new_path = self._stash(path)
  95. logger.debug('Removing file or directory %s', path)
  96. self._moved_paths.append(path)
  97. renames(path, new_path)
  98. for pth in self.pth.values():
  99. pth.remove()
  100. logger.info(
  101. 'Successfully uninstalled %s-%s',
  102. self.dist.project_name, self.dist.version
  103. )
  104. def rollback(self):
  105. """Rollback the changes previously made by remove()."""
  106. if self.save_dir is None:
  107. logger.error(
  108. "Can't roll back %s; was not uninstalled",
  109. self.dist.project_name,
  110. )
  111. return False
  112. logger.info('Rolling back uninstall of %s', self.dist.project_name)
  113. for path in self._moved_paths:
  114. tmp_path = self._stash(path)
  115. logger.debug('Replacing %s', path)
  116. renames(tmp_path, path)
  117. for pth in self.pth.values():
  118. pth.rollback()
  119. def commit(self):
  120. """Remove temporary save dir: rollback will no longer be possible."""
  121. if self.save_dir is not None:
  122. rmtree(self.save_dir)
  123. self.save_dir = None
  124. self._moved_paths = []
  125. class UninstallPthEntries(object):
  126. def __init__(self, pth_file):
  127. if not os.path.isfile(pth_file):
  128. raise UninstallationError(
  129. "Cannot remove entries from nonexistent file %s" % pth_file
  130. )
  131. self.file = pth_file
  132. self.entries = set()
  133. self._saved_lines = None
  134. def add(self, entry):
  135. entry = os.path.normcase(entry)
  136. # On Windows, os.path.normcase converts the entry to use
  137. # backslashes. This is correct for entries that describe absolute
  138. # paths outside of site-packages, but all the others use forward
  139. # slashes.
  140. if WINDOWS and not os.path.splitdrive(entry)[0]:
  141. entry = entry.replace('\\', '/')
  142. self.entries.add(entry)
  143. def remove(self):
  144. logger.debug('Removing pth entries from %s:', self.file)
  145. with open(self.file, 'rb') as fh:
  146. # windows uses '\r\n' with py3k, but uses '\n' with py2.x
  147. lines = fh.readlines()
  148. self._saved_lines = lines
  149. if any(b'\r\n' in line for line in lines):
  150. endline = '\r\n'
  151. else:
  152. endline = '\n'
  153. for entry in self.entries:
  154. try:
  155. logger.debug('Removing entry: %s', entry)
  156. lines.remove((entry + endline).encode("utf-8"))
  157. except ValueError:
  158. pass
  159. with open(self.file, 'wb') as fh:
  160. fh.writelines(lines)
  161. def rollback(self):
  162. if self._saved_lines is None:
  163. logger.error(
  164. 'Cannot roll back changes to %s, none were made', self.file
  165. )
  166. return False
  167. logger.debug('Rolling %s back to previous state', self.file)
  168. with open(self.file, 'wb') as fh:
  169. fh.writelines(self._saved_lines)
  170. return True