From 1d152a0740b7b96aa30e8250c1766a94ea0d029a Mon Sep 17 00:00:00 2001
From: Gregory Becker <becker33@llnl.gov>
Date: Wed, 18 Dec 2019 11:27:32 -0800
Subject: [PATCH] WIP infrastructure for Spack test command to test existing
 installations

---
 lib/spack/spack/build_environment.py          |  58 +++--
 lib/spack/spack/cmd/test.py                   | 234 ++++++------------
 lib/spack/spack/cmd/unit_test.py              | 105 ++++++++
 lib/spack/spack/package.py                    |  22 ++
 lib/spack/spack/test/cmd/test.py              |   2 +-
 lib/spack/spack/util/executable.py            |  17 +-
 .../repos/builtin/packages/python/package.py  |  15 ++
 7 files changed, 273 insertions(+), 180 deletions(-)
 create mode 100644 lib/spack/spack/cmd/unit_test.py

diff --git a/lib/spack/spack/build_environment.py b/lib/spack/spack/build_environment.py
index 4a57dde77b..72ff65f587 100644
--- a/lib/spack/spack/build_environment.py
+++ b/lib/spack/spack/build_environment.py
@@ -59,7 +59,7 @@
 from spack.util.environment import (
     env_flag, filter_system_paths, get_path, is_system_path,
     EnvironmentModifications, validate, preserve_environment)
-from spack.util.environment import system_dirs
+from spack.util.environment import system_dirs, inspect_path
 from spack.error import NoLibrariesError, NoHeadersError
 from spack.util.executable import Executable
 from spack.util.module_cmd import load_module, path_from_modules, module
@@ -711,28 +711,40 @@ def load_external_modules(pkg):
             load_module(external_module)
 
 
-def setup_package(pkg, dirty):
+def setup_package(pkg, dirty, context='build'):
     """Execute all environment setup routines."""
-    build_env = EnvironmentModifications()
+    env = EnvironmentModifications()
 
+    # clean environment
     if not dirty:
         clean_environment()
 
-    set_compiler_environment_variables(pkg, build_env)
-    set_build_environment_variables(pkg, build_env, dirty)
-    pkg.architecture.platform.setup_platform_environment(pkg, build_env)
+    # setup compilers and build tools for build contexts
+    if context == 'build':
+        set_compiler_environment_variables(pkg, env)
+        set_build_environment_variables(pkg, env, dirty)
+
+    # architecture specific setup
+    pkg.architecture.platform.setup_platform_environment(pkg, env)
 
-    build_env.extend(
-        modifications_from_dependencies(pkg.spec, context='build')
+    # recursive post-order dependency information
+    env.extend(
+        modifications_from_dependencies(pkg.spec, context=context)
     )
 
-    if (not dirty) and (not build_env.is_unset('CPATH')):
+    if context == 'build' and (not dirty) and (not env.is_unset('CPATH')):
         tty.debug("A dependency has updated CPATH, this may lead pkg-config"
                   " to assume that the package is part of the system"
                   " includes and omit it when invoked with '--cflags'.")
 
+    # setup package itself
     set_module_variables_for_package(pkg)
-    pkg.setup_build_environment(build_env)
+    if context == 'build':
+        pkg.setup_build_environment(env)
+    elif context == 'test':
+        import spack.user_environment as uenv  # avoid circular import
+        env.extend(inspect_path(pkg.spec.prefix,
+                                uenv.prefix_inspections(pkg.spec.platform)))
 
     # Loading modules, in particular if they are meant to be used outside
     # of Spack, can change environment variables that are relevant to the
@@ -742,15 +754,16 @@ def setup_package(pkg, dirty):
     # unnecessary. Modules affecting these variables will be overwritten anyway
     with preserve_environment('CC', 'CXX', 'FC', 'F77'):
         # All module loads that otherwise would belong in previous
-        # functions have to occur after the build_env object has its
+        # functions have to occur after the env object has its
         # modifications applied. Otherwise the environment modifications
         # could undo module changes, such as unsetting LD_LIBRARY_PATH
         # after a module changes it.
-        for mod in pkg.compiler.modules:
-            # Fixes issue https://github.com/spack/spack/issues/3153
-            if os.environ.get("CRAY_CPU_TARGET") == "mic-knl":
-                load_module("cce")
-            load_module(mod)
+        if context == 'build':
+            for mod in pkg.compiler.modules:
+                # Fixes issue https://github.com/spack/spack/issues/3153
+                if os.environ.get("CRAY_CPU_TARGET") == "mic-knl":
+                    load_module("cce")
+                load_module(mod)
 
         # kludge to handle cray libsci being automatically loaded by PrgEnv
         # modules on cray platform. Module unload does no damage when
@@ -768,8 +781,8 @@ def setup_package(pkg, dirty):
                       ':'.join(implicit_rpaths))
 
     # Make sure nothing's strange about the Spack environment.
