diff --git a/news/4245.feature b/news/4245.feature new file mode 100644 index 00000000000..d8e7808f734 --- /dev/null +++ b/news/4245.feature @@ -0,0 +1 @@ +added confirming dependencies to ``uninstall`` command. \ No newline at end of file diff --git a/src/pip/_internal/commands/uninstall.py b/src/pip/_internal/commands/uninstall.py index ba5a2f55267..41f8d543009 100644 --- a/src/pip/_internal/commands/uninstall.py +++ b/src/pip/_internal/commands/uninstall.py @@ -5,6 +5,7 @@ from pip._internal.basecommand import Command from pip._internal.exceptions import InstallationError from pip._internal.req import InstallRequirement, parse_requirements +from pip._internal.utils.misc import confirm_dependencies from pip._internal.utils.misc import protect_pip_from_modification_on_windows @@ -65,6 +66,8 @@ def run(self, options, args): '"pip help %(name)s")' % dict(name=self.name) ) + if not confirm_dependencies(reqs_to_uninstall): + return protect_pip_from_modification_on_windows( modifying_pip="pip" in reqs_to_uninstall ) diff --git a/src/pip/_internal/utils/misc.py b/src/pip/_internal/utils/misc.py index f69381b8c68..aec6f37eb0b 100644 --- a/src/pip/_internal/utils/misc.py +++ b/src/pip/_internal/utils/misc.py @@ -19,6 +19,7 @@ from collections import deque from pip._vendor import pkg_resources +from pip._vendor.packaging.utils import canonicalize_name # NOTE: retrying is not annotated in typeshed as on 2017-07-17, which is # why we ignore the type on this import. from pip._vendor.retrying import retry # type: ignore @@ -854,6 +855,34 @@ def enum(*sequential, **named): return type('Enum', (), enums) +def confirm_dependencies(reqs_to_uninstall): + depends = list(get_depends(reqs_to_uninstall)) + if not depends: + return True + + for d, deps in depends: + msg = ("The following packages depend on " + "%s and may break if it is uninstalled:") % d + logger.info(msg) + for dep in deps: + logger.info(" " + dep) + + return ask("uninstall? (y/n)", options=('y', 'n')) == 'y' + + +def get_depends(reqs_to_uninstall): + dist_deps = {d.key: [canonicalize_name(r.name) for r in d.requires()] + for d in get_installed_distributions()} + logger.debug('dist_deps %s', dist_deps) + dependants = {req: [d for d, r in dist_deps.items() if req in r] + for req in reqs_to_uninstall} + logger.debug('dependants %s', dependants) + for d, deps in dependants.items(): + deps = [r for r in deps if r not in reqs_to_uninstall] + if deps: + yield d, deps + + def remove_auth_from_url(url): # Return a copy of url with 'username:password@' removed. # username/pass params are passed to subversion through flags diff --git a/tests/functional/dummy1-0.0.0-py2.py3-none-any.whl b/tests/functional/dummy1-0.0.0-py2.py3-none-any.whl new file mode 100644 index 00000000000..8bc76e833c9 Binary files /dev/null and b/tests/functional/dummy1-0.0.0-py2.py3-none-any.whl differ diff --git a/tests/functional/dummy1/dummy1/__init__.py b/tests/functional/dummy1/dummy1/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/functional/dummy1/setup.cfg b/tests/functional/dummy1/setup.cfg new file mode 100644 index 00000000000..2a9acf13daa --- /dev/null +++ b/tests/functional/dummy1/setup.cfg @@ -0,0 +1,2 @@ +[bdist_wheel] +universal = 1 diff --git a/tests/functional/dummy1/setup.py b/tests/functional/dummy1/setup.py new file mode 100644 index 00000000000..af308f27d33 --- /dev/null +++ b/tests/functional/dummy1/setup.py @@ -0,0 +1,6 @@ +from setuptools import setup +setup( + name="dummy1", + install_requires=["dummy2"], + packages=["dummy1"], +) diff --git a/tests/functional/dummy2-0.0.0-py2.py3-none-any.whl b/tests/functional/dummy2-0.0.0-py2.py3-none-any.whl new file mode 100644 index 00000000000..2f82bc52c33 Binary files /dev/null and b/tests/functional/dummy2-0.0.0-py2.py3-none-any.whl differ diff --git a/tests/functional/dummy2/dummy2/__init__.py b/tests/functional/dummy2/dummy2/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/functional/dummy2/setup.cfg b/tests/functional/dummy2/setup.cfg new file mode 100644 index 00000000000..2a9acf13daa --- /dev/null +++ b/tests/functional/dummy2/setup.cfg @@ -0,0 +1,2 @@ +[bdist_wheel] +universal = 1 diff --git a/tests/functional/dummy2/setup.py b/tests/functional/dummy2/setup.py new file mode 100644 index 00000000000..c3a9e24c200 --- /dev/null +++ b/tests/functional/dummy2/setup.py @@ -0,0 +1,6 @@ +from setuptools import setup +setup( + name="dummy2", + install_requires=["dummy1"], + packages=["dummy2"], +) diff --git a/tests/functional/test_uninstall.py b/tests/functional/test_uninstall.py index e667d03cd54..b871b950009 100644 --- a/tests/functional/test_uninstall.py +++ b/tests/functional/test_uninstall.py @@ -144,13 +144,97 @@ def test_basic_uninstall_namespace_package(script): assert join(script.site_packages, 'pd') in result.files_created, ( sorted(result.files_created.keys()) ) - result2 = script.pip('uninstall', 'pd.find', '-y', expect_error=True) + result2 = script.pip('uninstall', 'pd.requires', '-y', expect_error=True) assert join(script.site_packages, 'pd') not in result2.files_deleted, ( sorted(result2.files_deleted.keys()) ) + pd_requires_path = join(script.site_packages, 'pd', 'requires') + assert pd_requires_path in result2.files_deleted, ( + sorted(result2.files_deleted.keys()) + ) + + +@pytest.mark.network +def test_uninstall_dependency(script): + """ + Uninstall a distribution depended from other, answer `y` to confirmation. + + """ + result = script.pip('install', 'pd.requires==0.0.3', expect_error=True) + assert join(script.site_packages, 'pd') in result.files_created, ( + sorted(result.files_created.keys()) + ) + result2 = script.pip('uninstall', 'pd.find', '-y', + expect_error=True, stdin=b"y") + assert join(script.site_packages, 'pd') not in result2.files_deleted, ( + sorted(result2.files_deleted.keys()) + ) + assert join(script.site_packages, 'pd', 'find') in result2.files_deleted, ( + sorted(result2.files_deleted.keys()) + ) + + +@pytest.mark.network +def test_uninstall_dependency_no(script): + """ + Not uninstalling, answer `no` to confirmation. + + """ + result = script.pip('install', 'pd.requires==0.0.3', expect_error=True) + assert join(script.site_packages, 'pd') in result.files_created, ( + sorted(result.files_created.keys()) + ) + result2 = script.pip('uninstall', 'pd.find', '-y', + expect_error=True, stdin=b"n") + assert not result2.files_deleted + + +@pytest.mark.network +def test_uninstall_dependency_all(script): + """ + Uninstall all dependencies, no confirmation. + """ + result = script.pip('install', 'pd.requires==0.0.3', expect_error=True) + assert join(script.site_packages, 'pd') in result.files_created, ( + sorted(result.files_created.keys()) + ) + result2 = script.pip('uninstall', 'pd.requires', 'pd.find', '-y', + expect_error=True, stdin=b"y") assert join(script.site_packages, 'pd', 'find') in result2.files_deleted, ( sorted(result2.files_deleted.keys()) ) + pd_requires_path = join(script.site_packages, 'pd', 'requires') + assert pd_requires_path in result2.files_deleted, ( + sorted(result2.files_deleted.keys()) + ) + assert join(script.site_packages, 'pd') in result2.files_deleted, ( + sorted(result2.files_deleted.keys()) + ) + + +@pytest.mark.network +def test_uninstall_recursive_dependencies(script): + """ + Uninstall recursive dependencies. + """ + here = os.path.normpath(os.path.abspath(os.path.dirname(__file__))) + dummy1_whl = os.path.join(here, "dummy1-0.0.0-py2.py3-none-any.whl") + dummy2_whl = os.path.join(here, "dummy2-0.0.0-py2.py3-none-any.whl") + result = script.pip('install', dummy1_whl, dummy2_whl, expect_error=True) + assert join(script.site_packages, 'dummy1') in result.files_created, ( + sorted(result.files_created.keys()) + ) + assert join(script.site_packages, 'dummy2') in result.files_created, ( + sorted(result.files_created.keys()) + ) + result2 = script.pip('uninstall', 'dummy1', 'dummy2', '-y', + expect_error=True, stdin=b"y") + assert join(script.site_packages, 'dummy1') in result2.files_deleted, ( + sorted(result2.files_deleted.keys()) + ) + assert join(script.site_packages, 'dummy2') in result2.files_deleted, ( + sorted(result2.files_deleted.keys()) + ) def test_uninstall_overlapping_package(script, data): diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py index 63d076610c3..0864f4b65cf 100644 --- a/tests/unit/test_utils.py +++ b/tests/unit/test_utils.py @@ -14,11 +14,13 @@ import pytest from mock import Mock, patch +from pip._vendor.packaging.requirements import Requirement from pip._vendor.six import BytesIO from pip._internal.exceptions import ( HashMismatch, HashMissing, InstallationError, UnsupportedPythonVersion, ) +from pip._internal.req.req_install import InstallRequirement from pip._internal.utils.encoding import auto_decode from pip._internal.utils.glibc import check_glibc_version from pip._internal.utils.hashes import Hashes, MissingHashes @@ -595,6 +597,35 @@ def test_check_requires(self, metadata, should_raise): check_dist_requires_python(fake_dist) +@patch('pip._internal.utils.misc.ask') +@patch('pip._internal.utils.misc.get_installed_distributions') +def test_confirm_dependencies(mock_gid, mock_ask): + from pip._internal.utils.misc import confirm_dependencies + + class installed: + def __init__(self, key, requires): + self.key = key + self._requires = requires + + def requires(self): + return self._requires + + class installed_require: + def __init__(self, name): + self.name = name + + installed_packages = [ + installed('dependant', + [InstallRequirement(Requirement("dummy"), None)]), + ] + mock_gid.return_value = installed_packages + mock_ask.return_value = 'y' + + reqs_to_uninstall = ["dummy"] + result = confirm_dependencies(reqs_to_uninstall) + assert result + + class TestGetProg(object): @pytest.mark.parametrize(