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.

req_uninstall.py 17KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455
  1. from __future__ import absolute_import
  2. import csv
  3. import functools
  4. import logging
  5. import os
  6. import sys
  7. import sysconfig
  8. from pip._vendor import pkg_resources
  9. from pip._internal.compat import WINDOWS, cache_from_source, uses_pycache
  10. from pip._internal.exceptions import UninstallationError
  11. from pip._internal.locations import bin_py, bin_user
  12. from pip._internal.utils.logging import indent_log
  13. from pip._internal.utils.misc import (
  14. FakeFile, ask, dist_in_usersite, dist_is_local, egg_link_path, is_local,
  15. normalize_path, renames,
  16. )
  17. from pip._internal.utils.temp_dir import TempDirectory
  18. logger = logging.getLogger(__name__)
  19. def _script_names(dist, script_name, is_gui):
  20. """Create the fully qualified name of the files created by
  21. {console,gui}_scripts for the given ``dist``.
  22. Returns the list of file names
  23. """
  24. if dist_in_usersite(dist):
  25. bin_dir = bin_user
  26. else:
  27. bin_dir = bin_py
  28. exe_name = os.path.join(bin_dir, script_name)
  29. paths_to_remove = [exe_name]
  30. if WINDOWS:
  31. paths_to_remove.append(exe_name + '.exe')
  32. paths_to_remove.append(exe_name + '.exe.manifest')
  33. if is_gui:
  34. paths_to_remove.append(exe_name + '-script.pyw')
  35. else:
  36. paths_to_remove.append(exe_name + '-script.py')
  37. return paths_to_remove
  38. def _unique(fn):
  39. @functools.wraps(fn)
  40. def unique(*args, **kw):
  41. seen = set()
  42. for item in fn(*args, **kw):
  43. if item not in seen:
  44. seen.add(item)
  45. yield item
  46. return unique
  47. @_unique
  48. def uninstallation_paths(dist):
  49. """
  50. Yield all the uninstallation paths for dist based on RECORD-without-.pyc
  51. Yield paths to all the files in RECORD. For each .py file in RECORD, add
  52. the .pyc in the same directory.
  53. UninstallPathSet.add() takes care of the __pycache__ .pyc.
  54. """
  55. r = csv.reader(FakeFile(dist.get_metadata_lines('RECORD')))
  56. for row in r:
  57. path = os.path.join(dist.location, row[0])
  58. yield path
  59. if path.endswith('.py'):
  60. dn, fn = os.path.split(path)
  61. base = fn[:-3]
  62. path = os.path.join(dn, base + '.pyc')
  63. yield path
  64. def compact(paths):
  65. """Compact a path set to contain the minimal number of paths
  66. necessary to contain all paths in the set. If /a/path/ and
  67. /a/path/to/a/file.txt are both in the set, leave only the
  68. shorter path."""
  69. sep = os.path.sep
  70. short_paths = set()
  71. for path in sorted(paths, key=len):
  72. should_add = any(
  73. path.startswith(shortpath.rstrip("*")) and
  74. path[len(shortpath.rstrip("*").rstrip(sep))] == sep
  75. for shortpath in short_paths
  76. )
  77. if not should_add:
  78. short_paths.add(path)
  79. return short_paths
  80. def compress_for_output_listing(paths):
  81. """Returns a tuple of 2 sets of which paths to display to user
  82. The first set contains paths that would be deleted. Files of a package
  83. are not added and the top-level directory of the package has a '*' added
  84. at the end - to signify that all it's contents are removed.
  85. The second set contains files that would have been skipped in the above
  86. folders.
  87. """
  88. will_remove = list(paths)
  89. will_skip = set()
  90. # Determine folders and files
  91. folders = set()
  92. files = set()
  93. for path in will_remove:
  94. if path.endswith(".pyc"):
  95. continue
  96. if path.endswith("__init__.py") or ".dist-info" in path:
  97. folders.add(os.path.dirname(path))
  98. files.add(path)
  99. folders = compact(folders)
  100. # This walks the tree using os.walk to not miss extra folders
  101. # that might get added.
  102. for folder in folders:
  103. for dirpath, _, dirfiles in os.walk(folder):
  104. for fname in dirfiles:
  105. if fname.endswith(".pyc"):
  106. continue
  107. file_ = os.path.normcase(os.path.join(dirpath, fname))
  108. if os.path.isfile(file_) and file_ not in files:
  109. # We are skipping this file. Add it to the set.
  110. will_skip.add(file_)
  111. will_remove = files | {
  112. os.path.join(folder, "*") for folder in folders
  113. }
  114. return will_remove, will_skip
  115. class UninstallPathSet(object):
  116. """A set of file paths to be removed in the uninstallation of a
  117. requirement."""
  118. def __init__(self, dist):
  119. self.paths = set()
  120. self._refuse = set()
  121. self.pth = {}
  122. self.dist = dist
  123. self.save_dir = TempDirectory(kind="uninstall")
  124. self._moved_paths = []
  125. def _permitted(self, path):
  126. """
  127. Return True if the given path is one we are permitted to
  128. remove/modify, False otherwise.
  129. """
  130. return is_local(path)
  131. def add(self, path):
  132. head, tail = os.path.split(path)
  133. # we normalize the head to resolve parent directory symlinks, but not
  134. # the tail, since we only want to uninstall symlinks, not their targets
  135. path = os.path.join(normalize_path(head), os.path.normcase(tail))
  136. if not os.path.exists(path):
  137. return
  138. if self._permitted(path):
  139. self.paths.add(path)
  140. else:
  141. self._refuse.add(path)
  142. # __pycache__ files can show up after 'installed-files.txt' is created,
  143. # due to imports
  144. if os.path.splitext(path)[1] == '.py' and uses_pycache:
  145. self.add(cache_from_source(path))
  146. def add_pth(self, pth_file, entry):
  147. pth_file = normalize_path(pth_file)
  148. if self._permitted(pth_file):
  149. if pth_file not in self.pth:
  150. self.pth[pth_file] = UninstallPthEntries(pth_file)
  151. self.pth[pth_file].add(entry)
  152. else:
  153. self._refuse.add(pth_file)
  154. def _stash(self, path):
  155. return os.path.join(
  156. self.save_dir.path, os.path.splitdrive(path)[1].lstrip(os.path.sep)
  157. )
  158. def remove(self, auto_confirm=False, verbose=False):
  159. """Remove paths in ``self.paths`` with confirmation (unless
  160. ``auto_confirm`` is True)."""
  161. if not self.paths:
  162. logger.info(
  163. "Can't uninstall '%s'. No files were found to uninstall.",
  164. self.dist.project_name,
  165. )
  166. return
  167. dist_name_version = (
  168. self.dist.project_name + "-" + self.dist.version
  169. )
  170. logger.info('Uninstalling %s:', dist_name_version)
  171. with indent_log():
  172. if auto_confirm or self._allowed_to_proceed(verbose):
  173. self.save_dir.create()
  174. for path in sorted(compact(self.paths)):
  175. new_path = self._stash(path)
  176. logger.debug('Removing file or directory %s', path)
  177. self._moved_paths.append(path)
  178. renames(path, new_path)
  179. for pth in self.pth.values():
  180. pth.remove()
  181. logger.info('Successfully uninstalled %s', dist_name_version)
  182. def _allowed_to_proceed(self, verbose):
  183. """Display which files would be deleted and prompt for confirmation
  184. """
  185. def _display(msg, paths):
  186. if not paths:
  187. return
  188. logger.info(msg)
  189. with indent_log():
  190. for path in sorted(compact(paths)):
  191. logger.info(path)
  192. if not verbose:
  193. will_remove, will_skip = compress_for_output_listing(self.paths)
  194. else:
  195. # In verbose mode, display all the files that are going to be
  196. # deleted.
  197. will_remove = list(self.paths)
  198. will_skip = set()
  199. _display('Would remove:', will_remove)
  200. _display('Would not remove (might be manually added):', will_skip)
  201. _display('Would not remove (outside of prefix):', self._refuse)
  202. return ask('Proceed (y/n)? ', ('y', 'n')) == 'y'
  203. def rollback(self):
  204. """Rollback the changes previously made by remove()."""
  205. if self.save_dir.path is None:
  206. logger.error(
  207. "Can't roll back %s; was not uninstalled",
  208. self.dist.project_name,
  209. )
  210. return False
  211. logger.info('Rolling back uninstall of %s', self.dist.project_name)
  212. for path in self._moved_paths:
  213. tmp_path = self._stash(path)
  214. logger.debug('Replacing %s', path)
  215. renames(tmp_path, path)
  216. for pth in self.pth.values():
  217. pth.rollback()
  218. def commit(self):
  219. """Remove temporary save dir: rollback will no longer be possible."""
  220. self.save_dir.cleanup()
  221. self._moved_paths = []
  222. @classmethod
  223. def from_dist(cls, dist):
  224. dist_path = normalize_path(dist.location)
  225. if not dist_is_local(dist):
  226. logger.info(
  227. "Not uninstalling %s at %s, outside environment %s",
  228. dist.key,
  229. dist_path,
  230. sys.prefix,
  231. )
  232. return cls(dist)
  233. if dist_path in {p for p in {sysconfig.get_path("stdlib"),
  234. sysconfig.get_path("platstdlib")}
  235. if p}:
  236. logger.info(
  237. "Not uninstalling %s at %s, as it is in the standard library.",
  238. dist.key,
  239. dist_path,
  240. )
  241. return cls(dist)
  242. paths_to_remove = cls(dist)
  243. develop_egg_link = egg_link_path(dist)
  244. develop_egg_link_egg_info = '{}.egg-info'.format(
  245. pkg_resources.to_filename(dist.project_name))
  246. egg_info_exists = dist.egg_info and os.path.exists(dist.egg_info)
  247. # Special case for distutils installed package
  248. distutils_egg_info = getattr(dist._provider, 'path', None)
  249. # Uninstall cases order do matter as in the case of 2 installs of the
  250. # same package, pip needs to uninstall the currently detected version
  251. if (egg_info_exists and dist.egg_info.endswith('.egg-info') and
  252. not dist.egg_info.endswith(develop_egg_link_egg_info)):
  253. # if dist.egg_info.endswith(develop_egg_link_egg_info), we
  254. # are in fact in the develop_egg_link case
  255. paths_to_remove.add(dist.egg_info)
  256. if dist.has_metadata('installed-files.txt'):
  257. for installed_file in dist.get_metadata(
  258. 'installed-files.txt').splitlines():
  259. path = os.path.normpath(
  260. os.path.join(dist.egg_info, installed_file)
  261. )
  262. paths_to_remove.add(path)
  263. # FIXME: need a test for this elif block
  264. # occurs with --single-version-externally-managed/--record outside
  265. # of pip
  266. elif dist.has_metadata('top_level.txt'):
  267. if dist.has_metadata('namespace_packages.txt'):
  268. namespaces = dist.get_metadata('namespace_packages.txt')
  269. else:
  270. namespaces = []
  271. for top_level_pkg in [
  272. p for p
  273. in dist.get_metadata('top_level.txt').splitlines()
  274. if p and p not in namespaces]:
  275. path = os.path.join(dist.location, top_level_pkg)
  276. paths_to_remove.add(path)
  277. paths_to_remove.add(path + '.py')
  278. paths_to_remove.add(path + '.pyc')
  279. paths_to_remove.add(path + '.pyo')
  280. elif distutils_egg_info:
  281. raise UninstallationError(
  282. "Cannot uninstall {!r}. It is a distutils installed project "
  283. "and thus we cannot accurately determine which files belong "
  284. "to it which would lead to only a partial uninstall.".format(
  285. dist.project_name,
  286. )
  287. )
  288. elif dist.location.endswith('.egg'):
  289. # package installed by easy_install
  290. # We cannot match on dist.egg_name because it can slightly vary
  291. # i.e. setuptools-0.6c11-py2.6.egg vs setuptools-0.6rc11-py2.6.egg
  292. paths_to_remove.add(dist.location)
  293. easy_install_egg = os.path.split(dist.location)[1]
  294. easy_install_pth = os.path.join(os.path.dirname(dist.location),
  295. 'easy-install.pth')
  296. paths_to_remove.add_pth(easy_install_pth, './' + easy_install_egg)
  297. elif egg_info_exists and dist.egg_info.endswith('.dist-info'):
  298. for path in uninstallation_paths(dist):
  299. paths_to_remove.add(path)
  300. elif develop_egg_link:
  301. # develop egg
  302. with open(develop_egg_link, 'r') as fh:
  303. link_pointer = os.path.normcase(fh.readline().strip())
  304. assert (link_pointer == dist.location), (
  305. 'Egg-link %s does not match installed location of %s '
  306. '(at %s)' % (link_pointer, dist.project_name, dist.location)
  307. )
  308. paths_to_remove.add(develop_egg_link)
  309. easy_install_pth = os.path.join(os.path.dirname(develop_egg_link),
  310. 'easy-install.pth')
  311. paths_to_remove.add_pth(easy_install_pth, dist.location)
  312. else:
  313. logger.debug(
  314. 'Not sure how to uninstall: %s - Check: %s',
  315. dist, dist.location,
  316. )
  317. # find distutils scripts= scripts
  318. if dist.has_metadata('scripts') and dist.metadata_isdir('scripts'):
  319. for script in dist.metadata_listdir('scripts'):
  320. if dist_in_usersite(dist):
  321. bin_dir = bin_user
  322. else:
  323. bin_dir = bin_py
  324. paths_to_remove.add(os.path.join(bin_dir, script))
  325. if WINDOWS:
  326. paths_to_remove.add(os.path.join(bin_dir, script) + '.bat')
  327. # find console_scripts
  328. _scripts_to_remove = []
  329. console_scripts = dist.get_entry_map(group='console_scripts')
  330. for name in console_scripts.keys():
  331. _scripts_to_remove.extend(_script_names(dist, name, False))
  332. # find gui_scripts
  333. gui_scripts = dist.get_entry_map(group='gui_scripts')
  334. for name in gui_scripts.keys():
  335. _scripts_to_remove.extend(_script_names(dist, name, True))
  336. for s in _scripts_to_remove:
  337. paths_to_remove.add(s)
  338. return paths_to_remove
  339. class UninstallPthEntries(object):
  340. def __init__(self, pth_file):
  341. if not os.path.isfile(pth_file):
  342. raise UninstallationError(
  343. "Cannot remove entries from nonexistent file %s" % pth_file
  344. )
  345. self.file = pth_file
  346. self.entries = set()
  347. self._saved_lines = None
  348. def add(self, entry):
  349. entry = os.path.normcase(entry)
  350. # On Windows, os.path.normcase converts the entry to use
  351. # backslashes. This is correct for entries that describe absolute
  352. # paths outside of site-packages, but all the others use forward
  353. # slashes.
  354. if WINDOWS and not os.path.splitdrive(entry)[0]:
  355. entry = entry.replace('\\', '/')
  356. self.entries.add(entry)
  357. def remove(self):
  358. logger.debug('Removing pth entries from %s:', self.file)
  359. with open(self.file, 'rb') as fh:
  360. # windows uses '\r\n' with py3k, but uses '\n' with py2.x
  361. lines = fh.readlines()
  362. self._saved_lines = lines
  363. if any(b'\r\n' in line for line in lines):
  364. endline = '\r\n'
  365. else:
  366. endline = '\n'
  367. # handle missing trailing newline
  368. if lines and not lines[-1].endswith(endline.encode("utf-8")):
  369. lines[-1] = lines[-1] + endline.encode("utf-8")
  370. for entry in self.entries:
  371. try:
  372. logger.debug('Removing entry: %s', entry)
  373. lines.remove((entry + endline).encode("utf-8"))
  374. except ValueError:
  375. pass
  376. with open(self.file, 'wb') as fh:
  377. fh.writelines(lines)
  378. def rollback(self):
  379. if self._saved_lines is None:
  380. logger.error(
  381. 'Cannot roll back changes to %s, none were made', self.file
  382. )
  383. return False
  384. logger.debug('Rolling %s back to previous state', self.file)
  385. with open(self.file, 'wb') as fh:
  386. fh.writelines(self._saved_lines)
  387. return True