You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

206 lines
7.0 KiB

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