Repository: pypa/pip

Issue:
Idempotent "pip uninstall"
If I run `pip uninstall a b` and `a` is installed but `b` is not the `pip uninstall` will error out and not uninstall `b` - as requested. I think this behavior is very surprising. Pip should maybe warn about packages that are already uninstalled? 

IOW, if the goal of uninstall is to remove a package, then if the package is already removed then it's a success. Why is it an error?



Discussion:
`apt-get`, for a nice example, works like you describe. It/s called idempotence. However, as a major difference between `apt-get` and `pip`, the former knows about _all_ available packages. `pip` doesn't without looking into some, potentially huge remote resource. If I hit `pip uninstall asdjkfh`, what if I actually meant `pip uninstall appdirs`? In that case, it's better to not pass errors silently.

By the way, the title of this issue is misleading, I think. `pip` errors because package `b` is not installed, regardless of whether you supply it to pip first or otherwise.

How about now?

Better. :)

Two points:
1. If you know `b` is not installed, why are you asking for it to be uninstalled? If you don't know, then an error "`b` is not installed" seems a reasonable way for you to find out.
2. In a real scenario, as @kernc pointed out, it's more likely that trying to uninstall a package that isn't installed is a typo, so getting told you made a mistake is what you'd want.

If what you're trying to do is say "make sure that these packages aren't present", then maybe something like an `--ignore-missing` option to `pip uninstall` would be helpful. But I wouldn't want it to be the default (errors should not pass silently). I'm not convinced it's useful enough to be worth adding, personally, but I'm not against it if enough people think it's worthwhile.

I would very much like an `--ignore-missing` parameter for `pip uninstall`. This would be very handy for scripts that want to ensure that packages are in a known state.

@dflock In shell scripts, this is normally worked around in the following way:

``` bash
pip list | grep $package && pip uninstall $package

# or

pip uninstall $package || true
```

`grep $package` when there's Flask and Flask-this and Flask-that does not sound like a robust solution :)

Yes, I ended up using `pip uninstall $package || true` - but it's still a workaround.

Problem is a single uninstall doesn't guarantee anything, especially if you have those pesky multi-version eggs around.

@ionelmc 
Can you point me to more information on the problem you mentioned?

I'm currently preparing the deployment of a release of our project that includes a lot of packages upgrades, requirements cleanup etc, and as far as I'm concerned the "--ignore-missing" param would greatly help the deployment's script testing phase... just my 2 cents.

what is the problem here?  does it just need someone to make a pull request, or is there disagreement about the feature?

`--ignore-missing` param would be useful, or even better have that option as default and instead a `--fail-fast` option or similar to give the less useful current behaviour
@anentropic As I've said, I prefer the option to be off by default, if it were to exist at all. So maybe there's a disagreement - depends on if anyone else wants to make an issue about preferring it to be on by default. Assuming no disagreement, it needs someone to do the work to implement it (including docs, tests, etc), and then we'll see where it goes from there. Depends somewhat on how intrusive the actual change ends up being.
So, basically someone needs to make a PR implementing this change (adding `--ignore-missing` to `pip uninstall`) for this to go forward?


My vote would be to change default return code and to return success if in the end the module is no longer installed. That's real idempotency, not having to add an additional parameter to obtain that behaviour.

Resolving patch:
diff --git a/src/pip/_internal/commands/uninstall.py b/src/pip/_internal/commands/uninstall.py
--- a/src/pip/_internal/commands/uninstall.py
+++ b/src/pip/_internal/commands/uninstall.py
@@ -64,7 +64,8 @@ def run(self, options, args):
                     '"pip help %(name)s")' % dict(name=self.name)
                 )
             for req in reqs_to_uninstall.values():
-                req.uninstall(
+                uninstall_pathset = req.uninstall(
                     auto_confirm=options.yes, verbose=options.verbose != 0
                 )
-                req.uninstalled_pathset.commit()
+                if uninstall_pathset:
+                    uninstall_pathset.commit()
diff --git a/src/pip/_internal/req/req_install.py b/src/pip/_internal/req/req_install.py
--- a/src/pip/_internal/req/req_install.py
+++ b/src/pip/_internal/req/req_install.py
@@ -648,13 +648,13 @@ def uninstall(self, auto_confirm=False, verbose=False):
 
         """
         if not self.check_if_exists():
-            raise UninstallationError(
-                "Cannot uninstall requirement %s, not installed" % (self.name,)
-            )
+            logger.warning("Skipping %s as it is not installed.", self.name)
+            return
         dist = self.satisfied_by or self.conflicts_with
 
-        self.uninstalled_pathset = UninstallPathSet.from_dist(dist)
-        self.uninstalled_pathset.remove(auto_confirm, verbose)
+        uninstalled_pathset = UninstallPathSet.from_dist(dist)
+        uninstalled_pathset.remove(auto_confirm, verbose)
+        return uninstalled_pathset
 
     def archive(self, build_dir):
         assert self.source_dir
diff --git a/src/pip/_internal/req/req_set.py b/src/pip/_internal/req/req_set.py
--- a/src/pip/_internal/req/req_set.py
+++ b/src/pip/_internal/req/req_set.py
@@ -216,7 +216,9 @@ def install(self, install_options, global_options=(), *args, **kwargs):
                         requirement.conflicts_with,
                     )
                     with indent_log():
-                        requirement.uninstall(auto_confirm=True)
+                        uninstalled_pathset = requirement.uninstall(
+                            auto_confirm=True
+                        )
                 try:
                     requirement.install(
                         install_options,
@@ -231,7 +233,7 @@ def install(self, install_options, global_options=(), *args, **kwargs):
                     )
                     # if install did not succeed, rollback previous uninstall
                     if should_rollback:
-                        requirement.uninstalled_pathset.rollback()
+                        uninstalled_pathset.rollback()
                     raise
                 else:
                     should_commit = (
@@ -239,7 +241,7 @@ def install(self, install_options, global_options=(), *args, **kwargs):
                         requirement.install_succeeded
                     )
                     if should_commit:
-                        requirement.uninstalled_pathset.commit()
+                        uninstalled_pathset.commit()
                 requirement.remove_temporary_source()
 
         return to_install


Test patch:
diff --git a/tests/functional/test_uninstall.py b/tests/functional/test_uninstall.py
--- a/tests/functional/test_uninstall.py
+++ b/tests/functional/test_uninstall.py
@@ -468,3 +468,25 @@ def test_uninstall_editable_and_pip_install(script, data):
     ) in uninstall2.files_deleted, list(uninstall2.files_deleted.keys())
     list_result2 = script.pip('list', '--format=json')
     assert "FSPkg" not in {p["name"] for p in json.loads(list_result2.stdout)}
+
+
+def test_uninstall_ignores_missing_packages(script, data):
+    """Uninstall of a non existent package prints a warning and exits cleanly
+    """
+    result = script.pip(
+        'uninstall', '-y', 'non-existent-pkg', expect_stderr=True,
+    )
+
+    assert "Skipping non-existent-pkg as it is not installed." in result.stderr
+    assert result.returncode == 0, "Expected clean exit"
+
+
+def test_uninstall_ignores_missing_packages_and_uninstalls_rest(script, data):
+    script.pip_install_local('simple')
+    result = script.pip(
+        'uninstall', '-y', 'non-existent-pkg', 'simple', expect_stderr=True,
+    )
+
+    assert "Skipping non-existent-pkg as it is not installed." in result.stderr
+    assert "Successfully uninstalled simple" in result.stdout
+    assert result.returncode == 0, "Expected clean exit"