Repository: conda/conda

Issue:
conda remove doesn't complain if only some of the packages don't exist
If I do `conda remove foo bar` and neither of them are in the current environment then I get an error.  However if I do `conda remove foo bar` and `foo` is present but `bar` is not then `foo` gets removed but there is no error or warning that `bar` wasn't there and was not removed.  I discovered this because I misunderstood the behavior of the `--features` flag, so it also applies to removing features.

I would suggest that a warning is required as a minimum, and perhaps it should be an error to be "symmetric" with a `conda install foo bar` where `foo` exists to be installed but `bar` does not (which just reports an error, and does not install anything).


Discussion:

Resolving patch:
diff --git a/conda/core/solve.py b/conda/core/solve.py
--- a/conda/core/solve.py
+++ b/conda/core/solve.py
@@ -189,15 +189,30 @@ def solve_final_state(self, deps_modifier=NULL, prune=NULL, ignore_pinned=NULL,
                                        for spec in _track_fts_specs))
             graph = PrefixGraph((index[dist] for dist in solution), itervalues(specs_map))
 
-            removed_records = []
+            all_removed_records = []
+            no_removed_records_specs = []
             for spec in specs_to_remove:
                 # If the spec was a track_features spec, then we need to also remove every
                 # package with a feature that matches the track_feature. The
                 # `graph.remove_spec()` method handles that for us.
                 log.trace("using PrefixGraph to remove records for %s", spec)
-                removed_records.extend(graph.remove_spec(spec))
+                removed_records = graph.remove_spec(spec)
+                if removed_records:
+                    all_removed_records.extend(removed_records)
+                else:
+                    no_removed_records_specs.append(spec)
+
+            # ensure that each spec in specs_to_remove is actually associated with removed records
+            unmatched_specs_to_remove = tuple(
+                spec for spec in no_removed_records_specs
+                if not any(spec.match(rec) for rec in all_removed_records)
+            )
+            if unmatched_specs_to_remove:
+                raise PackagesNotFoundError(
+                    tuple(sorted(str(s) for s in unmatched_specs_to_remove))
+                )
 
-            for rec in removed_records:
+            for rec in all_removed_records:
                 # We keep specs (minus the feature part) for the non provides_features packages
                 # if they're in the history specs.  Otherwise, we pop them from the specs_map.
                 rec_has_a_feature = set(rec.features or ()) & feature_names
