req_uninstall.py 7.2 KB

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