Repository: spack/spack
Pull request #49385: Error when an anonymous spec is required for a virtual package
Author: login: alalazo
Labels: name: tests
description: General test capability(ies)
name: gitlab
description: Issues related to gitlab integration
name: core
description: PR affects Spack core functionality
Created: 2025-03-09T11:28:14Z | Merged: 2025-03-12T07:33:42Z

When requiring a constraint on a virtual package, it makes little sense to use anonymous specs, and our documentation shows no example of requirements on virtual packages starting with `^`.

Right now, due to how `^` is implemented in the solver, writing:
```yaml
mpi:
  require: "^openmpi"
```
is absolutely equivalent to the more correct form:
```yaml
mpi:
  require: "openmpi"
```
but the situation will change when `%` will shift its meaning to be a direct dependency.

To avoid, later, errors that are both unclear, and quite slow to get to the user, this commit makes anonymous specs under virtual requirements an error, and shows a clear error message pointing to the file and line where the spec needs to be changed.

**Example**

Using this `spack.yaml`:
```yaml
# This is a Spack Environment file.
#
# It describes a set of packages to be installed, along with
# configuration settings.
spack:
  # add package specs to the `specs` list
  specs:
  - hdf5+mpi
  packages:
    mpi:
      require: "^openmpi"
  view: true
  concretizer:
    unify: true
```
users will now see this message:
```console
$ spack concretize
==> Error: /tmp/spack-mje23iey/spack.yaml:11: expected a named spec, but got '^openmpi' instead. Maybe you meant 'openmpi'?
```
Removing the `^` in the requirement fixes concretization.
<!--  
Remember that `spackbot` can help with your PR in multiple ways:
- `@spackbot help` shows all the commands that are currently available
- `@spackbot fix style` tries to push a commit to fix style issues in this PR
- `@spackbot re-run pipeline` runs the pipelines again, if you have write access to the repository 
-->



