diff --git a/pip/commands/install.py b/pip/commands/install.py index af577c673d1..9a90e6f0ac3 100644 --- a/pip/commands/install.py +++ b/pip/commands/install.py @@ -88,8 +88,21 @@ def __init__(self, *args, **kw): dest='upgrade', action='store_true', help='Upgrade all specified packages to the newest available ' - 'version. This process is recursive regardless of whether ' - 'a dependency is already satisfied.' + 'version. The handling of dependencies depends on the ' + 'upgrade-strategy used.' + ) + + cmd_opts.add_option( + '--upgrade-strategy', + dest='upgrade_strategy', + default='eager', + choices=['only-if-needed', 'eager'], + help='Determines how dependency upgrading should be handled. ' + '"eager" - dependencies are upgraded regardless of ' + 'whether the currently installed version satisfies the ' + 'requirements of the upgraded package(s). ' + '"only-if-needed" - are upgraded only when they do not ' + 'satisfy the requirements of the upgraded package(s).' ) cmd_opts.add_option( @@ -278,6 +291,7 @@ def run(self, options, args): src_dir=options.src_dir, download_dir=options.download_dir, upgrade=options.upgrade, + upgrade_strategy=options.upgrade_strategy, as_egg=options.as_egg, ignore_installed=options.ignore_installed, ignore_dependencies=options.ignore_dependencies, diff --git a/pip/req/req_set.py b/pip/req/req_set.py index 40b0d3e1307..aca8c40efd9 100644 --- a/pip/req/req_set.py +++ b/pip/req/req_set.py @@ -140,10 +140,10 @@ def prep_for_dist(self): class RequirementSet(object): def __init__(self, build_dir, src_dir, download_dir, upgrade=False, - ignore_installed=False, as_egg=False, target_dir=None, - ignore_dependencies=False, force_reinstall=False, - use_user_site=False, session=None, pycompile=True, - isolated=False, wheel_download_dir=None, + upgrade_strategy=None, ignore_installed=False, as_egg=False, + target_dir=None, ignore_dependencies=False, + force_reinstall=False, use_user_site=False, session=None, + pycompile=True, isolated=False, wheel_download_dir=None, wheel_cache=None, require_hashes=False): """Create a RequirementSet. @@ -170,6 +170,7 @@ def __init__(self, build_dir, src_dir, download_dir, upgrade=False, # the wheelhouse output by 'pip wheel'. self.download_dir = download_dir self.upgrade = upgrade + self.upgrade_strategy = upgrade_strategy self.ignore_installed = ignore_installed self.force_reinstall = force_reinstall self.requirements = Requirements() @@ -241,6 +242,8 @@ def add_requirement(self, install_req, parent_req_name=None): install_req.use_user_site = self.use_user_site install_req.target_dir = self.target_dir install_req.pycompile = self.pycompile + install_req.is_direct = (parent_req_name is None) + if not name: # url or path requirement w/o an egg fragment self.unnamed_requirements.append(install_req) @@ -375,6 +378,13 @@ def prepare_files(self, finder): if hash_errors: raise hash_errors + def _is_upgrade_allowed(self, req): + return self.upgrade and ( + self.upgrade_strategy == "eager" or ( + self.upgrade_strategy == "only-if-needed" and req.is_direct + ) + ) + def _check_skip_installed(self, req_to_install, finder): """Check if req_to_install should be skipped. @@ -396,17 +406,20 @@ def _check_skip_installed(self, req_to_install, finder): # Check whether to upgrade/reinstall this req or not. req_to_install.check_if_exists() if req_to_install.satisfied_by: - skip_reason = 'satisfied (use --upgrade to upgrade)' - if self.upgrade: - best_installed = False + upgrade_allowed = self._is_upgrade_allowed(req_to_install) + + # Is the best version is installed. + best_installed = False + + if upgrade_allowed: # For link based requirements we have to pull the # tree down and inspect to assess the version #, so # its handled way down. if not (self.force_reinstall or req_to_install.link): try: - finder.find_requirement(req_to_install, self.upgrade) + finder.find_requirement( + req_to_install, upgrade_allowed) except BestVersionAlreadyInstalled: - skip_reason = 'up-to-date' best_installed = True except DistributionNotFound: # No distribution found, so we squash the @@ -423,6 +436,15 @@ def _check_skip_installed(self, req_to_install, finder): req_to_install.conflicts_with = \ req_to_install.satisfied_by req_to_install.satisfied_by = None + + # Figure out a nice message to say why we're skipping this. + if best_installed: + skip_reason = 'already up-to-date' + elif self.upgrade_strategy == "only-if-needed": + skip_reason = 'not upgraded as not directly required' + else: + skip_reason = 'already satisfied' + return skip_reason else: return None @@ -463,7 +485,7 @@ def _prepare_file(self, 'req_to_install.satisfied_by is set to %r' % (req_to_install.satisfied_by,)) logger.info( - 'Requirement already %s: %s', skip_reason, + 'Requirement %s: %s', skip_reason, req_to_install) else: if (req_to_install.link and @@ -520,7 +542,10 @@ def _prepare_file(self, % (req_to_install, req_to_install.source_dir) ) req_to_install.populate_link( - finder, self.upgrade, require_hashes) + finder, + self._is_upgrade_allowed(req_to_install), + require_hashes + ) # We can't hit this spot and have populate_link return None. # req_to_install.satisfied_by is None here (because we're # guarded) and upgrade has no impact except when satisfied_by diff --git a/tests/data/packages/README.txt b/tests/data/packages/README.txt index c36d77f1b0e..b2f61b5138c 100644 --- a/tests/data/packages/README.txt +++ b/tests/data/packages/README.txt @@ -108,3 +108,8 @@ requires_wheelbroken_upper -------------------------- Requires wheelbroken and upper - used for testing implicit wheel building during install. + +require_simple-1.0.tar.gz +------------------------ +contains "require_simple" package which requires simple>=2.0 - used for testing +if dependencies are handled correctly. diff --git a/tests/data/packages/require_simple-1.0.tar.gz b/tests/data/packages/require_simple-1.0.tar.gz new file mode 100644 index 00000000000..c4eca04a783 Binary files /dev/null and b/tests/data/packages/require_simple-1.0.tar.gz differ diff --git a/tests/functional/test_install_upgrade.py b/tests/functional/test_install_upgrade.py index f4a78f0ca7c..10c95717a31 100644 --- a/tests/functional/test_install_upgrade.py +++ b/tests/functional/test_install_upgrade.py @@ -23,6 +23,112 @@ def test_no_upgrade_unless_requested(script): ) +def test_invalid_upgrade_strategy_causes_error(script): + """ + It errors out when the upgrade-strategy is an invalid/unrecognised one + + """ + result = script.pip_install_local( + '--upgrade', '--upgrade-strategy=bazinga', 'simple', + expect_error=True + ) + + assert result.returncode + assert "invalid choice" in result.stderr + + +def test_only_if_needed_does_not_upgrade_deps_when_satisfied(script): + """ + It doesn't upgrade a dependency if it already satisfies the requirements. + + """ + script.pip_install_local('simple==2.0', expect_error=True) + result = script.pip_install_local( + '--upgrade', '--upgrade-strategy=only-if-needed', 'require_simple', + expect_error=True + ) + + assert ( + (script.site_packages / 'require_simple-1.0-py%s.egg-info' % pyversion) + not in result.files_deleted + ), "should have installed require_simple==1.0" + assert ( + (script.site_packages / 'simple-2.0-py%s.egg-info' % pyversion) + not in result.files_deleted + ), "should not have uninstalled simple==2.0" + + +def test_only_if_needed_does_upgrade_deps_when_no_longer_satisfied(script): + """ + It does upgrade a dependency if it no longer satisfies the requirements. + + """ + script.pip_install_local('simple==1.0', expect_error=True) + result = script.pip_install_local( + '--upgrade', '--upgrade-strategy=only-if-needed', 'require_simple', + expect_error=True + ) + + assert ( + (script.site_packages / 'require_simple-1.0-py%s.egg-info' % pyversion) + not in result.files_deleted + ), "should have installed require_simple==1.0" + assert ( + script.site_packages / 'simple-3.0-py%s.egg-info' % + pyversion in result.files_created + ), "should have installed simple==3.0" + assert ( + script.site_packages / 'simple-1.0-py%s.egg-info' % + pyversion in result.files_deleted + ), "should have uninstalled simple==1.0" + + +def test_eager_does_upgrade_dependecies_when_currently_satisfied(script): + """ + It does upgrade a dependency even if it already satisfies the requirements. + + """ + script.pip_install_local('simple==2.0', expect_error=True) + result = script.pip_install_local( + '--upgrade', '--upgrade-strategy=eager', 'require_simple', + expect_error=True + ) + + assert ( + (script.site_packages / 'require_simple-1.0-py%s.egg-info' % pyversion) + not in result.files_deleted + ), "should have installed require_simple==1.0" + assert ( + (script.site_packages / 'simple-2.0-py%s.egg-info' % pyversion) + in result.files_deleted + ), "should have uninstalled simple==2.0" + + +def test_eager_does_upgrade_dependecies_when_no_longer_satisfied(script): + """ + It does upgrade a dependency if it no longer satisfies the requirements. + + """ + script.pip_install_local('simple==1.0', expect_error=True) + result = script.pip_install_local( + '--upgrade', '--upgrade-strategy=eager', 'require_simple', + expect_error=True + ) + + assert ( + (script.site_packages / 'require_simple-1.0-py%s.egg-info' % pyversion) + not in result.files_deleted + ), "should have installed require_simple==1.0" + assert ( + script.site_packages / 'simple-3.0-py%s.egg-info' % + pyversion in result.files_created + ), "should have installed simple==3.0" + assert ( + script.site_packages / 'simple-1.0-py%s.egg-info' % + pyversion in result.files_deleted + ), "should have uninstalled simple==1.0" + + @pytest.mark.network def test_upgrade_to_specific_version(script): """