Repository: spack/spack

Issue:
String representation of a spec might be semantically different from the originating spec
For specs that have multi-valued variants specified and a dependency following them, the string representation of a Spec might be parsed differently from the originating Spec.

### Expected Result

If I declare something like:
```python
Spack version 0.11.2
Python 2.7.6, Linux x86_64
>>> import spack.spec
>>> s = spack.spec.Spec('fftw+mpi simd=avx2 ^mvapich2')
>>> str(s)
'fftw+mpi simd=avx2^mvapich2'
>>> t = spack.spec.Spec(str(s))
```
I expect `s` and `t` to be semantically the same spec

### Actual Result
The fact that `str(s)` is emitted without a space between the variant value and the dependency causes Spack to interpret `avx2^mvapich2` as a unique token.

### Steps to reproduce the issue
```python
Spack version 0.11.2
Python 2.7.6, Linux x86_64
>>> import spack.spec
>>> s = spack.spec.Spec('fftw+mpi simd=avx2 ^mvapich2')
>>> str(s)
'fftw+mpi simd=avx2^mvapich2'
>>> t = spack.spec.Spec(str(s))
>>> s.concretize()
>>> t.concretize()
Traceback (most recent call last):
  File "<console>", line 1, in <module>
  File "/home/mculpo/PycharmProjects/spack/lib/spack/spack/spec.py", line 1829, in concretize
    changes = (self.normalize(force),
  File "/home/mculpo/PycharmProjects/spack/lib/spack/spack/spec.py", line 2206, in normalize
    self.validate_or_raise()
  File "/home/mculpo/PycharmProjects/spack/lib/spack/spack/spec.py", line 2268, in validate_or_raise
    substitute_abstract_variants(spec)
  File "/home/mculpo/PycharmProjects/spack/lib/spack/spack/variant.py", line 615, in substitute_abstract_variants
    pkg_variant.validate_or_raise(new_variant, spec.package_class)
  File "/home/mculpo/PycharmProjects/spack/lib/spack/spack/variant.py", line 135, in validate_or_raise
    raise InvalidVariantValueError(self, not_allowed_values, pkg)
InvalidVariantValueError: invalid values for variant "simd" in package "fftw": ['avx2^mvapich2']
```


### Information on your system

Should not be relevant, as the problem is very likely in the parsing.



Discussion:

Resolving patch:
diff --git a/lib/spack/spack/spec.py b/lib/spack/spack/spec.py
--- a/lib/spack/spack/spec.py
+++ b/lib/spack/spack/spec.py
@@ -3123,7 +3123,7 @@ def cformat(self, *args, **kwargs):
         return self.format(*args, **kwargs)
 
     def dep_string(self):
-        return ''.join("^" + dep.format() for dep in self.sorted_deps())
+        return ''.join(" ^" + dep.format() for dep in self.sorted_deps())
 
     def __str__(self):
         ret = self.format() + self.dep_string()


Test patch:
diff --git a/lib/spack/spack/test/concretize.py b/lib/spack/spack/test/concretize.py
--- a/lib/spack/spack/test/concretize.py
+++ b/lib/spack/spack/test/concretize.py
@@ -524,3 +524,16 @@ def test_regression_issue_7705(self):
         s.concretize()
 
         assert not s.package.provides('lapack')
+
+    @pytest.mark.regression('7941')
+    def test_regression_issue_7941(self):
+        # The string representation of a spec containing
+        # an explicit multi-valued variant and a dependency
+        # might be parsed differently than the originating spec
+        s = Spec('a foobar=bar ^b')
+        t = Spec(str(s))
+
+        s.concretize()
+        t.concretize()
+
+        assert s.dag_hash() == t.dag_hash()
diff --git a/lib/spack/spack/test/spec_syntax.py b/lib/spack/spack/test/spec_syntax.py
--- a/lib/spack/spack/test/spec_syntax.py
+++ b/lib/spack/spack/test/spec_syntax.py
@@ -94,7 +94,7 @@ class TestSpecSyntax(object):
     # Parse checks
     # ========================================================================
 