-    validate(build_env, tty.warn)
-    build_env.apply_modifications()
+    validate(env, tty.warn)
+    env.apply_modifications()
 
 
 def modifications_from_dependencies(spec, context):
@@ -789,7 +802,8 @@ def modifications_from_dependencies(spec, context):
     deptype_and_method = {
         'build': (('build', 'link', 'test'),
                   'setup_dependent_build_environment'),
-        'run': (('link', 'run'), 'setup_dependent_run_environment')
+        'run': (('link', 'run'), 'setup_dependent_run_environment'),
+        'test': (('link', 'run', 'test'), 'setup_dependent_run_environment')
     }
     deptype, method = deptype_and_method[context]
 
@@ -803,7 +817,7 @@ def modifications_from_dependencies(spec, context):
     return env
 
 
-def fork(pkg, function, dirty, fake):
+def fork(pkg, function, dirty, fake, context='build'):
     """Fork a child process to do part of a spack build.
 
     Args:
@@ -815,6 +829,8 @@ def fork(pkg, function, dirty, fake):
         dirty (bool): If True, do NOT clean the environment before
             building.
         fake (bool): If True, skip package setup b/c it's not a real build
+        context (string): If 'build', setup build environment. If 'test', setup
+            test environment.
 
     Usage::
 
@@ -843,7 +859,7 @@ def child_process(child_pipe, input_stream):
 
         try:
             if not fake:
-                setup_package(pkg, dirty=dirty)
+                setup_package(pkg, dirty=dirty, context=context)
             return_value = function()
             child_pipe.send(return_value)
 
diff --git a/lib/spack/spack/cmd/test.py b/lib/spack/spack/cmd/test.py
index 8cbc0fbccf..5dddd8c676 100644
--- a/lib/spack/spack/cmd/test.py
+++ b/lib/spack/spack/cmd/test.py
@@ -4,166 +4,94 @@
 # SPDX-License-Identifier: (Apache-2.0 OR MIT)
 
 from __future__ import print_function
-from __future__ import division
-
-import collections
-import sys
-import re
+import os
 import argparse
-import pytest
-from six import StringIO
 
-import llnl.util.tty.color as color
-from llnl.util.filesystem import working_dir
-from llnl.util.tty.colify import colify
+import llnl.util.tty as tty
 
-import spack.paths
+import spack.environment as ev
+import spack.cmd
 
-description = "run spack's unit tests (wrapper around pytest)"
-section = "developer"
+description = "run spack's tests for an install"
+section = "administrator"
 level = "long"
 
 
 def setup_parser(subparser):
-    subparser.add_argument(
-        '-H', '--pytest-help', action='store_true', default=False,
-        help="show full pytest help, with advanced options")
+#     subparser.add_argument(
+#         '--log-format',
+#         default=None,
+#         choices=spack.report.valid_formats,
+#         help="format to be used for log files"
+#     )
+#     subparser.add_argument(
+#         '--output-file',
+#         default=None,
+#         help="filename for the log file. if not passed a default will be used"
+#     )
+#     subparser.add_argument(
+#         '--cdash-upload-url',
+#         default=None,
+#         help="CDash URL where reports will be uploaded"
+#     )
+#     subparser.add_argument(
+#         '--cdash-build',
+#         default=None,
+#         help="""The name of the build that will be reported to CDash.
+# Defaults to spec of the package to install."""
+#     )
+#     subparser.add_argument(
+#         '--cdash-site',
+#         default=None,
+#         help="""The site name that will be reported to CDash.
+# Defaults to current system hostname."""
+#     )
+#     cdash_subgroup = subparser.add_mutually_exclusive_group()
+#     cdash_subgroup.add_argument(
+#         '--cdash-track',
+#         default='Experimental',
+#         help="""Results will be reported to this group on CDash.
+# Defaults to Experimental."""
+#     )
+#     cdash_subgroup.add_argument(
+#         '--cdash-buildstamp',
+#         default=None,
+#         help="""Instead of letting the CDash reporter prepare the
+# buildstamp which, when combined with build name, site and project,
+# uniquely identifies the build, provide this argument to identify
+# the build yourself.  Format: %%Y%%m%%d-%%H%%M-[cdash-track]"""
+#     )
+#     arguments.add_common_arguments(subparser, ['yes_to_all'])
+    length_group = subparser.add_mutually_exclusive_group()
+    length_group.add_argument(
+        '--smoke', action='store_true', dest='smoke_test', default=True,
+        help='run smoke tests (default)')
+    length_group.add_argument(
+        '--capability', action='store_false', dest='smoke_test', default=True,
+        help='run full capability tests using pavilion')
 
