Repository: spack/spack

Issue:
Testing Integrity of Spack repo
@adamjstewart to improve our CI infrastructure further...  Test that the target of each depends_on() declaration can be found in the repo; and that all variants in the depends_on() declarations actually exist.



Discussion:
Yeah, I was going to add package-specific testing that would catch stuff like that. @tgamblin Are you opposed to package-specific testing or just the installation part?

@adamjstewart: Nope.  There is already a `package_sanity` test that you could add this to.  It tests all the builtin packages to make sure they can be imported.  This constructs `Specs` for arguments to `depends_on` but does not check that packages exist (you can make a spec for a nonexistent package, but you can't concretize it).  Look at the `validate_names` method on `Spec` in `spec.py`.  I think you could base your check on that.  The difference would be that your test would check static information (defined in package class metadata) whereas that checks dynamic information (i.e. dependencies that are actually on the Spec).

This won't run under CI, but relates to #5982 

Resolving patch:
diff --git a/lib/spack/spack/package.py b/lib/spack/spack/package.py
--- a/lib/spack/spack/package.py
+++ b/lib/spack/spack/package.py
@@ -556,16 +556,19 @@ def installed_upstream(self):
     @classmethod
     def possible_dependencies(
             cls, transitive=True, expand_virtuals=True, deptype='all',
-            visited=None):
+            visited=None, missing=None):
         """Return dict of possible dependencies of this package.
 
         Args:
-            transitive (bool): return all transitive dependencies if True,
-                only direct dependencies if False.
-            expand_virtuals (bool): expand virtual dependencies into all
-                possible implementations.
-            deptype (str or tuple): dependency types to consider
-            visited (set): set of names of dependencies visited so far.
+            transitive (bool, optional): return all transitive dependencies if
+                True, only direct dependencies if False (default True)..
+            expand_virtuals (bool, optional): expand virtual dependencies into
+                all possible implementations (default True)
+            deptype (str or tuple, optional): dependency types to consider
+            visited (dicct, optional): dict of names of dependencies visited so
+                far, mapped to their immediate dependencies' names.
+            missing (dict, optional): dict to populate with packages and their
+                *missing* dependencies.
 
         Returns:
             (dict): dictionary mapping dependency names to *their*
@@ -576,7 +579,12 @@ def possible_dependencies(
         *immediate* dependencies. If ``expand_virtuals`` is ``False``,
         virtual package names wil be inserted as keys mapped to empty
         sets of dependencies.  Virtuals, if not expanded, are treated as
-        though they have no immediate dependencies
+        though they have no immediate dependencies.
+
+        Missing dependencies by default are ignored, but if a
+        missing dict is provided, it will be populated with package names
+        mapped to any dependencies they have that are in no
+        repositories. This is only populated if transitive is True.
 
         Note: the returned dict *includes* the package itself.
 
@@ -586,6 +594,9 @@ def possible_dependencies(
         if visited is None:
             visited = {cls.name: set()}
 
+        if missing is None:
+            missing = {cls.name: set()}
+
         for name, conditions in cls.dependencies.items():
             # check whether this dependency could be of the type asked for
             types = [dep.type for cond, dep in conditions.items()]
@@ -609,12 +620,24 @@ def possible_dependencies(
 
             # recursively traverse dependencies
             for dep_name in dep_names:
-                if dep_name not in visited:
-                    visited.setdefault(dep_name, set())
-                    if transitive:
-                        dep_cls = spack.repo.path.get_pkg_class(dep_name)
-                        dep_cls.possible_dependencies(
-                            transitive, expand_virtuals, deptype, visited)
+                if dep_name in visited:
+                    continue
+
+                visited.setdefault(dep_name, set())
+
+                # skip the rest if not transitive
+                if not transitive:
+                    continue
+
+                try:
+                    dep_cls = spack.repo.path.get_pkg_class(dep_name)
+                except spack.repo.UnknownPackageError:
+                    # log unknown packages
+                    missing.setdefault(cls.name, set()).add(dep_name)
+                    continue
+
+                dep_cls.possible_dependencies(
+                    transitive, expand_virtuals, deptype, visited, missing)
 
         return visited
 
@@ -2663,6 +2686,35 @@ def dump_packages(spec, path):
             spack.repo.path.dump_provenance(node, dest_pkg_dir)
 
 
+def possible_dependencies(*pkg_or_spec, **kwargs):
+    """Get the possible dependencies of a number of packages.
+
+    See ``PackageBase.possible_dependencies`` for details.
+    """
+    transitive = kwargs.get('transitive', True)
+    expand_virtuals = kwargs.get('expand_virtuals', True)
+    deptype = kwargs.get('deptype', 'all')
+    missing = kwargs.get('missing')
+
+    packages = []
+    for pos in pkg_or_spec:
+        if isinstance(pos, PackageMeta):
+            pkg = pos
+        elif isinstance(pos, spack.spec.Spec):
+            pkg = pos.package
+        else:
+            pkg = spack.spec.Spec(pos).package
+
+        packages.append(pkg)
+
+    visited = {}
+    for pkg in packages:
+        pkg.possible_dependencies(
+            transitive, expand_virtuals, deptype, visited, missing)
+
+    return visited
+
+
 def print_pkg(message):
     """Outputs a message with a package icon."""
     from llnl.util.tty.color import cwrite
diff --git a/var/spack/repos/builtin.mock/packages/missing-dependency/package.py b/var/spack/repos/builtin.mock/packages/missing-dependency/package.py
new file mode 100644
--- /dev/null
+++ b/var/spack/repos/builtin.mock/packages/missing-dependency/package.py
@@ -0,0 +1,21 @@
+# Copyright 2013-2019 Lawrence Livermore National Security, LLC and other
+# Spack Project Developers. See the top-level COPYRIGHT file for details.
+#
+# SPDX-License-Identifier: (Apache-2.0 OR MIT)
+
+from spack import *
+
+
+class MissingDependency(Package):
+    """Package with a dependency that does not exist."""
+
+    homepage = "http://www.example.com"
+    url      = "http://www.example.com/missing-dependency-1.0.tar.gz"
+
+    version('1.0', '0123456789abcdef0123456789abcdef')
+
+    # intentionally missing to test possible_dependencies()
+    depends_on("this-is-a-missing-dependency")
+
+    # this one is a "real" mock dependency
+    depends_on("a")


Test patch:
diff --git a/lib/spack/spack/test/package_class.py b/lib/spack/spack/test/package_class.py
--- a/lib/spack/spack/test/package_class.py
+++ b/lib/spack/spack/test/package_class.py
@@ -10,14 +10,14 @@
 static DSL metadata for packages.
 """
 
