Repository: conda/conda

Issue:
Infinite recursion in solver with blank string in features
I stumbled over this accidentally.
<!--
Hi!

This is an issue tracker for conda -- the package manager.  File feature requests
for conda here, as well as bug reports about something conda has messed up.

If your issue is a bug report or feature request for:
  * a specific conda package from Anaconda ('defaults' channel):
      ==> file at https://github.com/ContinuumIO/anaconda-issues
  * a specific conda package from conda-forge:
      ==> file at the corresponding feedstock under https://github.com/conda-forge
  * repo.continuum.io access and service:
      ==> file at https://github.com/ContinuumIO/anaconda-issues
  * anaconda.org access and service:
      ==> file at https://anaconda.org/contact/report
  * commands under 'conda build':
      ==> file at https://github.com/conda/conda-build
  * commands under 'conda env':
      ==> please file it here!
  * all other conda commands that start with 'conda':
      ==> please file it here!

-->

**I'm submitting a...**
  - [x] bug report
  - [ ] feature request


## Current Behavior
<!-- What actually happens?
     If you want to include console output, please use "Steps to Reproduce" below. -->
```
(base) [nwani@centos7-nwani scratch]$ conda install scratch -c local
Solving environment: failed

# >>>>>>>>>>>>>>>>>>>>>> ERROR REPORT <<<<<<<<<<<<<<<<<<<<<<

    Traceback (most recent call last):
      File "/home/nwani/m2/lib/python2.7/site-packages/conda/exceptions.py", line 789, in __call__
        return func(*args, **kwargs)
      File "/home/nwani/m2/lib/python2.7/site-packages/conda/cli/main.py", line 78, in _main
        exit_code = do_call(args, p)
      File "/home/nwani/m2/lib/python2.7/site-packages/conda/cli/conda_argparse.py", line 77, in do_call
        exit_code = getattr(module, func_name)(args, parser)
      File "/home/nwani/m2/lib/python2.7/site-packages/conda/cli/main_install.py", line 11, in execute
        install(args, parser, 'install')
      File "/home/nwani/m2/lib/python2.7/site-packages/conda/cli/install.py", line 236, in install
        force_reinstall=context.force,
      File "/home/nwani/m2/lib/python2.7/site-packages/conda/core/solve.py", line 504, in solve_for_transaction
        force_remove, force_reinstall)
      File "/home/nwani/m2/lib/python2.7/site-packages/conda/core/solve.py", line 437, in solve_for_diff
        final_precs = self.solve_final_state(deps_modifier, prune, ignore_pinned, force_remove)
      File "/home/nwani/m2/lib/python2.7/site-packages/conda/core/solve.py", line 317, in solve_final_state
        conflicting_specs = r.get_conflicting_specs(tuple(final_environment_specs))
      File "/home/nwani/m2/lib/python2.7/site-packages/conda/resolve.py", line 705, in get_conflicting_specs
        C = r2.gen_clauses()
      File "/home/nwani/m2/lib/python2.7/site-packages/conda/resolve.py", line 505, in gen_clauses
        C.Require(C.Or, nkey, self.push_MatchSpec(C, ms))
      File "/home/nwani/m2/lib/python2.7/site-packages/conda/resolve.py", line 477, in push_MatchSpec
        m = C.from_name(self.push_MatchSpec(C, ms2))
      File "/home/nwani/m2/lib/python2.7/site-packages/conda/resolve.py", line 477, in push_MatchSpec
        m = C.from_name(self.push_MatchSpec(C, ms2))
...yada...
...yada...
...yada...
      File "/home/nwani/m2/lib/python2.7/site-packages/conda/resolve.py", line 477, in push_MatchSpec
        m = C.from_name(self.push_MatchSpec(C, ms2))
      File "/home/nwani/m2/lib/python2.7/site-packages/conda/resolve.py", line 477, in push_MatchSpec
        m = C.from_name(self.push_MatchSpec(C, ms2))
      File "/home/nwani/m2/lib/python2.7/site-packages/conda/resolve.py", line 477, in push_MatchSpec
        m = C.from_name(self.push_MatchSpec(C, ms2))
      File "/home/nwani/m2/lib/python2.7/site-packages/conda/resolve.py", line 476, in push_MatchSpec
        ms2 = MatchSpec(track_features=tf) if tf else MatchSpec(nm)
      File "/home/nwani/m2/lib/python2.7/site-packages/conda/models/match_spec.py", line 76, in __call__
        return super(MatchSpecType, cls).__call__(**kwargs)
      File "/home/nwani/m2/lib/python2.7/site-packages/conda/models/match_spec.py", line 178, in __init__
        self._match_components = self._build_components(**kwargs)
      File "/home/nwani/m2/lib/python2.7/site-packages/conda/models/match_spec.py", line 365, in _build_components
        return frozendict(_make(key, value) for key, value in iteritems(kwargs))
      File "/home/nwani/m2/lib/python2.7/collections.py", line 69, in __init__
        self.__update(*args, **kwds)
      File "/home/nwani/m2/lib/python2.7/_abcoll.py", line 564, in update
        if isinstance(other, Mapping):
      File "/home/nwani/m2/lib/python2.7/abc.py", line 141, in __instancecheck__
        subtype in cls._abc_negative_cache):
      File "/home/nwani/m2/lib/python2.7/_weakrefset.py", line 75, in __contains__
        return wr in self.data
    RuntimeError: maximum recursion depth exceeded in cmp

`$ /home/nwani/m2/bin/conda install scratch -c local`

  environment variables:
                 CIO_TEST=<not set>
        CONDA_DEFAULT_ENV=base
             CONDA_PREFIX=/home/nwani/m2
    CONDA_PROMPT_MODIFIER=(base)
         CONDA_PYTHON_EXE=/home/nwani/m2/bin/python
               CONDA_ROOT=/home/nwani/m2
              CONDA_SHLVL=1
                     PATH=/home/nwani/m2/bin:/usr/local/bin:/usr/bin:/usr/local/sbin:/usr/sbin:/
                          home/nwani/.local/bin:/home/nwani/bin
       REQUESTS_CA_BUNDLE=<not set>
            SSL_CERT_FILE=<not set>

     active environment : base
    active env location : /home/nwani/m2
            shell level : 1
       user config file : /home/nwani/.condarc
 populated config files : 
          conda version : 4.4.9
    conda-build version : 3.4.1
         python version : 2.7.14.final.0
       base environment : /home/nwani/m2  (writable)
           channel URLs : file:///home/nwani/m2/conda-bld/linux-64
                          file:///home/nwani/m2/conda-bld/noarch
                          https://repo.continuum.io/pkgs/main/linux-64
                          https://repo.continuum.io/pkgs/main/noarch
                          https://repo.continuum.io/pkgs/free/linux-64
                          https://repo.continuum.io/pkgs/free/noarch
                          https://repo.continuum.io/pkgs/r/linux-64
                          https://repo.continuum.io/pkgs/r/noarch
                          https://repo.continuum.io/pkgs/pro/linux-64
                          https://repo.continuum.io/pkgs/pro/noarch
          package cache : /home/nwani/m2/pkgs
                          /home/nwani/.conda/pkgs
       envs directories : /home/nwani/m2/envs
                          /home/nwani/.conda/envs
               platform : linux-64
             user-agent : conda/4.4.9 requests/2.18.4 CPython/2.7.14 Linux/3.10.0-693.5.2.el7.x86_64 centos/7 glibc/2.17
                UID:GID : 1001:1001
             netrc file : None
           offline mode : False


An unexpected error has occurred. Conda has prepared the above report.
If submitted, this report will be used by core maintainers to improve
future releases of conda.
Would you like conda to send this report to the core maintainers?
[y/N]: y
Upload did not complete.
Thank you for helping to improve conda.
Opt-in to always sending reports (and not see this message again)
by running

    $ conda config --set report_errors true

```

