zephyr/scripts/pylib/twister/twisterlib/coverage.py
Dmitrii Golovanov 947c9dc1b4 twister: coverage: Fix LCOV full path prefixes
In some cases genhtml incorrectly built reports from LCOV
coverage data using full path for some of the source files
and relative paths for other files.
This fix adds `--prefix` parameter to shorten paths explicitly
relative to the ZEPHYR_BASE directory.

Signed-off-by: Dmitrii Golovanov <dmitrii.golovanov@intel.com>
2023-12-19 08:52:20 -06:00

346 lines
14 KiB
Python

# vim: set syntax=python ts=4 :
#
# Copyright (c) 2018-2022 Intel Corporation
# SPDX-License-Identifier: Apache-2.0
import os
import logging
import pathlib
import shutil
import subprocess
import glob
import re
logger = logging.getLogger('twister')
logger.setLevel(logging.DEBUG)
class CoverageTool:
""" Base class for every supported coverage tool
"""
def __init__(self):
self.gcov_tool = None
self.base_dir = None
self.output_formats = None
@staticmethod
def factory(tool):
if tool == 'lcov':
t = Lcov()
elif tool == 'gcovr':
t = Gcovr()
else:
logger.error("Unsupported coverage tool specified: {}".format(tool))
return None
logger.debug(f"Select {tool} as the coverage tool...")
return t
@staticmethod
def retrieve_gcov_data(input_file):
logger.debug("Working on %s" % input_file)
extracted_coverage_info = {}
capture_data = False
capture_complete = False
with open(input_file, 'r') as fp:
for line in fp.readlines():
if re.search("GCOV_COVERAGE_DUMP_START", line):
capture_data = True
continue
if re.search("GCOV_COVERAGE_DUMP_END", line):
capture_complete = True
break
# Loop until the coverage data is found.
if not capture_data:
continue
if line.startswith("*"):
sp = line.split("<")
if len(sp) > 1:
# Remove the leading delimiter "*"
file_name = sp[0][1:]
# Remove the trailing new line char
hex_dump = sp[1][:-1]
else:
continue
else:
continue
extracted_coverage_info.update({file_name: hex_dump})
if not capture_data:
capture_complete = True
return {'complete': capture_complete, 'data': extracted_coverage_info}
@staticmethod
def create_gcda_files(extracted_coverage_info):
gcda_created = True
logger.debug("Generating gcda files")
for filename, hexdump_val in extracted_coverage_info.items():
# if kobject_hash is given for coverage gcovr fails
# hence skipping it problem only in gcovr v4.1
if "kobject_hash" in filename:
filename = (filename[:-4]) + "gcno"
try:
os.remove(filename)
except Exception:
pass
continue
try:
with open(filename, 'wb') as fp:
fp.write(bytes.fromhex(hexdump_val))
except ValueError:
logger.exception("Unable to convert hex data for file: {}".format(filename))
gcda_created = False
except FileNotFoundError:
logger.exception("Unable to create gcda file: {}".format(filename))
gcda_created = False
return gcda_created
def generate(self, outdir):
coverage_completed = True
for filename in glob.glob("%s/**/handler.log" % outdir, recursive=True):
gcov_data = self.__class__.retrieve_gcov_data(filename)
capture_complete = gcov_data['complete']
extracted_coverage_info = gcov_data['data']
if capture_complete:
gcda_created = self.__class__.create_gcda_files(extracted_coverage_info)
if gcda_created:
logger.debug("Gcov data captured: {}".format(filename))
else:
logger.error("Gcov data invalid for: {}".format(filename))
coverage_completed = False
else:
logger.error("Gcov data capture incomplete: {}".format(filename))
coverage_completed = False
with open(os.path.join(outdir, "coverage.log"), "a") as coveragelog:
ret = self._generate(outdir, coveragelog)
if ret == 0:
report_log = {
"html": "HTML report generated: {}".format(os.path.join(outdir, "coverage", "index.html")),
"lcov": "LCOV report generated: {}".format(os.path.join(outdir, "coverage.info")),
"xml": "XML report generated: {}".format(os.path.join(outdir, "coverage", "coverage.xml")),
"csv": "CSV report generated: {}".format(os.path.join(outdir, "coverage", "coverage.csv")),
"txt": "TXT report generated: {}".format(os.path.join(outdir, "coverage", "coverage.txt")),
"coveralls": "Coveralls report generated: {}".format(os.path.join(outdir, "coverage", "coverage.coveralls.json")),
"sonarqube": "Sonarqube report generated: {}".format(os.path.join(outdir, "coverage", "coverage.sonarqube.xml"))
}
for r in self.output_formats.split(','):
logger.info(report_log[r])
else:
coverage_completed = False
logger.debug("All coverage data processed: {}".format(coverage_completed))
return coverage_completed
class Lcov(CoverageTool):
def __init__(self):
super().__init__()
self.ignores = []
self.output_formats = "lcov,html"
self.version = self.get_version()
def get_version(self):
try:
result = subprocess.run(['lcov', '--version'],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True, check=True)
version_output = result.stdout.strip().replace('lcov: LCOV version ', '')
return version_output
except subprocess.CalledProcessError as e:
logger.error(f"Unsable to determine lcov version: {e}")
return ""
def add_ignore_file(self, pattern):
self.ignores.append('*' + pattern + '*')
def add_ignore_directory(self, pattern):
self.ignores.append('*/' + pattern + '/*')
@staticmethod
def run_command(cmd, coveragelog):
cmd_str = " ".join(cmd)
logger.debug(f"Running {cmd_str}...")
return subprocess.call(cmd, stdout=coveragelog)
def _generate(self, outdir, coveragelog):
coveragefile = os.path.join(outdir, "coverage.info")
ztestfile = os.path.join(outdir, "ztest.info")
if self.version.startswith("2"):
branch_coverage = "branch_coverage=1"
ignore_errors = [
"--ignore-errors", "inconsistent,inconsistent",
"--ignore-errors", "negative,negative",
"--ignore-errors", "unused,unused",
"--ignore-errors", "empty,empty",
"--ignore-errors", "mismatch,mismatch"
]
else:
branch_coverage = "lcov_branch_coverage=1"
ignore_errors = []
cmd = ["lcov", "--gcov-tool", str(self.gcov_tool),
"--capture", "--directory", outdir,
"--rc", branch_coverage,
"--output-file", coveragefile]
cmd = cmd + ignore_errors
self.run_command(cmd, coveragelog)
# We want to remove tests/* and tests/ztest/test/* but save tests/ztest
cmd = ["lcov", "--gcov-tool", self.gcov_tool, "--extract",
coveragefile,
os.path.join(self.base_dir, "tests", "ztest", "*"),
"--output-file", ztestfile,
"--rc", branch_coverage]
cmd = cmd + ignore_errors
self.run_command(cmd, coveragelog)
if os.path.exists(ztestfile) and os.path.getsize(ztestfile) > 0:
cmd = ["lcov", "--gcov-tool", self.gcov_tool, "--remove",
ztestfile,
os.path.join(self.base_dir, "tests/ztest/test/*"),
"--output-file", ztestfile,
"--rc", branch_coverage]
cmd = cmd + ignore_errors
self.run_command(cmd, coveragelog)
files = [coveragefile, ztestfile]
else:
files = [coveragefile]
for i in self.ignores:
cmd = ["lcov", "--gcov-tool", self.gcov_tool, "--remove",
coveragefile, i,
"--output-file", coveragefile,
"--rc", branch_coverage]
cmd = cmd + ignore_errors
self.run_command(cmd, coveragelog)
if 'html' not in self.output_formats.split(','):
return 0
# The --ignore-errors source option is added to avoid it exiting due to
# samples/application_development/external_lib/
cmd = ["genhtml", "--legend", "--branch-coverage",
"--prefix", self.base_dir,
"-output-directory",
os.path.join(outdir, "coverage")] + files
cmd = cmd + ignore_errors
return self.run_command(cmd, coveragelog)
class Gcovr(CoverageTool):
def __init__(self):
super().__init__()
self.ignores = []
self.output_formats = "html"
def add_ignore_file(self, pattern):
self.ignores.append('.*' + pattern + '.*')
def add_ignore_directory(self, pattern):
self.ignores.append(".*/" + pattern + '/.*')
@staticmethod
def _interleave_list(prefix, list):
tuple_list = [(prefix, item) for item in list]
return [item for sublist in tuple_list for item in sublist]
@staticmethod
def _flatten_list(list):
return [a for b in list for a in b]
def _generate(self, outdir, coveragelog):
coveragefile = os.path.join(outdir, "coverage.json")
ztestfile = os.path.join(outdir, "ztest.json")
excludes = Gcovr._interleave_list("-e", self.ignores)
# Different ifdef-ed implementations of the same function should not be
# in conflict treated by GCOVR as separate objects for coverage statistics.
mode_options = ["--merge-mode-functions=separate"]
# We want to remove tests/* and tests/ztest/test/* but save tests/ztest
cmd = ["gcovr", "-r", self.base_dir,
"--gcov-ignore-parse-errors=negative_hits.warn_once_per_file",
"--gcov-executable", str(self.gcov_tool),
"-e", "tests/*"]
cmd += excludes + mode_options + ["--json", "-o", coveragefile, outdir]
cmd_str = " ".join(cmd)
logger.debug(f"Running {cmd_str}...")
subprocess.call(cmd, stdout=coveragelog)
subprocess.call(["gcovr", "-r", self.base_dir, "--gcov-executable",
self.gcov_tool, "-f", "tests/ztest", "-e",
"tests/ztest/test/*", "--json", "-o", ztestfile,
outdir] + mode_options, stdout=coveragelog)
if os.path.exists(ztestfile) and os.path.getsize(ztestfile) > 0:
files = [coveragefile, ztestfile]
else:
files = [coveragefile]
subdir = os.path.join(outdir, "coverage")
os.makedirs(subdir, exist_ok=True)
tracefiles = self._interleave_list("--add-tracefile", files)
# Convert command line argument (comma-separated list) to gcovr flags
report_options = {
"html": ["--html", os.path.join(subdir, "index.html"), "--html-details"],
"xml": ["--xml", os.path.join(subdir, "coverage.xml"), "--xml-pretty"],
"csv": ["--csv", os.path.join(subdir, "coverage.csv")],
"txt": ["--txt", os.path.join(subdir, "coverage.txt")],
"coveralls": ["--coveralls", os.path.join(subdir, "coverage.coveralls.json"), "--coveralls-pretty"],
"sonarqube": ["--sonarqube", os.path.join(subdir, "coverage.sonarqube.xml")]
}
gcovr_options = self._flatten_list([report_options[r] for r in self.output_formats.split(',')])
return subprocess.call(["gcovr", "-r", self.base_dir] + mode_options + gcovr_options + tracefiles,
stdout=coveragelog)
def run_coverage(testplan, options):
use_system_gcov = False
for plat in options.coverage_platform:
_plat = testplan.get_platform(plat)
if _plat and (_plat.type in {"native", "unit"}):
use_system_gcov = True
if not options.gcov_tool:
zephyr_sdk_gcov_tool = os.path.join(
os.environ.get("ZEPHYR_SDK_INSTALL_DIR", default=""),
"x86_64-zephyr-elf/bin/x86_64-zephyr-elf-gcov")
if os.environ.get("ZEPHYR_TOOLCHAIN_VARIANT") == "llvm":
llvm_path = os.environ.get("LLVM_TOOLCHAIN_PATH")
if llvm_path is not None:
llvm_path = os.path.join(llvm_path, "bin")
llvm_cov = shutil.which("llvm-cov", path=llvm_path)
llvm_cov_ext = pathlib.Path(llvm_cov).suffix
gcov_lnk = os.path.join(options.outdir, f"gcov{llvm_cov_ext}")
try:
os.symlink(llvm_cov, gcov_lnk)
except OSError:
shutil.copy(llvm_cov, gcov_lnk)
options.gcov_tool = gcov_lnk
elif use_system_gcov:
options.gcov_tool = "gcov"
elif os.path.exists(zephyr_sdk_gcov_tool):
options.gcov_tool = zephyr_sdk_gcov_tool
logger.info("Generating coverage files...")
coverage_tool = CoverageTool.factory(options.coverage_tool)
coverage_tool.gcov_tool = options.gcov_tool
coverage_tool.base_dir = os.path.abspath(options.coverage_basedir)
# Apply output format default
if options.coverage_formats is not None:
coverage_tool.output_formats = options.coverage_formats
coverage_tool.add_ignore_file('generated')
coverage_tool.add_ignore_directory('tests')
coverage_tool.add_ignore_directory('samples')
coverage_completed = coverage_tool.generate(options.outdir)
return coverage_completed