Repository: pypa/pip

Issue:
Provide a way to perform non-eager upgrades
Ref #59, #3786

pip should provide a way to do non-eager upgrades, i.e. upgrade a dependency only if it no longer satisfies the newer parent version requirements.

To be decided:
- [x] Should this behaviour be default?
- [x] If so, should the current behaviour (eager upgrades) be kept and exposed?



Discussion:
My answers to the questions in OP are:
- Yes.
- I don't need it but someone might so, let's keep it for a little while... deprecated state?

Quoting myself from #3786:

> a minimum disruption change would be:
> - `pip install --upgrade` provides non-eager upgrades by default.
> - Add a `--upgrade-strategy=[eager/non-eager]` (with any spelling) to choose your upgrade strategy, iff you really want to provide eager upgrades.

What is the status of this work now?

It got stalled because I ran out of free time. And another time, I messed up the permissions on my system and ate up time trying to fix it instead of working on this.

I have an unpushed, partially tested patch that I was planning to visit this weekend to polish and publish, hopefully as another PR.


Resolving patch:
diff --git a/pip/commands/install.py b/pip/commands/install.py
--- 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
--- 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


Test patch:
diff --git a/tests/data/packages/README.txt b/tests/data/packages/README.txt
--- 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
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
--- 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):
     """