Diff:
diff --git a/lib/spack/spack/solver/requirements.py b/lib/spack/spack/solver/requirements.py
index 0fd67a7250accf..6fa107e9fd0aad 100644
--- a/lib/spack/spack/solver/requirements.py
+++ b/lib/spack/spack/solver/requirements.py
@@ -165,7 +165,8 @@ def _rules_from_requirements(
 
                 # validate specs from YAML first, and fail with line numbers if parsing fails.
                 constraints = [
-                    parse_spec_from_yaml_string(constraint) for constraint in constraints
+                    parse_spec_from_yaml_string(constraint, named=kind == RequirementKind.VIRTUAL)
+                    for constraint in constraints
                 ]
                 when_str = requirement.get("when")
                 when = parse_spec_from_yaml_string(when_str) if when_str else spack.spec.Spec()
@@ -203,7 +204,7 @@ def reject_requirement_constraint(
                 s.validate_or_raise()
             except spack.error.SpackError as e:
                 tty.debug(
-                    f"[SETUP] Rejecting the default '{constraint}' requirement "
+                    f"[{__name__}] Rejecting the default '{constraint}' requirement "
                     f"on '{pkg_name}': {str(e)}",
                     level=2,
                 )
@@ -211,21 +212,37 @@ def reject_requirement_constraint(
         return False
 
 
-def parse_spec_from_yaml_string(string: str) -> spack.spec.Spec:
+def parse_spec_from_yaml_string(string: str, *, named: bool = False) -> spack.spec.Spec:
     """Parse a spec from YAML and add file/line info to errors, if it's available.
 
     Parse a ``Spec`` from the supplied string, but also intercept any syntax errors and
     add file/line information for debugging using file/line annotations from the string.
 
-    Arguments:
+    Args:
         string: a string representing a ``Spec`` from config YAML.
-
+        named: if True, the spec must have a name
     """
     try:
-        return spack.spec.Spec(string)
+        result = spack.spec.Spec(string)
     except spack.error.SpecSyntaxError as e:
         mark = get_mark_from_yaml_data(string)
         if mark:
             msg = f"{mark.name}:{mark.line + 1}: {str(e)}"
             raise spack.error.SpecSyntaxError(msg) from e
         raise e
+
+    if named is True and not result.name:
+        msg = f"expected a named spec, but got '{string}' instead"
+        mark = get_mark_from_yaml_data(string)
+
+        # Add a hint in case it's dependencies
+        deps = result.dependencies()
+        if len(deps) == 1:
+            msg = f"{msg}. Did you mean '{deps[0]}'?"
+
+        if mark:
+            msg = f"{mark.name}:{mark.line + 1}: {msg}"
+
+        raise spack.error.SpackError(msg)
+
+    return result
diff --git a/lib/spack/spack/test/concretization/requirements.py b/lib/spack/spack/test/concretization/requirements.py
index c2e5d4cadaf9d8..54bc1e152556bc 100644
--- a/lib/spack/spack/test/concretization/requirements.py
+++ b/lib/spack/spack/test/concretization/requirements.py
@@ -1125,3 +1125,46 @@ def test_strong_preferences_higher_priority_than_reuse(concretize_scope, mock_pa
         )
         ascent = result.specs[0]
     assert ascent["adios2"].dag_hash() == reused_spec.dag_hash(), ascent
+
+
+@pytest.mark.parametrize(
+    "packages_yaml,err_match",
+    [
+        (
+            """
+packages:
+  mpi:
+    require:
+    - "+bzip2"
+""",
+            "expected a named spec",
+        ),
+        (
+            """
+packages:
+  mpi:
+    require:
+    - one_of: ["+bzip2", openmpi]
+""",
+            "expected a named spec",
+        ),
+        (
+            """
+packages:
+  mpi:
+    require:
+    - "^mpich"
+""",
+            "Did you mean",
+        ),
+    ],
+)
+def test_anonymous_spec_cannot_be_used_in_virtual_requirements(
+    packages_yaml, err_match, concretize_scope, mock_packages
+):
+    """Tests that using anonymous specs in requirements for virtual packages raises an
+    appropriate error message.
+    """
+    update_packages_config(packages_yaml)
+    with pytest.raises(spack.error.SpackError, match=err_match):
+        spack.concretize.concretize_one("mpileaks")
diff --git a/share/spack/gitlab/cloud_pipelines/stacks/data-vis-sdk/spack.yaml b/share/spack/gitlab/cloud_pipelines/stacks/data-vis-sdk/spack.yaml
index 02594f3b611e34..a85e004104edc6 100644
--- a/share/spack/gitlab/cloud_pipelines/stacks/data-vis-sdk/spack.yaml
+++ b/share/spack/gitlab/cloud_pipelines/stacks/data-vis-sdk/spack.yaml
@@ -25,7 +25,7 @@ spack:
       # Minimize LLVM
       variants: ~lldb~lld~libomptarget~polly~gold libunwind=none compiler-rt=none
     libllvm:
-      require: ["^llvm"]
+      require: ["llvm"]
     visit:
       require: ["@3.4.1"]
 


Reviews:
user: login: psakievich
body: LGTM
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-03-10T16:46:12Z
user: login: haampie
state: DISMISSED
author_association: MEMBER
submitted_at: 2025-03-11T08:57:27Z
user: login: haampie
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-11T08:58:22Z
user: login: haampie
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-11T09:00:01Z
user: login: alalazo
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-11T16:28:24Z
user: login: psakievich
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-12T04:14:22Z

Review comments:
diff_hunk: @@ -203,29 +204,45 @@ def reject_requirement_constraint(
                 s.validate_or_raise()
             except spack.error.SpackError as e:
                 tty.debug(
-                    f"[SETUP] Rejecting the default '{constraint}' requirement "
+                    f"[{__name__}] Rejecting the default '{constraint}' requirement "
                     f"on '{pkg_name}': {str(e)}",
                     level=2,
                 )
                 return True
         return False
 
 
-def parse_spec_from_yaml_string(string: str) -> spack.spec.Spec:
+def parse_spec_from_yaml_string(string: str, *, named: bool = False) -> spack.spec.Spec:
     """Parse a spec from YAML and add file/line info to errors, if it's available.
 
     Parse a ``Spec`` from the supplied string, but also intercept any syntax errors and
     add file/line information for debugging using file/line annotations from the string.
 
-    Arguments:
+    Args:
         string: a string representing a ``Spec`` from config YAML.
-
+        named: if True, the spec must have a name
     """
     try:
-        return spack.spec.Spec(string)
+        result = spack.spec.Spec(string)
     except spack.error.SpecSyntaxError as e:
         mark = get_mark_from_yaml_data(string)
         if mark:
             msg = f"{mark.name}:{mark.line + 1}: {str(e)}"
             raise spack.error.SpecSyntaxError(msg) from e
         raise e
+
+    if named is True and not result.name:
+        msg = f"expected a named spec, but got '{string}' instead"
+        mark = get_mark_from_yaml_data(string)
+
+        # Add a hint in case it's dependencies
+        deps = result.dependencies()
+        if len(deps) == 1:
+            msg = f"{msg}. Maybe you meant just '{deps[0]}'?"
path: lib/spack/spack/solver/requirements.py
user: login: haampie
body: ```suggestion
            msg = f"{msg}. Did you mean '{deps[0]}'?"
```
created_at: 2025-03-11T08:58:22Z
author_association: MEMBER
diff_hunk: @@ -203,29 +204,45 @@ def reject_requirement_constraint(
                 s.validate_or_raise()
             except spack.error.SpackError as e:
                 tty.debug(
-                    f"[SETUP] Rejecting the default '{constraint}' requirement "
+                    f"[{__name__}] Rejecting the default '{constraint}' requirement "
                     f"on '{pkg_name}': {str(e)}",
                     level=2,
                 )
                 return True
         return False
 
 
-def parse_spec_from_yaml_string(string: str) -> spack.spec.Spec:
+def parse_spec_from_yaml_string(string: str, *, named: bool = False) -> spack.spec.Spec:
     """Parse a spec from YAML and add file/line info to errors, if it's available.
 
     Parse a ``Spec`` from the supplied string, but also intercept any syntax errors and
     add file/line information for debugging using file/line annotations from the string.
 
-    Arguments:
+    Args:
         string: a string representing a ``Spec`` from config YAML.
-
+        named: if True, the spec must have a name
     """
     try:
-        return spack.spec.Spec(string)
+        result = spack.spec.Spec(string)
     except spack.error.SpecSyntaxError as e:
         mark = get_mark_from_yaml_data(string)
         if mark:
             msg = f"{mark.name}:{mark.line + 1}: {str(e)}"
             raise spack.error.SpecSyntaxError(msg) from e
         raise e
+
+    if named is True and not result.name:
+        msg = f"expected a named spec, but got '{string}' instead"
+        mark = get_mark_from_yaml_data(string)
+
+        # Add a hint in case it's dependencies
+        deps = result.dependencies()
+        if len(deps) == 1:
+            msg = f"{msg}. Maybe you meant just '{deps[0]}'?"
path: lib/spack/spack/solver/requirements.py
user: login: haampie
body: this branch isn't tested, sounds like something that could bit rot.
created_at: 2025-03-11T09:00:01Z
author_association: MEMBER
diff_hunk: @@ -203,29 +204,45 @@ def reject_requirement_constraint(
                 s.validate_or_raise()
             except spack.error.SpackError as e:
                 tty.debug(
-                    f"[SETUP] Rejecting the default '{constraint}' requirement "
+                    f"[{__name__}] Rejecting the default '{constraint}' requirement "
                     f"on '{pkg_name}': {str(e)}",
                     level=2,
                 )
                 return True
         return False
 
 
-def parse_spec_from_yaml_string(string: str) -> spack.spec.Spec:
+def parse_spec_from_yaml_string(string: str, *, named: bool = False) -> spack.spec.Spec:
     """Parse a spec from YAML and add file/line info to errors, if it's available.
 
     Parse a ``Spec`` from the supplied string, but also intercept any syntax errors and
     add file/line information for debugging using file/line annotations from the string.
 
-    Arguments:
+    Args:
         string: a string representing a ``Spec`` from config YAML.
-
+        named: if True, the spec must have a name
     """
     try:
-        return spack.spec.Spec(string)
+        result = spack.spec.Spec(string)
     except spack.error.SpecSyntaxError as e:
         mark = get_mark_from_yaml_data(string)
         if mark:
             msg = f"{mark.name}:{mark.line + 1}: {str(e)}"
             raise spack.error.SpecSyntaxError(msg) from e
         raise e
+
+    if named is True and not result.name:
+        msg = f"expected a named spec, but got '{string}' instead"
+        mark = get_mark_from_yaml_data(string)
+
+        # Add a hint in case it's dependencies
+        deps = result.dependencies()
+        if len(deps) == 1:
+            msg = f"{msg}. Maybe you meant just '{deps[0]}'?"
path: lib/spack/spack/solver/requirements.py
user: login: alalazo
body: This is done in https://github.com/spack/spack/pull/49385/commits/72294e76c447245998b336cc530ad84b6b949937
created_at: 2025-03-11T16:28:24Z
author_association: MEMBER

PR comments:
user: login: psakievich
created_at: 2025-03-09T13:13:31Z
body: The error message seems a little opaque for interpretation from a casual user IMO.  Is using `^` or `%` the only thing that will currently trigger this?  Could we add a hint on how to fix it as part of the message?  Like "Try removin ^/% sigils from your spec" or "Spec delimiting sigils (^/%) are not allowed on virtual spec requirements."

If the error could be more broadly applied then that makes sense to keep it general. The use case here seems pretty targeted to me though.
author_association: CONTRIBUTOR
user: login: alalazo
created_at: 2025-03-09T15:09:15Z
body: > Is using ^ or % the only thing that will currently trigger this?

No, any anonymous spec will. We can add a "maybe you meant xxx" in case we see dependencies, but other than that it's difficult to know what would be appropriate if the spec was `+foo`.
author_association: MEMBER
user: login: alalazo
created_at: 2025-03-09T15:18:22Z
body: The error message has been improved with a hint, in case it's a single dependency.
author_association: MEMBER