diff --git a/pip/commands/uninstall.py b/pip/commands/uninstall.py index 3d81a307eed..606863e8d84 100644 --- a/pip/commands/uninstall.py +++ b/pip/commands/uninstall.py @@ -35,6 +35,12 @@ def __init__(self, *args, **kw): dest='yes', action='store_true', help="Don't ask for confirmation of uninstall deletions.") + self.cmd_opts.add_option( + '-t', '--target', + dest='target', + default=None, + help="Uninstall from a target directory. This is similar to the " + "flag of same name on the install command.") self.parser.insert_option_group(0, self.cmd_opts) @@ -61,4 +67,5 @@ def run(self, options, args): 'You must give at least one requirement to %(name)s (see "pip ' 'help %(name)s")' % dict(name=self.name) ) - requirement_set.uninstall(auto_confirm=options.yes) + requirement_set.uninstall(auto_confirm=options.yes, + target=options.target) diff --git a/pip/req/req_install.py b/pip/req/req_install.py index a087f4c17bc..1d2ecce830a 100644 --- a/pip/req/req_install.py +++ b/pip/req/req_install.py @@ -519,26 +519,30 @@ def update_editable(self, obtain=True): 'Unexpected version control type (in %s): %s' % (self.url, vc_type)) - def uninstall(self, auto_confirm=False): + def uninstall(self, auto_confirm=False, target=None): """ Uninstall the distribution currently satisfying this requirement. Prompts before removing or modifying files unless ``auto_confirm`` is True. + The `target` parameter optionally defines a target folder + which is considered for deinstallation instead of the + system site packages. + Refuses to delete or modify files outside of ``sys.prefix`` - thus uninstallation within a virtual environment can only modify that virtual environment, even if the virtualenv is linked to global site-packages. """ - if not self.check_if_exists(): + if not self.check_if_exists(target=target): raise UninstallationError( "Cannot uninstall requirement %s, not installed" % (self.name,) ) dist = self.satisfied_by or self.conflicts_with - paths_to_remove = UninstallPathSet(dist) + paths_to_remove = UninstallPathSet(dist, target=target) pip_egg_info_path = os.path.join(dist.location, dist.egg_name()) + '.egg-info' @@ -875,13 +879,29 @@ def _filter_install(self, line): break return (level, line) - def check_if_exists(self): + def check_if_exists(self, target=None): """Find an installed distribution that satisfies or conflicts with this requirement, and set self.satisfied_by or self.conflicts_with appropriately.""" if self.req is None: return False + + if target is not None: + working_set = pkg_resources.WorkingSet() + working_set.add_entry(target) + else: + working_set = pkg_resources.working_set + + def _get_dist(req): + # This does pretty much exactly the same logic as the + # get_distribution function in pkg_resources through two + # layers of indirection but it works with an explicit working + # set instead. + if isinstance(req, six.string_types): + req = pkg_resources.Requirement.parse(req) + return working_set.find(req) or working_set.require(str(req))[0] + try: # DISTRIBUTE TO SETUPTOOLS UPGRADE HACK (1 of 3 parts) # if we've already set distribute as a conflict to setuptools @@ -893,13 +913,11 @@ def check_if_exists(self): and self.conflicts_with.project_name == 'distribute'): return True else: - self.satisfied_by = pkg_resources.get_distribution(self.req) + self.satisfied_by = _get_dist(self.req) except pkg_resources.DistributionNotFound: return False except pkg_resources.VersionConflict: - existing_dist = pkg_resources.get_distribution( - self.req.project_name - ) + existing_dist = _get_dist(self.req.project_name) if self.use_user_site: if dist_in_usersite(existing_dist): self.conflicts_with = existing_dist diff --git a/pip/req/req_set.py b/pip/req/req_set.py index e7347caf8f8..19b9b178653 100644 --- a/pip/req/req_set.py +++ b/pip/req/req_set.py @@ -146,9 +146,9 @@ def get_requirement(self, project_name): return self.requirements[self.requirement_aliases[name]] raise KeyError("No project with the name %r" % project_name) - def uninstall(self, auto_confirm=False): + def uninstall(self, auto_confirm=False, target=None): for req in self.requirements.values(): - req.uninstall(auto_confirm=auto_confirm) + req.uninstall(auto_confirm=auto_confirm, target=target) req.commit_uninstall() def locate_files(self): diff --git a/pip/req/req_uninstall.py b/pip/req/req_uninstall.py index 7764b703271..60a7621450a 100644 --- a/pip/req/req_uninstall.py +++ b/pip/req/req_uninstall.py @@ -13,9 +13,10 @@ class UninstallPathSet(object): """A set of file paths to be removed in the uninstallation of a requirement.""" - def __init__(self, dist): + def __init__(self, dist, target=None): self.paths = set() self._refuse = set() + self.target = target self.pth = {} self.dist = dist self.save_dir = None @@ -27,10 +28,10 @@ def _permitted(self, path): remove/modify, False otherwise. """ - return is_local(path) + return is_local(path, target=self.target) def _can_uninstall(self): - if not dist_is_local(self.dist): + if not dist_is_local(self.dist, self.target): logger.notify( "Not uninstalling %s at %s, outside environment %s" % ( @@ -115,7 +116,7 @@ def remove(self, auto_confirm=False): new_path = self._stash(path) logger.info('Removing file or directory %s' % path) self._moved_paths.append(path) - renames(path, new_path) + renames(path, new_path, preserve=self.target) for pth in self.pth.values(): pth.remove() logger.notify( diff --git a/pip/util.py b/pip/util.py index 6da07625215..a0a9369dd60 100644 --- a/pip/util.py +++ b/pip/util.py @@ -292,8 +292,11 @@ def splitext(path): return base, ext -def renames(old, new): - """Like os.renames(), but handles renaming across devices.""" +def renames(old, new, preserve=None): + """Like os.renames(), but handles renaming across devices. The + preserve flag can be used to define a directory that will not be + deleted. + """ # Implementation borrowed from os.renames(). head, tail = os.path.split(new) if head and tail and not os.path.exists(head): @@ -302,14 +305,15 @@ def renames(old, new): shutil.move(old, new) head, tail = os.path.split(old) - if head and tail: + if head and tail and (preserve is None or + normalize_path(head) != normalize_path(preserve)): try: os.removedirs(head) except OSError: pass -def is_local(path): +def is_local(path, target=None): """ Return True if path is within sys.prefix, if we're running in a virtualenv. @@ -318,10 +322,16 @@ def is_local(path): """ if not running_under_virtualenv(): return True - return normalize_path(path).startswith(normalize_path(sys.prefix)) + norm_path = normalize_path(path) + for valid_root in sys.prefix, target: + if valid_root is None: + continue + if norm_path.startswith(normalize_path(valid_root)): + return True + return False -def dist_is_local(dist): +def dist_is_local(dist, target=None): """ Return True if given Distribution object is installed locally (i.e. within current virtualenv). @@ -329,7 +339,7 @@ def dist_is_local(dist): Always True if we're not in a virtualenv. """ - return is_local(dist_location(dist)) + return is_local(dist_location(dist), target=target) def dist_in_usersite(dist): diff --git a/tests/functional/test_uninstall.py b/tests/functional/test_uninstall.py index eae3261f2f1..5816355b980 100644 --- a/tests/functional/test_uninstall.py +++ b/tests/functional/test_uninstall.py @@ -8,6 +8,7 @@ from mock import patch from tests.lib import assert_all_changes, pyversion from tests.lib.local_repos import local_repo, local_checkout +from tests.lib.path import Path from pip.util import rmtree @@ -355,3 +356,30 @@ def test_uninstall_wheel(script, data): assert dist_info_folder in result.files_created result2 = script.pip('uninstall', 'simple.dist', '-y') assert_all_changes(result, result2, []) + + +def test_uninstall_package_with_target(script, data): + """ + Test uninstalling a package from a target. + """ + target_dir = script.scratch_path / 'target' + result = script.pip('install', '-t', target_dir, 'initools==0.1') + pkg_path = Path('scratch') / 'target' / 'initools' + assert pkg_path in result.files_created, (str(result)) + result2 = script.pip('uninstall', 'initools', '-y', '-t', target_dir) + assert_all_changes(result, result2, []) + + +def test_uninstall_wheel_with_target(script, data): + """ + Test uninstalling a package from a target. + """ + target_dir = script.scratch_path / 'target' + package = data.packages.join("simple.dist-0.1-py2.py3-none-any.whl") + result = script.pip('install', package, '--no-index', '-t', target_dir) + pkg_path = Path('scratch') / 'target' / 'simpledist' + assert pkg_path in result.files_created, (str(result)) + dist_info_folder = Path('scratch') / 'target' / 'simple.dist-0.1.dist-info' + assert dist_info_folder in result.files_created + result2 = script.pip('uninstall', 'simple.dist', '-y', '-t', target_dir) + assert_all_changes(result, result2, [])