Simplify creation of test and install reports (#34712)

The code in Spack to generate install and test reports currently suffers from unneeded complexity. For
instance, we have classes in Spack core packages, like `spack.reporters.CDash`, that need an
`argparse.Namespace` to be initialized and have "hard-coded" string literals on which they branch to
change their behavior:

```python
if do_fn.__name__ == "do_test" and skip_externals:
    package["result"] = "skipped"
else:
    package["result"] = "success"
package["stdout"] = fetch_log(pkg, do_fn, self.dir)
package["installed_from_binary_cache"] = pkg.installed_from_binary_cache
if do_fn.__name__ == "_install_task" and installed_already:
    return
```
This PR attempt to polish the major issues encountered in both `spack.report` and `spack.reporters`.

Details:
- [x] `spack.reporters` is now a package that contains both the base class `Reporter` and all 
      the derived classes (`JUnit` and `CDash`)
- [x] Classes derived from `spack.reporters.Reporter` don't take an `argparse.Namespace` anymore
       as argument to `__init__`. The rationale is that code for commands should be built upon Spack
       core classes, not vice-versa.
- [x] An `argparse.Action` has been coded to create the correct `Reporter` object based on command
       line arguments
- [x] The context managers to generate reports from either `spack install` or from `spack test` have
       been greatly simplified, and have been made less "dynamic" in nature. In particular, the `collect_info`
       class has been deleted in favor of two more specific context managers. This allows for a simpler
       structure of the code, and less knowledge required to client code (in particular on which method to patch)
- [x] The `InfoCollector` class has been turned into a simple hierarchy, so to avoid conditional statements
       within methods that assume a knowledge of the context in which the method is called.
This commit is contained in:
Massimiliano Culpo 2022-12-30 19:15:38 +01:00 committed by GitHub
parent 79268cedd2
commit b549548f69
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 369 additions and 323 deletions

View File

@ -39,9 +39,8 @@
import spack.util.url as url_util
import spack.util.web as web_util
from spack.error import SpackError
from spack.reporters.cdash import CDash
from spack.reporters import CDash, CDashConfiguration
from spack.reporters.cdash import build_stamp as cdash_build_stamp
from spack.util.pattern import Bunch
JOB_RETRY_CONDITIONS = [
"always",
@ -2358,10 +2357,14 @@ def populate_buildgroup(self, job_names):
tty.warn(msg)
def report_skipped(self, spec, directory_name, reason):
cli_args = self.args()
cli_args.extend(["package", [spec.name]])
it = iter(cli_args)
kv = {x.replace("--", "").replace("-", "_"): next(it) for x in it}
reporter = CDash(Bunch(**kv))
configuration = CDashConfiguration(
upload_url=self.upload_url,
packages=[spec.name],
build=self.build_name,
site=self.site,
buildstamp=self.build_stamp,
track=None,
ctest_parsing=False,
)
reporter = CDash(configuration=configuration)
reporter.test_skipped_report(directory_name, spec, reason)

View File

@ -13,6 +13,7 @@
import spack.dependency as dep
import spack.environment as ev
import spack.modules
import spack.reporters
import spack.spec
import spack.store
from spack.util.pattern import Args
@ -123,6 +124,64 @@ def __call__(self, parser, namespace, values, option_string=None):
setattr(namespace, self.dest, deptype)
def _cdash_reporter(namespace):
"""Helper function to create a CDash reporter. This function gets an early reference to the
argparse namespace under construction, so it can later use it to create the object.
"""
def _factory():
def installed_specs(args):
if getattr(args, "spec", ""):
packages = args.spec
elif getattr(args, "specs", ""):
packages = args.specs
elif getattr(args, "package", ""):
# Ensure CI 'spack test run' can output CDash results
packages = args.package
else:
packages = []
for file in args.specfiles:
with open(file, "r") as f:
s = spack.spec.Spec.from_yaml(f)
packages.append(s.format())
return packages
configuration = spack.reporters.CDashConfiguration(
upload_url=namespace.cdash_upload_url,
packages=installed_specs(namespace),
build=namespace.cdash_build,
site=namespace.cdash_site,
buildstamp=namespace.cdash_buildstamp,
track=namespace.cdash_track,
ctest_parsing=getattr(namespace, "ctest_parsing", False),
)
return spack.reporters.CDash(configuration=configuration)
return _factory
class CreateReporter(argparse.Action):
"""Create the correct object to generate reports for installation and testing."""
def __call__(self, parser, namespace, values, option_string=None):
setattr(namespace, self.dest, values)
if values == "junit":
setattr(namespace, "reporter", spack.reporters.JUnit)
elif values == "cdash":
setattr(namespace, "reporter", _cdash_reporter(namespace))
@arg
def log_format():
return Args(
"--log-format",
default=None,
action=CreateReporter,
choices=("junit", "cdash"),
help="format to be used for log files",
)
# TODO: merge constraint and installed_specs
@arg
def constraint():

View File

@ -8,9 +8,10 @@
import shutil
import sys
import textwrap
from typing import List
import llnl.util.filesystem as fs
import llnl.util.tty as tty
from llnl.util import lang, tty
import spack.build_environment
import spack.cmd
@ -232,12 +233,7 @@ def setup_parser(subparser):
if 'all' is chosen, run package tests during installation for all
packages. If neither are chosen, don't run tests for any packages.""",
)
subparser.add_argument(
"--log-format",
default=None,
choices=spack.report.valid_formats,
help="format to be used for log files",
)
arguments.add_common_arguments(subparser, ["log_format"])
subparser.add_argument(
"--log-file",
default=None,
@ -262,6 +258,12 @@ def default_log_file(spec):
return fs.os.path.join(dirname, basename)
def report_filename(args: argparse.Namespace, specs: List[spack.spec.Spec]) -> str:
"""Return the filename to be used for reporting to JUnit or CDash format."""
result = args.log_file or args.cdash_upload_url or default_log_file(specs[0])
return result
def install_specs(specs, install_kwargs, cli_args):
try:
if ev.active_environment():
@ -361,19 +363,8 @@ def print_cdash_help():
parser.print_help()
def _create_log_reporter(args):
# TODO: remove args injection to spack.report.collect_info, since a class in core
# TODO: shouldn't know what are the command line arguments a command use.
reporter = spack.report.collect_info(
spack.package_base.PackageInstaller, "_install_task", args.log_format, args
)
if args.log_file:
reporter.filename = args.log_file
return reporter
def install_all_specs_from_active_environment(
install_kwargs, only_concrete, cli_test_arg, reporter
install_kwargs, only_concrete, cli_test_arg, reporter_factory
):
"""Install all specs from the active environment
@ -415,12 +406,10 @@ def install_all_specs_from_active_environment(
tty.msg(msg)
return
if not reporter.filename:
reporter.filename = default_log_file(specs[0])
reporter.specs = specs
reporter = reporter_factory(specs) or lang.nullcontext()
tty.msg("Installing environment {0}".format(env.name))
with reporter("build"):
with reporter:
env.install_all(**install_kwargs)
tty.debug("Regenerating environment views for {0}".format(env.name))
@ -439,7 +428,7 @@ def compute_tests_install_kwargs(specs, cli_test_arg):
return False
def specs_from_cli(args, install_kwargs, reporter):
def specs_from_cli(args, install_kwargs):
"""Return abstract and concrete spec parsed from the command line."""
abstract_specs = spack.cmd.parse_specs(args.spec)
install_kwargs["tests"] = compute_tests_install_kwargs(abstract_specs, args.test)
@ -449,7 +438,9 @@ def specs_from_cli(args, install_kwargs, reporter):
)
except SpackError as e:
tty.debug(e)
reporter.concretization_report(e.message)
if args.log_format is not None:
reporter = args.reporter()
reporter.concretization_report(report_filename(args, abstract_specs), e.message)
raise
return abstract_specs, concrete_specs
@ -514,7 +505,17 @@ def install(parser, args):
if args.deprecated:
spack.config.set("config:deprecated", True, scope="command_line")
reporter = _create_log_reporter(args)
def reporter_factory(specs):
if args.log_format is None:
return None
context_manager = spack.report.build_context_manager(
reporter=args.reporter(),
filename=report_filename(args, specs=specs),
specs=specs,
)
return context_manager
install_kwargs = install_kwargs_from_args(args)
if not args.spec and not args.specfiles:
@ -523,12 +524,12 @@ def install(parser, args):
install_kwargs=install_kwargs,
only_concrete=args.only_concrete,
cli_test_arg=args.test,
reporter=reporter,
reporter_factory=reporter_factory,
)
return
# Specs from CLI
abstract_specs, concrete_specs = specs_from_cli(args, install_kwargs, reporter)
abstract_specs, concrete_specs = specs_from_cli(args, install_kwargs)
# Concrete specs from YAML or JSON files
specs_from_file = concrete_specs_from_file(args)
@ -538,11 +539,8 @@ def install(parser, args):
if len(concrete_specs) == 0:
tty.die("The `spack install` command requires a spec to install.")
if not reporter.filename:
reporter.filename = default_log_file(concrete_specs[0])
reporter.specs = concrete_specs
with reporter("build"):
reporter = reporter_factory(concrete_specs) or lang.nullcontext()
with reporter:
if args.overwrite:
require_user_confirmation_for_overwrite(concrete_specs, args)
install_kwargs["overwrite"] = [spec.dag_hash() for spec in concrete_specs]

View File

@ -13,8 +13,8 @@
import sys
import textwrap
import llnl.util.tty as tty
import llnl.util.tty.colify as colify
from llnl.util import lang, tty
from llnl.util.tty import colify
import spack.cmd
import spack.cmd.common.arguments as arguments
@ -63,12 +63,7 @@ def setup_parser(subparser):
run_parser.add_argument(
"--keep-stage", action="store_true", help="Keep testing directory for debugging"
)
run_parser.add_argument(
"--log-format",
default=None,
choices=spack.report.valid_formats,
help="format to be used for log files",
)
arguments.add_common_arguments(run_parser, ["log_format"])
run_parser.add_argument(
"--log-file",
default=None,
@ -231,10 +226,23 @@ def test_run(args):
# Set up reporter
setattr(args, "package", [s.format() for s in test_suite.specs])
reporter = spack.report.collect_info(
spack.package_base.PackageBase, "do_test", args.log_format, args
)
if not reporter.filename:
reporter = create_reporter(args, specs_to_test, test_suite) or lang.nullcontext()
with reporter:
test_suite(
remove_directory=not args.keep_stage,
dirty=args.dirty,
fail_first=args.fail_first,
externals=args.externals,
)
def create_reporter(args, specs_to_test, test_suite):
if args.log_format is None:
return None
filename = args.cdash_upload_url
if not filename:
if args.log_file:
if os.path.isabs(args.log_file):
log_file = args.log_file
@ -243,16 +251,15 @@ def test_run(args):
log_file = os.path.join(log_dir, args.log_file)
else:
log_file = os.path.join(os.getcwd(), "test-%s" % test_suite.name)
reporter.filename = log_file
reporter.specs = specs_to_test
filename = log_file
with reporter("test", test_suite.stage):
test_suite(
remove_directory=not args.keep_stage,
dirty=args.dirty,
fail_first=args.fail_first,
externals=args.externals,
)
context_manager = spack.report.test_context_manager(
reporter=args.reporter(),
filename=filename,
specs=specs_to_test,
raw_logs_dir=test_suite.stage,
)
return context_manager
def test_list(args):

View File

@ -3,9 +3,8 @@
#
# SPDX-License-Identifier: (Apache-2.0 OR MIT)
"""Tools to produce reports of spec installations"""
import argparse
import codecs
import collections
import contextlib
import functools
import os
import time
@ -16,74 +15,59 @@
import spack.build_environment
import spack.fetch_strategy
import spack.install_test
import spack.installer
import spack.package_base
from spack.install_test import TestSuite
from spack.reporter import Reporter
from spack.reporters.cdash import CDash
from spack.reporters.junit import JUnit
report_writers = {None: Reporter, "junit": JUnit, "cdash": CDash}
#: Allowed report formats
valid_formats = list(report_writers.keys())
__all__ = ["valid_formats", "collect_info"]
import spack.reporters
import spack.spec
def fetch_log(pkg, do_fn, dir):
log_files = {
"_install_task": pkg.build_log_path,
"do_test": os.path.join(dir, TestSuite.test_log_name(pkg.spec)),
}
try:
with codecs.open(log_files[do_fn.__name__], "r", "utf-8") as f:
return "".join(f.readlines())
except Exception:
return "Cannot open log for {0}".format(pkg.spec.cshort_spec)
class InfoCollector:
"""Base class for context manager objects that collect information during the execution of
certain package functions.
class InfoCollector(object):
"""Decorates PackageInstaller._install_task, which is called via
PackageBase.do_install for individual specs, to collect information
on the installation of certain specs.
When exiting the context this change will be rolled-back.
The data collected is available through the ``specs``
attribute once exited, and it's organized as a list where
each item represents the installation of one of the spec.
The data collected is available through the ``specs`` attribute once exited, and it's
organized as a list where each item represents the installation of one spec.
"""
wrap_class: Type
do_fn: str
_backup_do_fn: Callable
input_specs: List["spack.spec.Spec"]
input_specs: List[spack.spec.Spec]
specs: List[Dict[str, Any]]
dir: str
def __init__(self, wrap_class: Type, do_fn: str, specs: List["spack.spec.Spec"], dir: str):
def __init__(self, wrap_class: Type, do_fn: str, specs: List[spack.spec.Spec]):
#: Class for which to wrap a function
self.wrap_class = wrap_class
#: Action to be reported on
self.do_fn = do_fn
#: Backup of PackageBase function
#: Backup of the wrapped class function
self._backup_do_fn = getattr(self.wrap_class, do_fn)
#: Specs that will be acted on
self.input_specs = specs
#: This is where we record the data that will be included
#: in our report.
self.specs = []
#: Record directory for test log paths
self.dir = dir
#: This is where we record the data that will be included in our report
self.specs: List[Dict[str, Any]] = []
def fetch_log(self, pkg: spack.package_base.PackageBase) -> str:
"""Return the stdout log associated with the function being monitored
Args:
pkg: package under consideration
"""
raise NotImplementedError("must be implemented by derived classes")
def extract_package_from_signature(self, instance, *args, **kwargs):
"""Return the package instance, given the signature of the wrapped function."""
raise NotImplementedError("must be implemented by derived classes")
def __enter__(self):
# Initialize the spec report with the data that is available upfront.
Property = collections.namedtuple("Property", ["name", "value"])
for input_spec in self.input_specs:
name_fmt = "{0}_{1}"
name = name_fmt.format(input_spec.name, input_spec.dag_hash(length=7))
spec = {
spec_record = {
"name": name,
"nerrors": None,
"nfailures": None,
@ -93,45 +77,17 @@ def __enter__(self):
"properties": [],
"packages": [],
}
spec_record["properties"].append(Property("architecture", input_spec.architecture))
spec_record["properties"].append(Property("compiler", input_spec.compiler))
self.init_spec_record(input_spec, spec_record)
self.specs.append(spec_record)
self.specs.append(spec)
def gather_info(wrapped_fn):
"""Decorates a function to gather useful information for a CI report."""
Property = collections.namedtuple("Property", ["name", "value"])
spec["properties"].append(Property("architecture", input_spec.architecture))
spec["properties"].append(Property("compiler", input_spec.compiler))
# Check which specs are already installed and mark them as skipped
# only for install_task
if self.do_fn == "_install_task":
for dep in filter(lambda x: x.installed, input_spec.traverse()):
package = {
"name": dep.name,
"id": dep.dag_hash(),
"elapsed_time": "0.0",
"result": "skipped",
"message": "Spec already installed",
}
spec["packages"].append(package)
def gather_info(do_fn):
"""Decorates do_fn to gather useful information for
a CI report.
It's defined here to capture the environment and build
this context as the installations proceed.
"""
@functools.wraps(do_fn)
@functools.wraps(wrapped_fn)
def wrapper(instance, *args, **kwargs):
if isinstance(instance, spack.package_base.PackageBase):
pkg = instance
elif hasattr(args[0], "pkg"):
pkg = args[0].pkg
else:
raise Exception
# We accounted before for what is already installed
installed_already = pkg.spec.installed
pkg = self.extract_package_from_signature(instance, *args, **kwargs)
package = {
"name": pkg.name,
@ -147,8 +103,8 @@ def wrapper(instance, *args, **kwargs):
# installed explicitly will also be installed as a
# dependency of another spec. In this case append to both
# spec reports.
for s in llnl.util.lang.dedupe([pkg.spec.root, pkg.spec]):
name = name_fmt.format(s.name, s.dag_hash(length=7))
for current_spec in llnl.util.lang.dedupe([pkg.spec.root, pkg.spec]):
name = name_fmt.format(current_spec.name, current_spec.dag_hash(length=7))
try:
item = next((x for x in self.specs if x["name"] == name))
item["packages"].append(package)
@ -156,132 +112,165 @@ def wrapper(instance, *args, **kwargs):
pass
start_time = time.time()
value = None
try:
value = do_fn(instance, *args, **kwargs)
externals = kwargs.get("externals", False)
skip_externals = pkg.spec.external and not externals
if do_fn.__name__ == "do_test" and skip_externals:
package["result"] = "skipped"
else:
package["result"] = "success"
package["stdout"] = fetch_log(pkg, do_fn, self.dir)
value = wrapped_fn(instance, *args, **kwargs)
package["stdout"] = self.fetch_log(pkg)
package["installed_from_binary_cache"] = pkg.installed_from_binary_cache
if do_fn.__name__ == "_install_task" and installed_already:
return
self.on_success(pkg, kwargs, package)
return value
except spack.build_environment.InstallError as e:
except spack.build_environment.InstallError as exc:
# An InstallError is considered a failure (the recipe
# didn't work correctly)
package["result"] = "failure"
package["message"] = e.message or "Installation failure"
package["stdout"] = fetch_log(pkg, do_fn, self.dir)
package["message"] = exc.message or "Installation failure"
package["stdout"] = self.fetch_log(pkg)
package["stdout"] += package["message"]
package["exception"] = e.traceback
package["exception"] = exc.traceback
raise
except (Exception, BaseException) as e:
except (Exception, BaseException) as exc:
# Everything else is an error (the installation
# failed outside of the child process)
package["result"] = "error"
package["stdout"] = fetch_log(pkg, do_fn, self.dir)
package["message"] = str(e) or "Unknown error"
package["stdout"] = self.fetch_log(pkg)
package["message"] = str(exc) or "Unknown error"
package["exception"] = traceback.format_exc()
raise
finally:
package["elapsed_time"] = time.time() - start_time
return value
return wrapper
setattr(self.wrap_class, self.do_fn, gather_info(getattr(self.wrap_class, self.do_fn)))
def __exit__(self, exc_type, exc_val, exc_tb):
def on_success(self, pkg: spack.package_base.PackageBase, kwargs, package_record):
"""Add additional properties on function call success."""
raise NotImplementedError("must be implemented by derived classes")
def init_spec_record(self, input_spec: spack.spec.Spec, record):
"""Add additional entries to a spec record when entering the collection context."""
def __exit__(self, exc_type, exc_val, exc_tb):
# Restore the original method in PackageBase
setattr(self.wrap_class, self.do_fn, self._backup_do_fn)
for spec in self.specs:
spec["npackages"] = len(spec["packages"])
spec["nfailures"] = len([x for x in spec["packages"] if x["result"] == "failure"])
spec["nerrors"] = len([x for x in spec["packages"] if x["result"] == "error"])
spec["time"] = sum([float(x["elapsed_time"]) for x in spec["packages"]])
spec["time"] = sum(float(x["elapsed_time"]) for x in spec["packages"])
class collect_info(object):
"""Collects information to build a report while installing
and dumps it on exit.
If the format name is not ``None``, this context manager decorates
PackageInstaller._install_task when entering the context for a
PackageBase.do_install operation and unrolls the change when exiting.
Within the context, only the specs that are passed to it
on initialization will be recorded for the report. Data from
other specs will be discarded.
Examples:
.. code-block:: python
# The file 'junit.xml' is written when exiting
# the context
s = [Spec('hdf5').concretized()]
with collect_info(PackageBase, do_install, s, 'junit', 'a.xml'):
# A report will be generated for these specs...
for spec in s:
getattr(class, function)(spec)
# ...but not for this one
Spec('zlib').concretized().do_install()
class BuildInfoCollector(InfoCollector):
"""Collect information for the PackageInstaller._install_task method.
Args:
class: class on which to wrap a function
function: function to wrap
format_name: one of the supported formats
args: args passed to function
Raises:
ValueError: when ``format_name`` is not in ``valid_formats``
specs: specs whose install information will be recorded
"""
def __init__(self, cls: Type, function: str, format_name: str, args: argparse.Namespace):
self.cls = cls
self.function = function
self.filename = None
self.ctest_parsing = getattr(args, "ctest_parsing", False)
if args.cdash_upload_url:
self.format_name = "cdash"
self.filename = "cdash_report"
else:
self.format_name = format_name
# Check that the format is valid.
if self.format_name not in valid_formats:
raise ValueError("invalid report type: {0}".format(self.format_name))
self.report_writer = report_writers[self.format_name](args)
def __init__(self, specs: List[spack.spec.Spec]):
super().__init__(spack.installer.PackageInstaller, "_install_task", specs)
def __call__(self, type, dir=None):
self.type = type
self.dir = dir or os.getcwd()
return self
def init_spec_record(self, input_spec, record):
# Check which specs are already installed and mark them as skipped
for dep in filter(lambda x: x.installed, input_spec.traverse()):
package = {
"name": dep.name,
"id": dep.dag_hash(),
"elapsed_time": "0.0",
"result": "skipped",
"message": "Spec already installed",
}
record["packages"].append(package)
def concretization_report(self, msg):
self.report_writer.concretization_report(self.filename, msg)
def on_success(self, pkg, kwargs, package_record):
package_record["result"] = "success"
def __enter__(self):
if self.format_name:
# Start the collector and patch self.function on appropriate class
self.collector = InfoCollector(self.cls, self.function, self.specs, self.dir)
self.collector.__enter__()
def fetch_log(self, pkg):
try:
with open(pkg.build_log_path, "r", encoding="utf-8") as stream:
return "".join(stream.readlines())
except Exception:
return f"Cannot open log for {pkg.spec.cshort_spec}"
def __exit__(self, exc_type, exc_val, exc_tb):
if self.format_name:
# Close the collector and restore the original function
self.collector.__exit__(exc_type, exc_val, exc_tb)
def extract_package_from_signature(self, instance, *args, **kwargs):
return args[0].pkg
report_data = {"specs": self.collector.specs}
report_data["ctest-parsing"] = self.ctest_parsing
report_fn = getattr(self.report_writer, "%s_report" % self.type)
report_fn(self.filename, report_data)
class TestInfoCollector(InfoCollector):
"""Collect information for the PackageBase.do_test method.
Args:
specs: specs whose install information will be recorded
record_directory: record directory for test log paths
"""
dir: str
def __init__(self, specs: List[spack.spec.Spec], record_directory: str):
super().__init__(spack.package_base.PackageBase, "do_test", specs)
self.dir = record_directory
def on_success(self, pkg, kwargs, package_record):
externals = kwargs.get("externals", False)
skip_externals = pkg.spec.external and not externals
if skip_externals:
package_record["result"] = "skipped"
package_record["result"] = "success"
def fetch_log(self, pkg: spack.package_base.PackageBase):
log_file = os.path.join(self.dir, spack.install_test.TestSuite.test_log_name(pkg.spec))
try:
with open(log_file, "r", encoding="utf-8") as stream:
return "".join(stream.readlines())
except Exception:
return f"Cannot open log for {pkg.spec.cshort_spec}"
def extract_package_from_signature(self, instance, *args, **kwargs):
return instance
@contextlib.contextmanager
def build_context_manager(
reporter: spack.reporters.Reporter,
filename: str,
specs: List[spack.spec.Spec],
):
"""Decorate a package to generate a report after the installation function is executed.
Args:
reporter: object that generates the report
filename: filename for the report
specs: specs that need reporting
"""
collector = BuildInfoCollector(specs)
try:
with collector:
yield
finally:
reporter.build_report(filename, specs=collector.specs)
@contextlib.contextmanager
def test_context_manager(
reporter: spack.reporters.Reporter,
filename: str,
specs: List[spack.spec.Spec],
raw_logs_dir: str,
):
"""Decorate a package to generate a report after the test function is executed.
Args:
reporter: object that generates the report
filename: filename for the report
specs: specs that need reporting
raw_logs_dir: record directory for test log paths
"""
collector = TestInfoCollector(specs, raw_logs_dir)
try:
with collector:
yield
finally:
reporter.test_report(filename, specs=collector.specs)

View File

@ -1,23 +0,0 @@
# Copyright 2013-2022 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)
__all__ = ["Reporter"]
class Reporter(object):
"""Base class for report writers."""
def __init__(self, args):
self.args = args
def build_report(self, filename, report_data):
pass
def test_report(self, filename, report_data):
pass
def concretization_report(self, filename, msg):
pass

View File

@ -2,3 +2,8 @@
# Spack Project Developers. See the top-level COPYRIGHT file for details.
#
# SPDX-License-Identifier: (Apache-2.0 OR MIT)
from .base import Reporter
from .cdash import CDash, CDashConfiguration
from .junit import JUnit
__all__ = ["JUnit", "CDash", "CDashConfiguration", "Reporter"]

View File

@ -0,0 +1,18 @@
# Copyright 2013-2022 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 typing import Any, Dict, List
class Reporter:
"""Base class for report writers."""
def build_report(self, filename: str, specs: List[Dict[str, Any]]):
raise NotImplementedError("must be implemented by derived classes")
def test_report(self, filename: str, specs: List[Dict[str, Any]]):
raise NotImplementedError("must be implemented by derived classes")
def concretization_report(self, filename: str, msg: str):
raise NotImplementedError("must be implemented by derived classes")

View File

@ -12,6 +12,7 @@
import socket
import time
import xml.sax.saxutils
from typing import Dict
from urllib.parse import urlencode
from urllib.request import HTTPHandler, Request, build_opener
@ -24,15 +25,14 @@
import spack.platforms
import spack.util.git
from spack.error import SpackError
from spack.reporter import Reporter
from spack.reporters.extract import extract_test_parts
from spack.util.crypto import checksum
from spack.util.log_parse import parse_log_events
__all__ = ["CDash"]
from .base import Reporter
from .extract import extract_test_parts
# Mapping Spack phases to the corresponding CTest/CDash phase.
map_phases_to_cdash = {
MAP_PHASES_TO_CDASH = {
"autoreconf": "configure",
"cmake": "configure",
"configure": "configure",
@ -42,8 +42,14 @@
}
# Initialize data structures common to each phase's report.
cdash_phases = set(map_phases_to_cdash.values())
cdash_phases.add("update")
CDASH_PHASES = set(MAP_PHASES_TO_CDASH.values())
CDASH_PHASES.add("update")
CDashConfiguration = collections.namedtuple(
"CDashConfiguration",
["upload_url", "packages", "build", "site", "buildstamp", "track", "ctest_parsing"],
)
def build_stamp(track, timestamp):
@ -64,13 +70,13 @@ class CDash(Reporter):
CDash instance hosted at https://mydomain.com/cdash.
"""
def __init__(self, args):
Reporter.__init__(self, args)
def __init__(self, configuration: CDashConfiguration):
#: Set to False if any error occurs when building the CDash report
self.success = True
# Posixpath is used here to support the underlying template enginge
# Jinja2, which expects `/` path separators
self.template_dir = posixpath.join("reports", "cdash")
self.cdash_upload_url = args.cdash_upload_url
# Jinja2 expects `/` path separators
self.template_dir = "reports/cdash"
self.cdash_upload_url = configuration.upload_url
if self.cdash_upload_url:
self.buildid_regexp = re.compile("<buildId>([0-9]+)</buildId>")
@ -81,38 +87,26 @@ def __init__(self, args):
tty.verbose("Using CDash auth token from environment")
self.authtoken = os.environ.get("SPACK_CDASH_AUTH_TOKEN")
if getattr(args, "spec", ""):
packages = args.spec
elif getattr(args, "specs", ""):
packages = args.specs
elif getattr(args, "package", ""):
# Ensure CI 'spack test run' can output CDash results
packages = args.package
else:
packages = []
for file in args.specfiles:
with open(file, "r") as f:
s = spack.spec.Spec.from_yaml(f)
packages.append(s.format())
self.install_command = " ".join(packages)
self.base_buildname = args.cdash_build or self.install_command
self.site = args.cdash_site or socket.gethostname()
self.install_command = " ".join(configuration.packages)
self.base_buildname = configuration.build or self.install_command
self.site = configuration.site or socket.gethostname()
self.osname = platform.system()
self.osrelease = platform.release()
self.target = spack.platforms.host().target("default_target")
self.endtime = int(time.time())
self.buildstamp = (
args.cdash_buildstamp
if args.cdash_buildstamp
else build_stamp(args.cdash_track, self.endtime)
configuration.buildstamp
if configuration.buildstamp
else build_stamp(configuration.track, self.endtime)
)
self.buildIds = collections.OrderedDict()
self.buildIds: Dict[str, str] = {}
self.revision = ""
git = spack.util.git.git()
with working_dir(spack.paths.spack_root):
self.revision = git("rev-parse", "HEAD", output=str).strip()
self.generator = "spack-{0}".format(spack.main.get_version())
self.multiple_packages = False
self.ctest_parsing = configuration.ctest_parsing
def report_build_name(self, pkg_name):
return (
@ -129,7 +123,7 @@ def build_report_for_package(self, directory_name, package, duration):
self.current_package_name = package["name"]
self.buildname = self.report_build_name(self.current_package_name)
report_data = self.initialize_report(directory_name)
for phase in cdash_phases:
for phase in CDASH_PHASES:
report_data[phase] = {}
report_data[phase]["loglines"] = []
report_data[phase]["status"] = 0
@ -149,10 +143,10 @@ def build_report_for_package(self, directory_name, package, duration):
match = self.phase_regexp.search(line)
if match:
current_phase = match.group(1)
if current_phase not in map_phases_to_cdash:
if current_phase not in MAP_PHASES_TO_CDASH:
current_phase = ""
continue
cdash_phase = map_phases_to_cdash[current_phase]
cdash_phase = MAP_PHASES_TO_CDASH[current_phase]
if cdash_phase not in phases_encountered:
phases_encountered.append(cdash_phase)
report_data[cdash_phase]["loglines"].append(
@ -239,13 +233,13 @@ def clean_log_event(event):
f.write(t.render(report_data))
self.upload(phase_report)
def build_report(self, directory_name, input_data):
def build_report(self, directory_name, specs):
# Do an initial scan to determine if we are generating reports for more
# than one package. When we're only reporting on a single package we
# do not explicitly include the package's name in the CDash build name.
self.multipe_packages = False
self.multiple_packages = False
num_packages = 0
for spec in input_data["specs"]:
for spec in specs:
# Do not generate reports for packages that were installed
# from the binary cache.
spec["packages"] = [
@ -263,7 +257,7 @@ def build_report(self, directory_name, input_data):
break
# Generate reports for each package in each spec.
for spec in input_data["specs"]:
for spec in specs:
duration = 0
if "time" in spec:
duration = int(spec["time"])
@ -392,10 +386,10 @@ def test_report_for_package(self, directory_name, package, duration, ctest_parsi
self.report_test_data(directory_name, package, phases, report_data)
def test_report(self, directory_name, input_data):
def test_report(self, directory_name, specs):
"""Generate reports for each package in each spec."""
tty.debug("Processing test report")
for spec in input_data["specs"]:
for spec in specs:
duration = 0
if "time" in spec:
duration = int(spec["time"])
@ -404,7 +398,7 @@ def test_report(self, directory_name, input_data):
directory_name,
package,
duration,
input_data["ctest-parsing"],
self.ctest_parsing,
)
self.finalize_report()

View File

@ -2,36 +2,32 @@
# Spack Project Developers. See the top-level COPYRIGHT file for details.
#
# SPDX-License-Identifier: (Apache-2.0 OR MIT)
import os.path
import posixpath
import spack.tengine
from spack.reporter import Reporter
__all__ = ["JUnit"]
from .base import Reporter
class JUnit(Reporter):
"""Generate reports of spec installations for JUnit."""
def __init__(self, args):
Reporter.__init__(self, args)
# Posixpath is used here to support the underlying template enginge
# Jinja2, which expects `/` path separators
self.template_file = posixpath.join("reports", "junit.xml")
_jinja_template = "reports/junit.xml"
def build_report(self, filename, report_data):
def concretization_report(self, filename, msg):
pass
def build_report(self, filename, specs):
if not (os.path.splitext(filename))[1]:
# Ensure the report name will end with the proper extension;
# otherwise, it currently defaults to the "directory" name.
filename = filename + ".xml"
# Write the report
report_data = {"specs": specs}
with open(filename, "w") as f:
env = spack.tengine.make_environment()
t = env.get_template(self.template_file)
t = env.get_template(self._jinja_template)
f.write(t.render(report_data))
def test_report(self, filename, report_data):
self.build_report(filename, report_data)
def test_report(self, filename, specs):
self.build_report(filename, specs)

View File

@ -7,10 +7,9 @@
import llnl.util.filesystem as fs
import llnl.util.tty as tty
import spack.reporters.cdash
import spack.reporters.extract
import spack.spec
from spack.util.pattern import Bunch
from spack.reporters import CDash, CDashConfiguration
# Use a path variable to appease Spack style line length checks
fake_install_prefix = fs.join_path(
@ -152,22 +151,23 @@ def test_reporters_skip():
def test_reporters_report_for_package_no_stdout(tmpdir, monkeypatch, capfd):
class MockCDash(spack.reporters.cdash.CDash):
class MockCDash(CDash):
def upload(*args, **kwargs):
# Just return (Do NOT try to upload the report to the fake site)
return
args = Bunch(
cdash_upload_url="https://fake-upload",
package="fake-package",
cdash_build="fake-cdash-build",
cdash_site="fake-site",
cdash_buildstamp=None,
cdash_track="fake-track",
configuration = CDashConfiguration(
upload_url="https://fake-upload",
packages="fake-package",
build="fake-cdash-build",
site="fake-site",
buildstamp=None,
track="fake-track",
ctest_parsing=False,
)
monkeypatch.setattr(tty, "_debug", 1)
reporter = MockCDash(args)
reporter = MockCDash(configuration=configuration)
pkg_data = {"name": "fake-package"}
reporter.test_report_for_package(tmpdir.strpath, pkg_data, 0, False)
err = capfd.readouterr()[1]