-    def check_parse(self, expected, spec=None, remove_arch=True):
+    def check_parse(self, expected, spec=None):
         """Assert that the provided spec is able to be parsed.
 
            If this is called with one argument, it assumes that the
@@ -152,14 +152,18 @@ def test_anonymous_specs_with_multiple_parts(self):
         self.check_parse('@4.2: languages=go')
 
     def test_simple_dependence(self):
-        self.check_parse("openmpi^hwloc")
-        self.check_parse("openmpi^hwloc^libunwind")
+        self.check_parse("openmpi ^hwloc")
+        self.check_parse("openmpi ^hwloc", "openmpi^hwloc")
+
+        self.check_parse("openmpi ^hwloc ^libunwind")
+        self.check_parse("openmpi ^hwloc ^libunwind",
+                         "openmpi^hwloc^libunwind")
 
     def test_dependencies_with_versions(self):
-        self.check_parse("openmpi^hwloc@1.2e6")
-        self.check_parse("openmpi^hwloc@1.2e6:")
-        self.check_parse("openmpi^hwloc@:1.4b7-rc3")
-        self.check_parse("openmpi^hwloc@1.2e6:1.4b7-rc3")
+        self.check_parse("openmpi ^hwloc@1.2e6")
+        self.check_parse("openmpi ^hwloc@1.2e6:")
+        self.check_parse("openmpi ^hwloc@:1.4b7-rc3")
+        self.check_parse("openmpi ^hwloc@1.2e6:1.4b7-rc3")
 
     def test_multiple_specs(self):
         self.check_parse("mvapich emacs")
@@ -172,31 +176,33 @@ def test_multiple_specs_after_kv(self):
     def test_multiple_specs_long_second(self):
         self.check_parse('mvapich emacs@1.1.1%intel cflags="-O3"',
                          'mvapich emacs @1.1.1 %intel cflags=-O3')
-        self.check_parse('mvapich cflags="-O3 -fPIC" emacs^ncurses%intel')
+        self.check_parse('mvapich cflags="-O3 -fPIC" emacs ^ncurses%intel')
+        self.check_parse('mvapich cflags="-O3 -fPIC" emacs ^ncurses%intel',
+                         'mvapich cflags="-O3 -fPIC" emacs^ncurses%intel')
 
     def test_full_specs(self):
         self.check_parse(
             "mvapich_foo"
-            "^_openmpi@1.2:1.4,1.6%intel@12.1+debug~qt_4"
-            "^stackwalker@8.1_1e")
+            " ^_openmpi@1.2:1.4,1.6%intel@12.1+debug~qt_4"
+            " ^stackwalker@8.1_1e")
         self.check_parse(
             "mvapich_foo"
-            "^_openmpi@1.2:1.4,1.6%intel@12.1 debug=2 ~qt_4"
-            "^stackwalker@8.1_1e")
+            " ^_openmpi@1.2:1.4,1.6%intel@12.1 debug=2 ~qt_4"
+            " ^stackwalker@8.1_1e")
         self.check_parse(
             'mvapich_foo'
-            '^_openmpi@1.2:1.4,1.6%intel@12.1 cppflags="-O3" +debug~qt_4'
-            '^stackwalker@8.1_1e')
+            ' ^_openmpi@1.2:1.4,1.6%intel@12.1 cppflags="-O3" +debug~qt_4'
+            ' ^stackwalker@8.1_1e')
         self.check_parse(
             "mvapich_foo"
-            "^_openmpi@1.2:1.4,1.6%intel@12.1 debug=2 ~qt_4"
-            "^stackwalker@8.1_1e arch=test-redhat6-x86_32")
+            " ^_openmpi@1.2:1.4,1.6%intel@12.1 debug=2 ~qt_4"
+            " ^stackwalker@8.1_1e arch=test-redhat6-x86_32")
 
     def test_canonicalize(self):
         self.check_parse(
             "mvapich_foo"
-            "^_openmpi@1.2:1.4,1.6%intel@12.1:12.6+debug~qt_4"
-            "^stackwalker@8.1_1e",
+            " ^_openmpi@1.2:1.4,1.6%intel@12.1:12.6+debug~qt_4"
+            " ^stackwalker@8.1_1e",
 
             "mvapich_foo "
             "^_openmpi@1.6,1.2:1.4%intel@12.1:12.6+debug~qt_4 "
@@ -204,21 +210,21 @@ def test_canonicalize(self):
 
         self.check_parse(
             "mvapich_foo"
-            "^_openmpi@1.2:1.4,1.6%intel@12.1:12.6+debug~qt_4"
-            "^stackwalker@8.1_1e",
+            " ^_openmpi@1.2:1.4,1.6%intel@12.1:12.6+debug~qt_4"
+            " ^stackwalker@8.1_1e",
 
             "mvapich_foo "
             "^stackwalker@8.1_1e "
             "^_openmpi@1.6,1.2:1.4%intel@12.1:12.6~qt_4+debug")
 
         self.check_parse(
-            "x^y@1,2:3,4%intel@1,2,3,4+a~b+c~d+e~f",
+            "x ^y@1,2:3,4%intel@1,2,3,4+a~b+c~d+e~f",
             "x ^y~f+e~d+c~b+a@4,2:3,1%intel@4,3,2,1")
 
         self.check_parse(
             "x arch=test-redhat6-None "
-            "^y arch=test-None-x86_64 "
-            "^z arch=linux-None-None",
+            " ^y arch=test-None-x86_64 "
+            " ^z arch=linux-None-None",
 
             "x os=fe "
             "^y target=be "
@@ -226,12 +232,12 @@ def test_canonicalize(self):
 
         self.check_parse(
             "x arch=test-debian6-x86_64 "
-            "^y arch=test-debian6-x86_64",
+            " ^y arch=test-debian6-x86_64",
 
             "x os=default_os target=default_target "
             "^y os=default_os target=default_target")
 
-        self.check_parse("x^y", "x@: ^y@:")
+        self.check_parse("x ^y", "x@: ^y@:")
 
     def test_parse_errors(self):
         errors = ['x@@1.2', 'x ^y@@1.2', 'x@1.2::', 'x::']