Repository: conda/conda-build

Issue:
conda-build should check if runtime dependencies are valid matchspecs
### What happened?

It doesn't seem like `conda-build` ensures that `run` and `run_constrained` dependencies are valid `MatchSpec` objects. Otherwise, I don't know how to explain [this issue](https://github.com/conda-forge/fractal-server-feedstock/issues/9), where a package had wrongly specified the following `run_constrained` item:

```yaml
 - cloudpickle >=2.2.1<2.3.0 
```

Which results in the following error when conda classic tries to install it:

```
InvalidVersionSpec: Invalid version '2.2.1<2.3.0': invalid character(s)
```

I'd say `conda-build` should try to convert all those items to a `MatchSpec` before reporting the package as successfully built.

### Conda Details

<details>
<summary><code>conda info</code></summary>

```shell
active environment : conda-dev
    active env location : /Users/jrodriguez/.local/anaconda/envs/conda-dev
            shell level : 2
       user config file : /Users/jrodriguez/.condarc
 populated config files : /Users/jrodriguez/.local/anaconda/.condarc
                          /Users/jrodriguez/.condarc
          conda version : 23.9.0
    conda-build version : 3.26.1
         python version : 3.9.9.final.0
       virtual packages : __archspec=1=m1
                          __osx=13.5.2=0
                          __unix=0=0
       base environment : /Users/jrodriguez/.local/anaconda  (writable)
      conda av data dir : /Users/jrodriguez/.local/anaconda/etc/conda
  conda av metadata url : None
           channel URLs : https://conda.anaconda.org/conda-forge/osx-arm64
                          https://conda.anaconda.org/conda-forge/noarch
          package cache : /Users/jrodriguez/.local/anaconda/pkgs
                          /Users/jrodriguez/.conda/pkgs
       envs directories : /Users/jrodriguez/.local/anaconda/envs
                          /Users/jrodriguez/.conda/envs
               platform : osx-arm64
             user-agent : conda/23.9.0 requests/2.31.0 CPython/3.9.9 Darwin/22.6.0 OSX/13.5.2 solver/libmamba conda-libmamba-solver/23.9.1 libmambapy/1.5.1
                UID:GID : 501:20
             netrc file : None
           offline mode : False
```

</details>

<details>
<summary><code>conda config</code></summary>

```shell
==> /Users/jrodriguez/.local/anaconda/.condarc <==
channels:
  - conda-forge

==> /Users/jrodriguez/.condarc <==
add_pip_as_python_dependency: False
auto_update_conda: False
local_repodata_ttl: 0
channel_priority: strict
channels:
  - conda-forge
show_channel_urls: True
report_errors: False
experimental:
  - jlap
solver: libmamba
```

</details>


Discussion:

Resolving patch:
diff --git a/conda_build/metadata.py b/conda_build/metadata.py
--- a/conda_build/metadata.py
+++ b/conda_build/metadata.py
@@ -480,6 +480,24 @@ def _check_circular_dependencies(
         raise exceptions.RecipeError(error)
 
 
+def _check_run_constrained(metadata_tuples):
+    errors = []
+    for _, metadata in metadata_tuples:
+        for dep in _get_all_dependencies(metadata, envs=("run_constrained",)):
+            if "{{" in dep:
+                # skip Jinja content; it might have not been rendered yet; we'll get it next call
+                continue
+            try:
+                MatchSpec(dep)
+            except ValueError as exc:
+                errors.append(
+                    f"- Output '{metadata.name()}' has invalid run_constrained item: {dep}. "
+                    f"Reason: {exc}"
+                )
+    if errors:
+        raise exceptions.RecipeError("\n".join(["", *errors]))
+
+
 def _variants_equal(metadata, output_metadata):
     match = True
     for key, val in metadata.config.variant.items():
@@ -2755,6 +2773,7 @@ def get_output_metadata_set(
                 m.final = True
                 final_conda_packages.append((out_d, m))
             output_tuples = final_conda_packages + non_conda_packages
+        _check_run_constrained(output_tuples)
         return output_tuples
 
     def get_loop_vars(self):


Test patch:
diff --git a/tests/test-recipes/metadata/_run_constrained_error/meta.yaml b/tests/test-recipes/metadata/_run_constrained_error/meta.yaml
new file mode 100644
--- /dev/null
+++ b/tests/test-recipes/metadata/_run_constrained_error/meta.yaml
@@ -0,0 +1,10 @@
+package:
+  name: test_run_constrained_error
+  version: 1.0
+
+requirements:
+  run_constrained:
+    # obtained from https://github.com/conda-forge/willow-feedstock/blob/67d9ac1c5232295ccaac41b131e3982a335b365b/recipe/meta.yaml#L29
+    - pillow-heif  >=0.10.0,<1.0.0<py312|>=0.13.0,<1.0.0>=py312
+    - {{ 'another-package' }} {{ '>=0.20.0,<2.0.0<py310|>=0.23.0,<2.0.0>=py310' }}
+  
diff --git a/tests/test_api_build.py b/tests/test_api_build.py
--- a/tests/test_api_build.py
+++ b/tests/test_api_build.py
@@ -41,6 +41,7 @@
     DependencyNeedsBuildingError,
     OverDependingError,
     OverLinkingError,
+    RecipeError,
 )
 from conda_build.os_utils.external import find_executable
 from conda_build.render import finalize_metadata
@@ -1464,6 +1465,12 @@ def test_run_constrained_stores_constrains_info(testing_config):
     assert info_contents["constrains"][0] == "bzip2  1.*"
 
 
+def test_run_constrained_is_validated(testing_config: Config):
+    recipe = os.path.join(metadata_dir, "_run_constrained_error")
+    with pytest.raises(RecipeError):
+        api.build(recipe, config=testing_config)
+
+
 @pytest.mark.sanity
 def test_no_locking(testing_config):
     recipe = os.path.join(metadata_dir, "source_git_jinja2")