### Steps to Reproduce
<!-- If the current behavior is a bug, please provide specific, minimal steps to independently reproduce.
     Include the exact conda commands that reproduce the issue and their output between the ticks below. -->
Create a conda package with the following recipe (although the recipe is incorrect, nothing complained):
```
package:
  name: scratch
  version: 1.0.0

build:
  features:
    -
```

## Expected Behavior
<!-- What do you think should happen? -->

1. conda-build should complain that value of a feature can't be blank
2. conda shouldn't go into infinite recursion

The issue happens because somehow conda passes `*[track_features=]` to `push_MatchSpec()` in resolve.py, which leads to infinite recursion with `push_MatchSpec()` being passed '*' again and again.

FWIW, conda 4.3* doesn't suffer from this problem and happily installs the package.


Discussion:
Oh that's bad.

Resolving patch:
diff --git a/conda/models/index_record.py b/conda/models/index_record.py
--- a/conda/models/index_record.py
+++ b/conda/models/index_record.py
@@ -96,6 +96,7 @@ def __init__(self, **kwargs):
     def box(self, instance, instance_type, val):
         if isinstance(val, string_types):
             val = val.replace(' ', ',').split(',')
+        val = tuple(f for f in (ff.strip() for ff in val) if f)
         return super(_FeaturesField, self).box(instance, instance_type, val)
 
     def dump(self, instance, instance_type, val):
