Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added a --target parameter to uninstall to allow uninstalling from custom folders #1912

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion pip/commands/uninstall.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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)
34 changes: 26 additions & 8 deletions pip/req/req_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions pip/req/req_set.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
9 changes: 5 additions & 4 deletions pip/req/req_uninstall.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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" %
(
Expand Down Expand Up @@ -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(
Expand Down
24 changes: 17 additions & 7 deletions pip/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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.

Expand All @@ -318,18 +322,24 @@ 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).

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):
Expand Down
28 changes: 28 additions & 0 deletions tests/functional/test_uninstall.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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, [])