-    # extra spack arguments to list tests
-    list_group = subparser.add_argument_group("listing tests")
-    list_mutex = list_group.add_mutually_exclusive_group()
-    list_mutex.add_argument(
-        '-l', '--list', action='store_const', default=None,
-        dest='list', const='list', help="list test filenames")
-    list_mutex.add_argument(
-        '-L', '--list-long', action='store_const', default=None,
-        dest='list', const='long', help="list all test functions")
-    list_mutex.add_argument(
-        '-N', '--list-names', action='store_const', default=None,
-        dest='list', const='names', help="list full names of all tests")
-
-    # use tests for extension
     subparser.add_argument(
-        '--extension', default=None,
-        help="run test for a given spack extension")
-
-    # spell out some common pytest arguments, so they'll show up in help
-    pytest_group = subparser.add_argument_group(
-        "common pytest arguments (spack test --pytest-help for more details)")
-    pytest_group.add_argument(
-        "-s", action='append_const', dest='parsed_args', const='-s',
-        help="print output while tests run (disable capture)")
-    pytest_group.add_argument(
-        "-k", action='store', metavar="EXPRESSION", dest='expression',
-        help="filter tests by keyword (can also use w/list options)")
-    pytest_group.add_argument(
-        "--showlocals", action='append_const', dest='parsed_args',
-        const='--showlocals', help="show local variable values in tracebacks")
-
-    # remainder is just passed to pytest
-    subparser.add_argument(
-        'pytest_args', nargs=argparse.REMAINDER, help="arguments for pytest")
-
-
-def do_list(args, extra_args):
-    """Print a lists of tests than what pytest offers."""
-    # Run test collection and get the tree out.
-    old_output = sys.stdout
-    try:
-        sys.stdout = output = StringIO()
-        pytest.main(['--collect-only'] + extra_args)
-    finally:
-        sys.stdout = old_output
-
-    lines = output.getvalue().split('\n')
-    tests = collections.defaultdict(lambda: set())
-    prefix = []
-
-    # collect tests into sections
-    for line in lines:
-        match = re.match(r"(\s*)<([^ ]*) '([^']*)'", line)
-        if not match:
-            continue
-        indent, nodetype, name = match.groups()
-
-        # strip parametrized tests
-        if "[" in name:
-            name = name[:name.index("[")]
-
-        depth = len(indent) // 2
-
-        if nodetype.endswith("Function"):
-            key = tuple(prefix)
-            tests[key].add(name)
-        else:
-            prefix = prefix[:depth]
-            prefix.append(name)
-
-    def colorize(c, prefix):
-        if isinstance(prefix, tuple):
-            return "::".join(
-                color.colorize("@%s{%s}" % (c, p))
-                for p in prefix if p != "()"
-            )
-        return color.colorize("@%s{%s}" % (c, prefix))
-
-    if args.list == "list":
-        files = set(prefix[0] for prefix in tests)
-        color_files = [colorize("B", file) for file in sorted(files)]
-        colify(color_files)
-
-    elif args.list == "long":
-        for prefix, functions in sorted(tests.items()):
-            path = colorize("*B", prefix) + "::"
-            functions = [colorize("c", f) for f in sorted(functions)]
-            color.cprint(path)
-            colify(functions, indent=4)
-            print()
-
-    else:  # args.list == "names"
-        all_functions = [
-            colorize("*B", prefix) + "::" + colorize("c", f)
-            for prefix, functions in sorted(tests.items())
-            for f in sorted(functions)
-        ]
-        colify(all_functions)
-
-
-def add_back_pytest_args(args, unknown_args):
-    """Add parsed pytest args, unknown args, and remainder together.
-
-    We add some basic pytest arguments to the Spack parser to ensure that
-    they show up in the short help, so we have to reassemble things here.
-    """
-    result = args.parsed_args or []
-    result += unknown_args or []
-    result += args.pytest_args or []
-    if args.expression:
-        result += ["-k", args.expression]
-    return result
-
-
-def test(parser, args, unknown_args):
-    if args.pytest_help:
-        # make the pytest.main help output more accurate
-        sys.argv[0] = 'spack test'
-        return pytest.main(['-h'])
-
-    # add back any parsed pytest args we need to pass to pytest
-    pytest_args = add_back_pytest_args(args, unknown_args)
-
-    # The default is to test the core of Spack. If the option `--extension`
-    # has been used, then test that extension.
-    pytest_root = spack.paths.spack_root
-    if args.extension:
-        target = args.extension
-        extensions = spack.config.get('config:extensions')
-        pytest_root = spack.extensions.path_for_extension(target, *extensions)
-
-    # pytest.ini lives in the root of the spack repository.
-    with working_dir(pytest_root):
-        if args.list:
-            do_list(args, pytest_args)
-            return
-
-        return pytest.main(pytest_args)
+        'specs', nargs=argparse.REMAINDER,
+        help="list of specs to test")
+
+
+def test(parser, args):
+    env = ev.get_env(args, 'test')
+    hashes = env.all_hashes() if env else None
+
+    specs = spack.cmd.parse_specs(args.specs) if args.specs else [None]
+    specs_to_test = []
+    for spec in specs:
+        matching = spack.store.db.query_local(spec, hashes=hashes)
+        if spec and not matching:
+            tty.warn("No installed packages match spec %s" % spec)
+        specs_to_test.extend(matching)
+
+    log_dir = os.getcwd()
+
+    if args.smoke_test:
+        for spec in specs_to_test:
+            log_file = os.path.join(log_dir, 'test-%s' % spec.dag_hash())
+            spec.package.do_test(log_file)
+    else:
+        raise NotImplementedError
diff --git a/lib/spack/spack/cmd/unit_test.py b/lib/spack/spack/cmd/unit_test.py
new file mode 100644
index 0000000000..3cc6680b68
--- /dev/null
+++ b/lib/spack/spack/cmd/unit_test.py
@@ -0,0 +1,105 @@
+# 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 __future__ import print_function
+
+import sys
+import os
+import re
+import argparse
+import pytest
+from six import StringIO
+
+from llnl.util.filesystem import working_dir
+from llnl.util.tty.colify import colify
+
+import spack.paths
+
+description = "run spack's unit tests"
+section = "developer"
+level = "long"
+
+
+def setup_parser(subparser):
+    subparser.add_argument(
+        '-H', '--pytest-help', action='store_true', default=False,
+        help="print full pytest help message, showing advanced options")
+
+    list_group = subparser.add_mutually_exclusive_group()
+    list_group.add_argument(
+        '-l', '--list', action='store_true', default=False,
+        help="list basic test names")
+    list_group.add_argument(
+        '-L', '--long-list', action='store_true', default=False,
+        help="list the entire hierarchy of tests")
+    subparser.add_argument(
+        '--extension', default=None,
+        help="run test for a given Spack extension"
+    )
+    subparser.add_argument(
+        'tests', nargs=argparse.REMAINDER,
+        help="list of tests to run (will be passed to pytest -k)")
+
+
+def do_list(args, unknown_args):
+    """Print a lists of tests than what pytest offers."""
+    # Run test collection and get the tree out.
+    old_output = sys.stdout
+    try:
+        sys.stdout = output = StringIO()
+        pytest.main(['--collect-only'])
+    finally:
+        sys.stdout = old_output
+
+    # put the output in a more readable tree format.
+    lines = output.getvalue().split('\n')
+    output_lines = []
+    for line in lines:
+        match = re.match(r"(\s*)<([^ ]*) '([^']*)'", line)
+        if not match:
+            continue
+        indent, nodetype, name = match.groups()
+
+        # only print top-level for short list
+        if args.list:
+            if not indent:
+                output_lines.append(
+                    os.path.basename(name).replace('.py', ''))
+        else:
+            print(indent + name)
+
+    if args.list:
+        colify(output_lines)
+
+
+def unit_test(parser, args, unknown_args):
+    if args.pytest_help:
+        # make the pytest.main help output more accurate
+        sys.argv[0] = 'spack unit-test'
+        pytest.main(['-h'])
+        return
+
+    # The default is to test the core of Spack. If the option `--extension`
+    # has been used, then test that extension.
+    pytest_root = spack.paths.test_path
+    if args.extension:
+        target = args.extension
+        extensions = spack.config.get('config:extensions')
+        pytest_root = spack.extensions.path_for_extension(target, *extensions)
+
+    # pytest.ini lives in the root of the spack repository.
+    with working_dir(pytest_root):
+        # --list and --long-list print the test output better.
+        if args.list or args.long_list:
+            do_list(args, unknown_args)
+            return
+
+        # Allow keyword search without -k if no options are specified
+        if (args.tests and not unknown_args and
+            not any(arg.startswith('-') for arg in args.tests)):
+            return pytest.main(['-k'] + args.tests)
+
+        # Just run the pytest command
+        return pytest.main(unknown_args + args.tests)
diff --git a/lib/spack/spack/package.py b/lib/spack/spack/package.py
index d5cb3065a8..b571769cf3 100644
--- a/lib/spack/spack/package.py
+++ b/lib/spack/spack/package.py
@@ -1576,6 +1576,28 @@ def do_install(self, **kwargs):
 
     do_install.__doc__ += install_args_docstring
 