@@ -210,9 +225,6 @@ def solve_final_state(self, deps_modifier=NULL, prune=NULL, ignore_pinned=NULL,
 
             solution = tuple(Dist(rec) for rec in graph.records)
 
-            if not removed_records and not prune:
-                raise PackagesNotFoundError(tuple(spec.name for spec in specs_to_remove))
-
         # We handle as best as possible environments in inconsistent states. To do this,
         # we remove now from consideration the set of packages causing inconsistencies,
         # and then we add them back in following the main SAT call.


Test patch:
diff --git a/tests/test_create.py b/tests/test_create.py
--- a/tests/test_create.py
+++ b/tests/test_create.py
@@ -710,6 +710,15 @@ def test_remove_all(self):
             assert exists(join(prefix, PYTHON_BINARY))
             assert_package_is_installed(prefix, 'python')
 
+            # regression test for #2154
+            with pytest.raises(PackagesNotFoundError) as exc:
+                run_command(Commands.REMOVE, prefix, 'python', 'foo', 'numpy')
+            assert repr(exc.value) == dals("""
+            PackagesNotFoundError: The following packages are missing from the target environment:
+              - foo
+              - numpy
+            """)
+
             run_command(Commands.REMOVE, prefix, '--all')
             assert not exists(prefix)
 
@@ -995,13 +1004,14 @@ def test_update_deps_flag_present(self):
             assert package_is_installed(prefix, 'itsdangerous')
             assert package_is_installed(prefix, 'flask')
 
+    @pytest.mark.skipif(True, reason="Add this test back someday.")
     # @pytest.mark.skipif(not on_win, reason="shortcuts only relevant on Windows")
-    # def test_shortcut_in_underscore_env_shows_message(self):
-    #     prefix = make_temp_prefix("_" + str(uuid4())[:7])
-    #     with make_temp_env(prefix=prefix):
-    #         stdout, stderr = run_command(Commands.INSTALL, prefix, "console_shortcut")
-    #         assert ("Environment name starts with underscore '_'.  "
-    #                 "Skipping menu installation." in stderr)
+    def test_shortcut_in_underscore_env_shows_message(self):
+        prefix = make_temp_prefix("_" + str(uuid4())[:7])
+        with make_temp_env(prefix=prefix):
+            stdout, stderr = run_command(Commands.INSTALL, prefix, "console_shortcut")
+            assert ("Environment name starts with underscore '_'.  "
+                    "Skipping menu installation." in stderr)
 
     @pytest.mark.skipif(not on_win, reason="shortcuts only relevant on Windows")
     def test_shortcut_not_attempted_with_no_shortcuts_arg(self):
diff --git a/tests/test_instructions.py b/tests/test_instructions.py
--- a/tests/test_instructions.py
+++ b/tests/test_instructions.py
@@ -41,14 +41,6 @@ def handle(self, record):
 
 class TestExecutePlan(unittest.TestCase):
 
-    # def test_invalid_instruction(self):
-    #     index = {'This is an index': True}
-    #
-    #     plan = [('DOES_NOT_EXIST', ())]
-    #
-    #     with self.assertRaises(exceptions.InvalidInstruction):
-    #         execute_instructions(plan, index, verbose=False)
-
     def test_simple_instruction(self):
 
         index = {'This is an index': True}
@@ -86,42 +78,6 @@ def simple_cmd(state, arg):
         execute_instructions(plan, index, verbose=False)
         self.assertTrue(simple_cmd.called)
 
-    # def test_progess(self):
-    #
-    #     index = {'This is an index': True}
-    #
-    #     plan = [
-    #         ('PROGRESS', '2'),
-    #         ('LINK', 'ipython'),
-    #         ('LINK', 'menuinst'),
-    #     ]
-    #
-    #     def cmd(state, arg):
-    #         pass  # NO-OP
-    #
-    #     _commands = {'PROGRESS': PROGRESS_CMD, 'LINK': cmd, 'UNLINKLINKTRANSACTION': cmd}
-    #     h = LoggingTestHandler()
-    #
-    #     update_logger = getLogger('progress.update')
-    #     update_logger.setLevel(DEBUG)
-    #     update_logger.addHandler(h)
-    #
-    #     stop_logger = getLogger('progress.stop')
-    #     stop_logger.setLevel(DEBUG)
-    #     stop_logger.addHandler(h)
-    #
-    #     execute_instructions(plan, index, _commands=_commands)
-    #
-    #     update_logger.removeHandler(h)
-    #     stop_logger.removeHandler(h)
-    #
-    #     expected = [('progress.update', ('ipython', 0)),
-    #                 ('progress.update', ('menuinst', 1)),
-    #                 ('progress.stop', None)
-    #                 ]
-    #
-    #     self.assertEqual(h.records, expected)
-
     def test_check_files_in_tarball_files_exist(self):
         source_dir = os.getcwd()
         files = [__file__]
diff --git a/tests/test_plan.py b/tests/test_plan.py
--- a/tests/test_plan.py
+++ b/tests/test_plan.py
@@ -90,52 +90,6 @@ def check(self, specs, added):
         specs = [s.split(' (')[0] for s in specs]
         self.assertEqual(specs, new_specs)
 
-    # def test_1(self):
-    #     self.linked = solve(['anaconda 1.5.0', 'python 2.7*', 'numpy 1.7*'])
-    #     for specs, added in [
-    #         (['python 3*'], []),
-    #         (['python'], ['python 2.7*']),
-    #         (['scipy'], ['python 2.7*']),
-    #         ]:
-    #         self.check(specs, added)
-    #
-    # def test_2(self):
-    #     self.linked = solve(['anaconda 1.5.0', 'python 2.6*', 'numpy 1.6*'])
-    #     for specs, added in [
-    #         (['python'], ['python 2.6*']),
-    #         (['numpy'], ['python 2.6*']),
-    #         (['pandas'], ['python 2.6*']),
-    #         # however, this would then be unsatisfiable
-    #         (['python 3*', 'numpy'], []),
-    #         ]:
-    #         self.check(specs, added)
-    #
-    # def test_3(self):
-    #     self.linked = solve(['anaconda 1.5.0', 'python 3.3*'])
-    #     for specs, added in [
-    #         (['python'], ['python 3.3*']),
-    #         (['numpy'], ['python 3.3*']),
-    #         (['scipy'], ['python 3.3*']),
-    #         ]:
-    #         self.check(specs, added)
-    #
-    # def test_4(self):
-    #     self.linked = []
-    #     for dp in ('2.7', '3.5'):
-    #         with env_var('CONDA_DEFAULT_PYTHON', dp, reset_context):
-    #             ps = ['python 2.7*'] if context.default_python == '2.7' else []
-    #             for specs, added in [
-    #                 (['python'], ps),
-    #                 (['numpy'], ps),
-    #                 (['scipy'], ps),
-    #                 (['anaconda'], ps),
-    #                 (['anaconda 1.5.0 np17py27_0'], []),
-    #                 (['sympy 0.7.2 py27_0'], []),
-    #                 (['scipy 0.12.0 np16py27_0'], []),
-    #                 (['anaconda', 'python 3*'], []),
-    #                 ]:
-    #                 self.check(specs, added)
-
 
 def test_display_actions_0():
     os.environ['CONDA_SHOW_CHANNEL_URLS'] = 'False'