diff --git a/conda/models/match_spec.py b/conda/models/match_spec.py
--- a/conda/models/match_spec.py
+++ b/conda/models/match_spec.py
@@ -680,14 +680,14 @@ def __init__(self, value):
         super(FeatureMatch, self).__init__(self._convert(value))
 
     def _convert(self, value):
-        if isinstance(value, frozenset):
-            return value
-        elif not value:
+        if not value:
             return frozenset()
         elif isinstance(value, string_types):
-            return frozenset(value.replace(' ', ',').split(','))
+            return frozenset(f for f in (
+                ff.strip() for ff in value.replace(' ', ',').split(',')
+            ) if f)
         else:
-            return frozenset(value)
+            return frozenset(f for f in (ff.strip() for ff in value) if f)
 
     def match(self, other):
         other = self._convert(other)
diff --git a/conda/resolve.py b/conda/resolve.py
--- a/conda/resolve.py
+++ b/conda/resolve.py
@@ -460,7 +460,9 @@ def push_MatchSpec(self, C, spec):
 
         simple = spec._is_single()
         nm = spec.get_exact_value('name')
-        tf = spec.get_exact_value('track_features')
+        tf = frozenset(_tf for _tf in (
+            f.strip() for f in spec.get_exact_value('track_features') or ()
+        ) if _tf)
 
         if nm:
             tgroup = libs = self.groups.get(nm, [])


Test patch:
diff --git a/tests/models/test_match_spec.py b/tests/models/test_match_spec.py
--- a/tests/models/test_match_spec.py
+++ b/tests/models/test_match_spec.py
@@ -368,32 +368,43 @@ def test_build_number_and_filename(self):
         assert ms.get_exact_value('build') == '0'
         assert ms._to_filename_do_not_use() == 'zlib-1.2.7-0.tar.bz2'
 
-    @pytest.mark.skip(reason="key-value features interface disabled in conda 4.4")
-    def test_key_value_features_match(self):
+    def test_track_features_match(self):
         dst = Dist('defaults::foo-1.2.3-4.tar.bz2')
         a = MatchSpec(features='test')
-        assert text_type(a) == "*[provides_features='test=true']"
+        assert text_type(a) == "*[features=test]"
         assert not a.match(DPkg(dst))
         assert not a.match(DPkg(dst, track_features=''))
+
+        a = MatchSpec(track_features='test')
         assert a.match(DPkg(dst, track_features='test'))
         assert not a.match(DPkg(dst, track_features='test2'))
-        assert a.match(DPkg(dst, track_features='test me'))
-        assert a.match(DPkg(dst, track_features='you test'))
-        assert a.match(DPkg(dst, track_features='you test me'))
-        assert a.get_exact_value('provides_features') == frozendict({'test': 'true'})
+        assert not a.match(DPkg(dst, track_features='test me'))
+        assert not a.match(DPkg(dst, track_features='you test'))
+        assert not a.match(DPkg(dst, track_features='you test me'))
+        assert a.get_exact_value('track_features') == frozenset(('test',))
 
-        b = MatchSpec(features='mkl')
+        b = MatchSpec(track_features='mkl')
         assert not b.match(DPkg(dst))
         assert b.match(DPkg(dst, track_features='mkl'))
-        assert b.match(DPkg(dst, track_features='blas=mkl'))
-        assert b.match(DPkg(dst, track_features='blas=mkl debug'))
+        assert b.match(DPkg(dst, track_features='mkl'))
+        assert not b.match(DPkg(dst, track_features='mkl debug'))
         assert not b.match(DPkg(dst, track_features='debug'))
 
-        c = MatchSpec(features='nomkl')
+        c = MatchSpec(track_features='nomkl')
         assert not c.match(DPkg(dst))
         assert not c.match(DPkg(dst, track_features='mkl'))
         assert c.match(DPkg(dst, track_features='nomkl'))
-        assert c.match(DPkg(dst, track_features='blas=nomkl debug'))
+        assert not c.match(DPkg(dst, track_features='nomkl debug'))
+
+        # regression test for #6860
+        d = MatchSpec(track_features='')
+        assert d.get_exact_value('track_features') == frozenset()
+        d = MatchSpec(track_features=' ')
+        assert d.get_exact_value('track_features') == frozenset()
+        d = MatchSpec(track_features=('', ''))
+        assert d.get_exact_value('track_features') == frozenset()
+        d = MatchSpec(track_features=('', '', 'test'))
+        assert d.get_exact_value('track_features') == frozenset(('test',))
 
     def test_bracket_matches(self):
         record = {