+    def do_test(self, log_file, dirty=False):
+        def test_process():
+            with log_output(log_file) as logger:
+                with logger.force_echo():
+                    tty.msg('Testing package %s' %
+                            self.spec.format('{name}-{hash:7}'))
+                old_debug = tty.is_debug()
+                tty.set_debug(True)
+                self.test()
+                tty.set_debug(old_debug)
+
+        try:
+            spack.build_environment.fork(
+                self, test_process, dirty=dirty, fake=False, context='test')
+        except Exception as e:
+            tty.error('Tests failed. See test log for details\n'
+                      '  %s\n' % log_file)
+
+
+    def test(self):
+        pass
+
     def unit_test_check(self):
         """Hook for unit tests to assert things about package internals.
 
diff --git a/lib/spack/spack/test/cmd/test.py b/lib/spack/spack/test/cmd/test.py
index a9ef735afe..31100994e7 100644
--- a/lib/spack/spack/test/cmd/test.py
+++ b/lib/spack/spack/test/cmd/test.py
@@ -5,7 +5,7 @@
 
 from spack.main import SpackCommand
 
-spack_test = SpackCommand('test')
+spack_test = SpackCommand('unit-test')
 cmd_test_py = 'lib/spack/spack/test/cmd/test.py'
 
 
diff --git a/lib/spack/spack/util/executable.py b/lib/spack/spack/util/executable.py
index 097da3337e..7aba677a0c 100644
--- a/lib/spack/spack/util/executable.py
+++ b/lib/spack/spack/util/executable.py
@@ -2,7 +2,7 @@
 # Spack Project Developers. See the top-level COPYRIGHT file for details.
 #
 # SPDX-License-Identifier: (Apache-2.0 OR MIT)
-
+import sys
 import os
 import re
 import shlex
@@ -98,6 +98,9 @@ def __call__(self, *args, **kwargs):
           If both ``output`` and ``error`` are set to ``str``, then one string
           is returned containing output concatenated with error. Not valid
           for ``input``
+        * ``str.split``, as in the ``split`` method of the Python string type.
+          Behaves the same as ``str``, except that value is also written to
+          ``stdout`` or ``stderr``.
 
         By default, the subprocess inherits the parent's file descriptors.
 
@@ -132,7 +135,7 @@ def __call__(self, *args, **kwargs):
         def streamify(arg, mode):
             if isinstance(arg, string_types):
                 return open(arg, mode), True
-            elif arg is str:
+            elif arg in (str, str.split):
                 return subprocess.PIPE, False
             else:
                 return arg, False
@@ -168,12 +171,16 @@ def streamify(arg, mode):
             out, err = proc.communicate()
 
             result = None
-            if output is str or error is str:
+            if output in (str, str.split) or error in (str, str.split):
                 result = ''
-                if output is str:
+                if output in (str, str.split):
                     result += text_type(out.decode('utf-8'))
-                if error is str:
+                    if output is str.split:
+                        sys.stdout.write(out)
+                if error in (str, str.split):
                     result += text_type(err.decode('utf-8'))
+                    if error is str.split:
+                        sys.stderr.write(err)
 
             rc = self.returncode = proc.returncode
             if fail_on_error and rc != 0 and (rc not in ignore_errors):
diff --git a/var/spack/repos/builtin/packages/python/package.py b/var/spack/repos/builtin/packages/python/package.py
index 4282d8e014..2a6e407824 100644
--- a/var/spack/repos/builtin/packages/python/package.py
+++ b/var/spack/repos/builtin/packages/python/package.py
@@ -1109,3 +1109,18 @@ def remove_files_from_view(self, view, merge_map):
                 view.remove_file(src, dst)
             else:
                 os.remove(dst)
+
+    def test(self):
+        # contains python executable
+        python = which('python')
+        assert os.path.dirname(python.path) == os.path.dirname(self.command.path)
+
+        # run hello world
+        output = self.command('-c', 'print("hello world!")',
+                              output=str.split, error=str.split)
+        assert output == "hello world!\n"
+
+#        error = self.command('-c', 'print("Error: failed.")',
+#                             output=str.split, error=str.split)
+
+#        assert error.strip() == 'Error: failed.'
-- 
GitLab