+import pytest
 import spack.repo
 
 
-def test_possible_dependencies(mock_packages):
-    mpileaks = spack.repo.get('mpileaks')
+@pytest.fixture
+def mpileaks_possible_deps(mock_packages):
     mpi_names = [spec.name for spec in spack.repo.path.providers_for('mpi')]
-
-    assert mpileaks.possible_dependencies(expand_virtuals=True) == {
+    possible = {
         'callpath': set(['dyninst'] + mpi_names),
         'dyninst': set(['libdwarf', 'libelf']),
         'fake': set(),
@@ -29,6 +29,13 @@ def test_possible_dependencies(mock_packages):
         'multi-provider-mpi': set(),
         'zmpi': set(['fake']),
     }
+    return possible
+
+
+def test_possible_dependencies(mock_packages, mpileaks_possible_deps):
+    mpileaks = spack.repo.get('mpileaks')
+    assert (mpileaks.possible_dependencies(expand_virtuals=True) ==
+            mpileaks_possible_deps)
 
     assert mpileaks.possible_dependencies(expand_virtuals=False) == {
         'callpath': set(['dyninst']),
@@ -40,6 +47,15 @@ def test_possible_dependencies(mock_packages):
     }
 
 
+def test_possible_dependencies_missing(mock_packages):
+    md = spack.repo.get("missing-dependency")
+    missing = {}
+    md.possible_dependencies(transitive=True, missing=missing)
+    assert missing["missing-dependency"] == set([
+        "this-is-a-missing-dependency"
+    ])
+
+
 def test_possible_dependencies_with_deptypes(mock_packages):
     dtbuild1 = spack.repo.get('dtbuild1')
 
@@ -59,3 +75,17 @@ def test_possible_dependencies_with_deptypes(mock_packages):
         'dtbuild1': set(['dtlink2']),
         'dtlink2': set(),
     }
+
+
+def test_possible_dependencies_with_multiple_classes(
+        mock_packages, mpileaks_possible_deps):
+    pkgs = ['dt-diamond', 'mpileaks']
+    expected = mpileaks_possible_deps.copy()
+    expected.update({
+        'dt-diamond': set(['dt-diamond-left', 'dt-diamond-right']),
+        'dt-diamond-left': set(['dt-diamond-bottom']),
+        'dt-diamond-right': set(['dt-diamond-bottom']),
+        'dt-diamond-bottom': set(),
+    })
+
+    assert spack.package.possible_dependencies(*pkgs) == expected
diff --git a/lib/spack/spack/test/package_sanity.py b/lib/spack/spack/test/package_sanity.py
--- a/lib/spack/spack/test/package_sanity.py
+++ b/lib/spack/spack/test/package_sanity.py
@@ -10,6 +10,7 @@
 import pytest
 
 import spack.fetch_strategy
+import spack.package
 import spack.paths
 import spack.repo
 import spack.util.executable as executable
@@ -187,3 +188,18 @@ def test_prs_update_old_api():
     assert not failing, msg.format(
         len(failing), ','.join(failing)
     )
+
+
+def test_all_dependencies_exist():
+    """Make sure no packages have nonexisting dependencies."""
+    missing = {}
+    pkgs = [pkg for pkg in spack.repo.path.all_package_names()]
+    spack.package.possible_dependencies(
+        *pkgs, transitive=True, missing=missing)
+
+    lines = [
+        "%s: [%s]" % (name, ", ".join(deps)) for name, deps in missing.items()
+    ]
+    assert not missing, "These packages have missing dependencies:\n" + (
+        "\n".join(lines)
+    )