blob: 062c2d98ce373511d95c79e7b755ca202605d676 [file] [log] [blame]
#!/usr/bin/env python
#
# Copyright 2018 The Chromium Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
# Copied from chromium tools/code_coverage/ and modified for goma client.
"""This script helps to generate code coverage report.
It uses Clang Source-based Code Coverage -
https://clang.llvm.org/docs/SourceBasedCodeCoverage.html
In order to generate code coverage report, you need to first add
"use_clang_coverage=true" GN flag to args.gn file in your build output
directory (e.g. out/Coverage).
* Example usage:
gn gen out/Coverage --args='use_clang_coverage=true'
gclient runhooks
python tools/code_coverage/coverage.py -b out/Coverage -o out/report \\
-f base/ -f client/ -f lib/
The command above builds all targets and then runs unit tests. The coverage
report is filtered to include only files and sub-directories under base/,
client/ and lib/.
For more options, please refer to tools/code_coverage/coverage.py -h.
"""
from __future__ import print_function
import argparse
from collections import defaultdict
import json
import logging
import os
import subprocess
import sys
import urllib2
# The root directory of the checkout.
SRC_ROOT_DIR = os.path.abspath(
os.path.join(os.path.dirname(__file__), os.path.pardir, os.path.pardir))
sys.path.append(os.path.join(SRC_ROOT_DIR, 'tools', 'clang', 'scripts'))
import update as clang_update
sys.path.append(os.path.join(SRC_ROOT_DIR, 'build'))
import run_unittest
# this script uses jinja2. Also append markupsafe to sys path because jinja2
# depends on markupsafe.
sys.path.append(os.path.join(SRC_ROOT_DIR, 'third_party', 'jinja2'))
sys.path.append(os.path.join(SRC_ROOT_DIR, 'third_party', 'markupsafe'))
import jinja2
# Absolute path to the code coverage tools binary.
LLVM_BUILD_DIR = clang_update.LLVM_BUILD_DIR
LLVM_BIN_DIR = os.path.join(LLVM_BUILD_DIR, 'bin')
LLVM_COV_PATH = os.path.join(LLVM_BIN_DIR, 'llvm-cov')
LLVM_PROFDATA_PATH = os.path.join(LLVM_BIN_DIR, 'llvm-profdata')
# Build arg required for generating code coverage data.
CLANG_COVERAGE_BUILD_ARG = 'use_clang_coverage'
# Directory to store the logs of tests executions.
LOGS_DIR_NAME = 'logs'
# Name of the file extension for profraw data files.
PROFRAW_FILE_EXTENSION = 'profraw'
# Name of the final profdata file, and this file needs to be passed to
# "llvm-cov" command in order to call "llvm-cov show" to inspect the
# line-by-line coverage of specific files.
PROFDATA_FILE_NAME = 'coverage.profdata'
# Name of the file with summary information generated by llvm-cov export.
SUMMARY_FILE_NAME = 'summary.json'
# Name of the html index files for different views.
DIRECTORY_VIEW_INDEX_FILE = 'directory_view_index.html'
FILE_VIEW_INDEX_FILE = 'file_view_index.html'
INDEX_HTML_FILE = 'index.html'
# The default name of the html coverage report for a directory.
DIRECTORY_COVERAGE_HTML_REPORT_NAME = 'report.html'
class _CoverageSummary(object):
"""Encapsulates coverage summary representation."""
def __init__(self,
regions_total=0,
regions_covered=0,
functions_total=0,
functions_covered=0,
lines_total=0,
lines_covered=0):
"""Initializes _CoverageSummary object.
Args:
regions_total: Total number of regions.
regions_covered: Exercised number of regions.
functions_total: Total number of functions.
functions_covered: Exercised number of functions.
lines_total: Total number of lines.
lines_covered: Exercised number of lines.
"""
self._summary = {
'regions': {
'total': regions_total,
'covered': regions_covered
},
'functions': {
'total': functions_total,
'covered': functions_covered
},
'lines': {
'total': lines_total,
'covered': lines_covered
}
}
def Get(self):
"""Returns summary as a dictionary.
Args:
Returns a dictionary representation of the coverage summary.
"""
return self._summary
def AddSummary(self, other_summary):
"""Adds another summary to this one element-wise.
Args:
other_summary: A _CoverageSummary object to add to this one.
"""
for feature in self._summary:
self._summary[feature]['total'] += other_summary.Get()[feature]['total']
self._summary[feature]['covered'] += other_summary.Get()[feature][
'covered']
class _CoverageReportHtmlGenerator(object):
"""Encapsulates coverage html report generation.
The generated html has a table that contains links to other coverage reports.
"""
def __init__(self, output_path, output_dir):
"""Initializes _CoverageReportHtmlGenerator object.
Args:
output_path: Path to the html report that will be generated.
output_dir: The output directory.
"""
css_file_name = 'style.css'
css_absolute_path = os.path.join(output_dir, css_file_name)
assert os.path.isfile(css_absolute_path), (
'css file doesn\'t exit. Command "llvm-cov show -format=html" was not'
'called correctly, a css file is expected to be generated at: "%s".' %
css_absolute_path)
self._css_absolute_path = css_absolute_path
self._output_path = output_path
self._output_dir = output_dir
self._table_entries = []
self._total_entry = {}
source_dir = os.path.dirname(os.path.realpath(__file__))
template_dir = os.path.join(source_dir, 'html_templates')
jinja_env = jinja2.Environment(
loader=jinja2.FileSystemLoader(template_dir), trim_blocks=True)
self._header_template = jinja_env.get_template('header.html')
self._table_template = jinja_env.get_template('table.html')
self._footer_template = jinja_env.get_template('footer.html')
self._style_overrides = open(
os.path.join(source_dir, 'static', 'css', 'style.css')).read()
def AddLinkToAnotherReport(self, html_report_path, name, summary):
"""Adds a link to another html report in this report.
The link to be added is assumed to be an entry in this directory.
Args:
html_report_path: Path to the report that the added link points to.
name: Display name of the link.
summary: Coverage summary of the report to be linked.
"""
# Use relative paths instead of absolute paths to make the generated reports
# portable.
html_report_relative_path = _GetRelativePathToDirectoryOfFile(
html_report_path, self._output_path)
table_entry = self._CreateTableEntryFromCoverageSummary(
summary, html_report_relative_path, name,
os.path.basename(html_report_path) ==
DIRECTORY_COVERAGE_HTML_REPORT_NAME)
self._table_entries.append(table_entry)
def CreateTotalsEntry(self, summary):
"""Creates an entry corresponds to the 'Totals' row in the html report.
Args:
summary: Coverage summary of the 'Totals' row.
"""
self._total_entry = self._CreateTableEntryFromCoverageSummary(summary)
def _CreateTableEntryFromCoverageSummary(self,
summary,
href=None,
name=None,
is_dir=None):
"""Creates an entry to display in the html report.
Args:
summary: Coverage summary of the entry.
href: Link to the entry.
name: Display name of the entry.
is_dir: Whether this entry reresents a directory or not.
Returns:
An entry that will be rendered to the html reports.
"""
assert (href is None and name is None and is_dir is None) or (
href is not None and name is not None and is_dir is not None), (
'The only scenario when href or name or is_dir can be None is when '
'creating an entry for the Totals row, and in that case, all three '
'attributes must be None.')
entry = {}
if href is not None:
entry['href'] = href
if name is not None:
entry['name'] = name
if is_dir is not None:
entry['is_dir'] = is_dir
summary_dict = summary.Get()
for feature in summary_dict:
if summary_dict[feature]['total'] == 0:
percentage = 0.0
else:
percentage = float(summary_dict[feature]
['covered']) / summary_dict[feature]['total'] * 100
color_class = self._GetColorClass(percentage)
entry[feature] = {
'total': summary_dict[feature]['total'],
'covered': summary_dict[feature]['covered'],
'percentage': '{:6.2f}'.format(percentage),
'color_class': color_class
}
return entry
def _GetColorClass(self, percentage):
"""Returns the css color class based on coverage percentage.
Args:
percentable: Coverage percentage.
Returns:
A string representating the color of the entry to display.
"""
if percentage >= 0 and percentage < 80:
return 'red'
if percentage >= 80 and percentage < 100:
return 'yellow'
if percentage == 100:
return 'green'
assert False, 'Invalid coverage percentage: "%d".' % percentage
def WriteHtmlCoverageReport(self):
"""Writes html coverage report.
In the report, sub-directories are displayed before files and within each
category, entries are sorted alphabetically.
"""
def EntryCmp(left, right):
"""Compare function for table entries."""
if left['is_dir'] != right['is_dir']:
return -1 if left['is_dir'] == True else 1
return -1 if left['name'] < right['name'] else 1
self._table_entries = sorted(self._table_entries, cmp=EntryCmp)
css_path = os.path.join(self._output_dir, 'style.css')
directory_view_path = _GetDirectoryViewPath(self._output_dir)
directory_view_href = _GetRelativePathToDirectoryOfFile(
directory_view_path, self._output_path)
file_view_path = _GetFileViewPath(self._output_dir)
file_view_href = _GetRelativePathToDirectoryOfFile(file_view_path,
self._output_path)
html_header = self._header_template.render(
css_path=_GetRelativePathToDirectoryOfFile(css_path, self._output_path),
directory_view_href=directory_view_href,
file_view_href=file_view_href,
style_overrides=self._style_overrides)
html_table = self._table_template.render(
entries=self._table_entries, total_entry=self._total_entry)
html_footer = self._footer_template.render()
with open(self._output_path, 'w') as html_file:
html_file.write(html_header + html_table + html_footer)
def _ConfigureLLVMCoverageTools():
"""Configures llvm coverage tools."""
DownloadCoverageToolsIfNeeded()
coverage_tools_exist = (
os.path.isfile(LLVM_COV_PATH) and os.path.isfile(LLVM_PROFDATA_PATH))
if not coverage_tools_exist:
raise Exception(
'Cannot find coverage tools, please make sure both \'%s\' and \'%s\' '
'exist.' % (LLVM_COV_PATH, LLVM_PROFDATA_PATH))
def _ConfigureLogging(args):
"""Configures logging settings for later use."""
log_level = logging.DEBUG if args.verbose else logging.INFO
log_format = '[%(asctime)s %(levelname)s] %(message)s'
logging.basicConfig(level=log_level, format=log_format)
def _GetHostPlatform():
"""Returns the host platform."""
if sys.platform == 'win32' or sys.platform == 'cygwin':
return 'win'
if sys.platform.startswith('linux'):
return 'linux'
else:
assert sys.platform == 'darwin'
return 'mac'
def DownloadCoverageToolsIfNeeded():
"""Temporary solution to download llvm-profdata and llvm-cov tools."""
def _GetRevisionFromStampFile(stamp_file_path):
"""Returns a pair of revision number by reading the build stamp file.
Args:
stamp_file_path: A path the build stamp file created by
tools/clang/scripts/update.py.
Returns:
A pair of integers represeting the main and sub revision respectively.
"""
if not os.path.isfile(stamp_file_path):
return 0, 0
with open(stamp_file_path) as stamp_file:
stamp_file_line = stamp_file.readline()
if ',' in stamp_file_line:
package_version = stamp_file_line.rstrip().split(',')[0]
else:
package_version = stamp_file_line.rstrip()
clang_revision_str, clang_sub_revision_str = package_version.split('-')
return int(clang_revision_str), int(clang_sub_revision_str)
host_platform = _GetHostPlatform()
clang_revision, clang_sub_revision = _GetRevisionFromStampFile(
clang_update.STAMP_FILE)
coverage_revision_stamp_file = os.path.join(
os.path.dirname(clang_update.STAMP_FILE), 'cr_coverage_revision')
coverage_revision, coverage_sub_revision = _GetRevisionFromStampFile(
coverage_revision_stamp_file)
has_coverage_tools = (
os.path.isfile(LLVM_COV_PATH) and os.path.isfile(LLVM_PROFDATA_PATH))
if (has_coverage_tools and coverage_revision == clang_revision and
coverage_sub_revision == clang_sub_revision):
# LLVM coverage tools are up to date, bail out.
return
package_version = '%d-%d' % (clang_revision, clang_sub_revision)
coverage_tools_file = 'llvm-code-coverage-%s.tgz' % package_version
# The code bellow follows the code from tools/clang/scripts/update.py.
if host_platform == 'mac':
coverage_tools_url = clang_update.CDS_URL + '/Mac/' + coverage_tools_file
elif host_platform == 'linux':
coverage_tools_url = (
clang_update.CDS_URL + '/Linux_x64/' + coverage_tools_file)
else:
assert host_platform == 'win'
coverage_tools_url = clang_update.CDS_URL + '/Win/' + coverage_tools_file
try:
clang_update.DownloadAndUnpack(coverage_tools_url,
clang_update.LLVM_BUILD_DIR)
with open(coverage_revision_stamp_file, 'w') as file_handle:
file_handle.write('%s,%s' % (package_version, host_platform))
file_handle.write('\n')
except urllib2.URLError:
raise Exception(
'Failed to download coverage tools: %s.' % coverage_tools_url)
def _GetLogsDirectoryPath(output_dir):
"""Path to the logs directory.
Args:
output_dir: The output directory.
Returns:
A path to the logs directory.
"""
return os.path.join(output_dir, LOGS_DIR_NAME)
def _GetProfdataFilePath(output_dir):
"""Path to the resulting .profdata file.
Args:
output_dir: The output directory.
Returns:
A path to the resulting .profdata file.
"""
return os.path.join(output_dir, PROFDATA_FILE_NAME)
def _GetSummaryFilePath(output_dir):
"""The JSON file that contains coverage summary written by llvm-cov export.
Args:
output_dir: The output directory.
Returns:
A path to the coverage summary file.
"""
return os.path.join(output_dir, SUMMARY_FILE_NAME)
def _GetDirectoryViewPath(output_dir):
"""Path to the HTML file for the directory view.
Args:
output_dir: The output directory.
Returns:
A path the the directory view index file.
"""
return os.path.join(output_dir, DIRECTORY_VIEW_INDEX_FILE)
def _GetFileViewPath(output_dir):
"""Path to the HTML file for the file view.
Args:
output_dir: The output directory.
Returns:
A path to the file view index file.
"""
return os.path.join(output_dir, FILE_VIEW_INDEX_FILE)
def _GetHtmlIndexPath(output_dir):
"""Path to the main HTML index file.
Args:
output_dir: The output directory.
Returns:
A path to the index file.
"""
return os.path.join(output_dir, INDEX_HTML_FILE)
def _GetCoverageHtmlReportPathForFile(file_path, output_dir):
"""Given a file path, returns the corresponding html report path.
Args:
file_path: Path to the file to get html report for.
output_dir: The output directory.
Returns:
A path to the html report for the provided file.
"""
assert os.path.isfile(file_path), '"%s" is not a file.' % file_path
html_report_path = '%s.html' % _GetFullPath(file_path)
# '+' is used instead of os.path.join because both of them are absolute paths
# and os.path.join ignores the first path.
return os.path.join(output_dir, 'coverage') + html_report_path
def _GetCoverageHtmlReportPathForDirectory(dir_path, output_dir):
"""Given a directory path, returns the corresponding html report path.
Args:
dir_path: Path to the directory to get html report for.
output_dir: The output directory.
Returns:
A path to the html report for the provided directory.
"""
assert os.path.isdir(dir_path), '"%s" is not a directory.' % dir_path
html_report_path = os.path.join(
_GetFullPath(dir_path), DIRECTORY_COVERAGE_HTML_REPORT_NAME)
# '+' is used instead of os.path.join because both of them are absolute paths
# and os.path.join ignores the first path.
return os.path.join(output_dir, 'coverage') + html_report_path
def _GetRelativePathToDirectoryOfFile(target_path, base_path):
"""Returns a target path relative to the directory of base_path.
This method requires base_path to be a file, otherwise, one should call
os.path.relpath directly.
Args:
target_path: The target path to get relative version for.
base_path: The base path of the relative reference.
"""
assert os.path.dirname(base_path) != base_path, (
'Base path: "%s" is a directory, please call os.path.relpath directly.' %
base_path)
base_dir = os.path.dirname(base_path)
return os.path.relpath(target_path, base_dir)
def _BuildAllTargets(build_dir):
"""Builds target with Clang coverage instrumentation.
Args:
build_dir: The build directory.
"""
logging.info('Building all targets.')
subprocess_cmd = ['ninja', '-C', build_dir]
subprocess.check_call(subprocess_cmd)
logging.debug('Finished building.')
def _ValidateBuildingWithClangCoverage(build_dir):
"""Asserts that targets are built with Clang coverage enabled."""
cmd = ['gn', 'args', build_dir, '--list', '--json']
build_args = json.loads(subprocess.check_output(cmd))
for build_arg in build_args:
if (build_arg.get('name') == CLANG_COVERAGE_BUILD_ARG and
build_arg.get('current', {}).get('value') == 'true'):
return
raise Exception(
'\'%s = true\' is required in args.gn.' % CLANG_COVERAGE_BUILD_ARG)
def _GetGTestTargets():
"""Gets all gtest targets from the run_unittest script.
Returns:
A list of gtest targets that are runnable on the current platform.
"""
targets = []
for test_case in run_unittest.TEST_DIRS:
targets.extend(run_unittest.TestNames(test_case))
# goma-make_unittest is a python script instead of gtest, so exclude it.
targets.remove('goma-make_unittest')
return targets
def _GetTargetProfDataPathsByExecutingBinaries(targets, build_dir, output_dir):
"""Runs test binaries and returns the relative paths to the profdata files.
Each target corresponds to a profdata path in the returned list.
Args:
targets: A list of targets to generate code coverage for.
build_dir: The build directory.
output_dir: The output directory.
Returns:
A list of relative paths to the generated profdata files.
"""
logs_dir_path = _GetLogsDirectoryPath(output_dir)
logging.info(
('Executing following test binaries, and outputs are redirected to "%s":'
'\n\n%s.'), logs_dir_path, sorted(targets))
# Remove existing profraw data files.
for file_or_dir in os.listdir(output_dir):
if (os.path.isfile(os.path.join(output_dir, file_or_dir)) and
file_or_dir.endswith(PROFRAW_FILE_EXTENSION)):
os.remove(os.path.join(output_dir, file_or_dir))
# Ensure that logs directory exists.
if not os.path.isdir(logs_dir_path):
os.makedirs(logs_dir_path)
profdata_file_paths = []
# Run all test targets to generate profraw data files.
for target in targets:
_ExecuteTestBinary(target, build_dir, output_dir)
profraw_file_paths = []
for file_or_dir in os.listdir(output_dir):
if (os.path.isfile(os.path.join(output_dir, file_or_dir)) and
file_or_dir.endswith(PROFRAW_FILE_EXTENSION)):
profraw_file_paths.append(os.path.join(output_dir, file_or_dir))
if not profraw_file_paths:
raise Exception(
'Running target "%s" failed to generate any profraw data file, '
'please make sure the binary exists, is properly instrumented and '
'does not crash.' % target)
profdata_file_path = _CreateTargetProfDataFileFromProfRawFiles(
target, profraw_file_paths, output_dir)
# Remove profraw files now so that they are not used in next iteration.
for profraw_file_path in profraw_file_paths:
os.remove(profraw_file_path)
if not profdata_file_path:
raise Exception('Failed to merge target "%s" profraw files.' % target)
profdata_file_paths.append(profdata_file_path)
logging.info('Finished executing the test binaries.')
return profdata_file_paths
def _ExecuteTestBinary(target, build_dir, output_dir):
"""Runs a single binary and generates a profdata file.
Args:
target: test target to execute.
build_dir: The build directory.
output_dir: The output directory.
"""
# Per Clang "Source-based Code Coverage" doc:
#
# "%p" expands out to the process ID. It's not used by this scripts due to:
# 1) If a target program spawns too many processess, it may exhaust all disk
# space available.
# 2) If a target binary uses shared libraries, coverage profile data for them
# will be missing, resulting in incomplete coverage reports.
#
# "%Nm" expands out to the instrumented binary's signature. When this pattern
# is specified, the runtime creates a pool of N raw profiles which are used
# for on-line profile merging. The runtime takes care of selecting a raw
# profile from the pool, locking it, and updating it before the program exits.
# N must be between 1 and 9. The merge pool specifier can only occur once per
# filename pattern.
#
# "%4m" is chosen as it creates some level of parallelism,
# but it's not too big to consume too much computing resource or disk space.
output_file_name = target + '_output.log'
output_file_path = os.path.join(
_GetLogsDirectoryPath(output_dir), output_file_name)
logging.debug('Running test binary: "%s", the output is redirected to "%s".',
target, output_file_path)
profile_pattern_string = '%4m'
expected_profraw_file_name = '%s.%s.%s' % (target, profile_pattern_string,
PROFRAW_FILE_EXTENSION)
expected_profraw_file_path = os.path.join(output_dir,
expected_profraw_file_name)
cmd = [os.path.join(build_dir, target)]
try:
# Some targets or tests may write into stderr, redirect it as well.
with open(output_file_path, 'wb') as output_file_handle:
subprocess.check_call(
cmd,
stdout=output_file_handle,
stderr=subprocess.STDOUT,
env=_GetEnvironmentVars(expected_profraw_file_path))
except subprocess.CalledProcessError as e:
logging.warning('Command: "%s" exited with non-zero return code.', cmd)
def _GetEnvironmentVars(expected_profraw_file_path):
"""Return environment vars for subprocess, given a profraw file path.
Args:
expected_profraw_file_path: Path to the profraw files to be generated.
Returns:
A copy of current environment vairiables with 'LLVM_PROFILE_FILE' inserted.
"""
env = os.environ.copy()
env.update({
'LLVM_PROFILE_FILE': expected_profraw_file_path,
})
return env
def _CreateTargetProfDataFileFromProfRawFiles(target, profraw_file_paths,
output_dir):
"""Returns a relative path to target profdata file.
Args:
profraw_file_paths: A list of relative paths to the profdata data files
that are to be merged.
output_dir: The output directory.
Returns:
A relative path to the merged coverage profdata file.
Raises:
CalledProcessError: An error occurred merging profdata files.
"""
logging.debug('Creating target profile data file.')
logging.debug('Merging target profraw files to create target profdata file.')
profdata_file_path = os.path.join(output_dir, '%s.profdata' % target)
try:
subprocess_cmd = [
LLVM_PROFDATA_PATH, 'merge', '-o', profdata_file_path, '-sparse=true'
]
subprocess_cmd.extend(profraw_file_paths)
output = subprocess.check_output(subprocess_cmd)
logging.debug('Merge output: %s', output)
except subprocess.CalledProcessError as error:
logging.error(
'Failed to merge target profraw files to create target profdata.')
raise error
logging.debug('Finished merging target profraw files.')
logging.debug('Target "%s" profile data is created as: "%s".', target,
profdata_file_path)
return profdata_file_path
def _CreateCoverageProfileDataFromTargetProfDataFiles(
target_profdata_file_paths, output_dir):
"""Returns a relative path to coverage profdata file by merging target
profdata files.
Args:
target_profdata_file_paths: A list of relative paths to the profdata data
files that are to be merged.
output_dir: The output directory.
Returns:
A relative path to the merged coverage profdata file.
Raises:
CalledProcessError: An error occurred merging profdata files.
"""
logging.debug('Creating the coverage profile data file.')
logging.debug('Merging per-target profdata files to create an indexed '
'profdata file.')
indexed_profdata_file_path = _GetProfdataFilePath(output_dir)
try:
subprocess_cmd = [
LLVM_PROFDATA_PATH, 'merge', '-o', indexed_profdata_file_path,
'-sparse=true'
]
subprocess_cmd.extend(target_profdata_file_paths)
output = subprocess.check_output(subprocess_cmd)
logging.debug('Merge output: %s', output)
except subprocess.CalledProcessError as error:
logging.error(
'Failed to merge target profdata files to create coverage profdata. %s')
raise error
for target_profdata_file_path in target_profdata_file_paths:
os.remove(target_profdata_file_path)
logging.debug('Finished merging target profdata files.')
logging.debug('Code coverage profile data is created as: "%s".',
indexed_profdata_file_path)
return indexed_profdata_file_path
def _GetBinaryPathsFromTargets(targets, build_dir):
"""Return binary paths from target names.
Args:
targets: A list of test targets to generate code coverage for.
build_dir: The build directory.
Returns:
A list of paths to the built binaries of the targets.
"""
binary_paths = []
for target in targets:
binary_path = os.path.join(build_dir, target)
if not os.path.isfile(binary_path):
raise Exception('Target binary "%s" not found in build directory.' %
os.path.basename(binary_path))
binary_paths.append(binary_path)
return binary_paths
def _GeneratePerFileLineByLineCoverageInHtml(binary_paths, profdata_file_path,
filters, ignore_filename_regex,
output_dir):
"""Generates per file line-by-line coverage in html using 'llvm-cov show'.
For a file with absolute path /a/b/x.cc, a html report is generated as:
OUTPUT_DIR/coverage/a/b/x.cc.html. An index html file is also generated as:
OUTPUT_DIR/index.html.
Args:
binary_paths: A list of paths to the instrumented binaries.
profdata_file_path: A path to the profdata file.
filters: A list of directories and files to get coverage for.
ignore_filename_regex: A regular expression to exclude certain files from
the code coverage report.
output_dir: The output directory.
"""
# llvm-cov show [options] -instr-profile PROFILE BIN [-object BIN,...]
# [[-object BIN]] [SOURCES]
# NOTE: For object files, the first one is specified as a positional argument,
# and the rest are specified as keyword argument.
logging.debug('Generating per file line by line coverage reports using '
'"llvm-cov show" command.')
subprocess_cmd = [
LLVM_COV_PATH, 'show', '-format=html',
'-output-dir={}'.format(output_dir),
'-instr-profile={}'.format(profdata_file_path), binary_paths[0]
]
subprocess_cmd.extend(
['-object=' + binary_path for binary_path in binary_paths[1:]])
subprocess_cmd.extend(['-Xdemangler', 'c++filt', '-Xdemangler', '-n'])
subprocess_cmd.extend(filters)
if ignore_filename_regex:
subprocess_cmd.append('-ignore-filename-regex=%s' % ignore_filename_regex)
subprocess.check_call(subprocess_cmd)
logging.debug('Finished running "llvm-cov show" command.')
def _GeneratePerFileCoverageSummary(binary_paths, profdata_file_path, filters,
ignore_filename_regex, output_dir):
"""Generates per file coverage summary using "llvm-cov export" command.
Args:
binary_paths: A list of paths to the instrumented binaries.
profdata_file_path: A path to the profdata file.
filters: A list of directories and files to get coverage for.
ignore_filename_regex: A regular expression to exclude certain files from
the code coverage report.
output_dir: The output directory.
Returns:
A dictionary from file path to _CoverageSummary.
"""
# llvm-cov export [options] -instr-profile PROFILE BIN [-object BIN,...]
# [[-object BIN]] [SOURCES].
# NOTE: For object files, the first one is specified as a positional argument,
# and the rest are specified as keyword argument.
logging.debug('Generating per-file code coverage summary using "llvm-cov '
'export -summary-only" command.')
subprocess_cmd = [
LLVM_COV_PATH, 'export', '-summary-only',
'-instr-profile=' + profdata_file_path, binary_paths[0]
]
subprocess_cmd.extend(
['-object=' + binary_path for binary_path in binary_paths[1:]])
subprocess_cmd.extend(filters)
if ignore_filename_regex:
subprocess_cmd.append('-ignore-filename-regex=%s' % ignore_filename_regex)
export_output = subprocess.check_output(subprocess_cmd)
# Write output on the disk to be used by code coverage bot.
with open(_GetSummaryFilePath(output_dir), 'w') as f:
f.write(export_output)
json_output = json.loads(export_output)
files_coverage_data = json_output['data'][0]['files']
per_file_coverage_summary = {}
for file_coverage_data in files_coverage_data:
file_path = file_coverage_data['filename']
summary = file_coverage_data['summary']
if summary['lines']['count'] == 0:
continue
per_file_coverage_summary[file_path] = _CoverageSummary(
regions_total=summary['regions']['count'],
regions_covered=summary['regions']['covered'],
functions_total=summary['functions']['count'],
functions_covered=summary['functions']['covered'],
lines_total=summary['lines']['count'],
lines_covered=summary['lines']['covered'])
logging.debug('Finished generating per-file code coverage summary.')
return per_file_coverage_summary
def _CalculatePerDirectoryCoverageSummary(per_file_coverage_summary):
"""Calculates per directory coverage summary.
Args:
per_file_coverage_summary: A dictionary from file path to coverage summary.
Returns:
A dictionary from directory path to coverage summary.
"""
logging.debug('Calculating per-directory coverage summary.')
per_directory_coverage_summary = defaultdict(lambda: _CoverageSummary())
for file_path in per_file_coverage_summary:
summary = per_file_coverage_summary[file_path]
parent_dir = os.path.dirname(file_path)
while True:
per_directory_coverage_summary[parent_dir].AddSummary(summary)
if parent_dir == SRC_ROOT_DIR:
break
parent_dir = os.path.dirname(parent_dir)
logging.debug('Finished calculating per-directory coverage summary.')
return per_directory_coverage_summary
def _GenerateFileViewHtmlIndexFile(per_file_coverage_summary, output_dir):
"""Generates html index file for file view.
Args:
per_file_coverage_summary: A dictionary from file path to coverage summary.
output_dir: The output directory.
"""
file_view_index_file_path = _GetFileViewPath(output_dir)
logging.debug('Generating file view html index file as: "%s".',
file_view_index_file_path)
html_generator = _CoverageReportHtmlGenerator(file_view_index_file_path,
output_dir)
totals_coverage_summary = _CoverageSummary()
for file_path in per_file_coverage_summary:
totals_coverage_summary.AddSummary(per_file_coverage_summary[file_path])
html_generator.AddLinkToAnotherReport(
_GetCoverageHtmlReportPathForFile(file_path, output_dir),
os.path.relpath(file_path, SRC_ROOT_DIR),
per_file_coverage_summary[file_path])
html_generator.CreateTotalsEntry(totals_coverage_summary)
html_generator.WriteHtmlCoverageReport()
logging.debug('Finished generating file view html index file.')
def _GeneratePerDirectoryCoverageInHtml(per_directory_coverage_summary,
per_file_coverage_summary, output_dir):
"""Generates per directory coverage breakdown in html.
Args:
per_directory_coverage_summary: A dictionary from directory to coverage
summary.
per_file_coverage_summary: A dictionary from file path to coverage summary.
output_dir: The output directory.
"""
logging.debug('Writing per-directory coverage html reports.')
for dir_path in per_directory_coverage_summary:
_GenerateCoverageInHtmlForDirectory(dir_path,
per_directory_coverage_summary,
per_file_coverage_summary, output_dir)
logging.debug('Finished writing per-directory coverage html reports.')
def _GenerateCoverageInHtmlForDirectory(dir_path,
per_directory_coverage_summary,
per_file_coverage_summary, output_dir):
"""Generates coverage html report for a single directory.
Args:
dir_path: Path to the directory to generate html report for.
per_directory_coverage_summary: A dictionary from directory to coverage
summary.
per_file_coverage_summary: A dictionary from file path to coverage summary.
output_dir: The output directory.
"""
html_generator = _CoverageReportHtmlGenerator(
_GetCoverageHtmlReportPathForDirectory(dir_path, output_dir), output_dir)
for entry_name in os.listdir(dir_path):
entry_path = os.path.normpath(os.path.join(dir_path, entry_name))
if entry_path in per_file_coverage_summary:
entry_html_report_path = _GetCoverageHtmlReportPathForFile(
entry_path, output_dir)
entry_coverage_summary = per_file_coverage_summary[entry_path]
elif entry_path in per_directory_coverage_summary:
entry_html_report_path = _GetCoverageHtmlReportPathForDirectory(
entry_path, output_dir)
entry_coverage_summary = per_directory_coverage_summary[entry_path]
else:
# Any file without executable lines shouldn't be included into the report.
# For example, OWNER and README.md files.
continue
html_generator.AddLinkToAnotherReport(entry_html_report_path,
os.path.basename(entry_path),
entry_coverage_summary)
html_generator.CreateTotalsEntry(per_directory_coverage_summary[dir_path])
html_generator.WriteHtmlCoverageReport()
def _GenerateDirectoryViewHtmlIndexFile(output_dir):
"""Generates the html index file for directory view.
Note that the index file is already generated under source root, so this file
simply redirects to it, and the reason of this extra layer is for structural
consistency with other views.
Args:
output_dir: The output directory.
"""
directory_view_index_file_path = _GetDirectoryViewPath(output_dir)
logging.debug('Generating directory view html index file as: "%s".',
directory_view_index_file_path)
src_root_html_report_path = _GetCoverageHtmlReportPathForDirectory(
SRC_ROOT_DIR, output_dir)
_WriteRedirectHtmlFile(directory_view_index_file_path,
src_root_html_report_path)
logging.debug('Finished generating directory view html index file.')
def _OverwriteHtmlReportsIndexFile(output_dir):
"""Overwrites the root index file to redirect to the default view.
Args:
output_dir: The output directory.
"""
html_index_file_path = _GetHtmlIndexPath(output_dir)
directory_view_index_file_path = _GetDirectoryViewPath(output_dir)
_WriteRedirectHtmlFile(html_index_file_path, directory_view_index_file_path)
def _WriteRedirectHtmlFile(from_html_path, to_html_path):
"""Writes a html file that redirects to another html file.
Args:
from_html_path: From which the redirect starts.
to_html_path: To which the redirect lands.
"""
to_html_relative_path = _GetRelativePathToDirectoryOfFile(
to_html_path, from_html_path)
content = ("""
<!DOCTYPE html>
<html>
<head>
<!-- HTML meta refresh URL redirection -->
<meta http-equiv="refresh" content="0; url=%s">
</head>
</html>""" % to_html_relative_path)
with open(from_html_path, 'w') as f:
f.write(content)
def _ValidateCurrentPlatformIsSupported():
"""Asserts that this script suports running on the current platform."""
current_platform = _GetHostPlatform()
if current_platform not in ['linux', 'mac']:
raise Exception('This tool is only supported on linux and mac.')
def _VerifyPathsAndReturnAbsolutes(paths):
"""Verifies that the paths exist and returns absolute versions.
Args:
paths: A list of files or directories.
Returns:
A list of absolute paths.
"""
absolute_paths = []
for path in paths:
absolute_path = _GetFullPath(path)
if not os.path.exists(absolute_path):
raise Exception('Path: "%s" doesn\'t exist.' % path)
absolute_paths.append(absolute_path)
return absolute_paths
def _GetFullPath(path):
"""Return full absolute path."""
return (os.path.abspath(
os.path.realpath(os.path.expandvars(os.path.expanduser(path)))))
def _ParseCommandArguments():
"""Adds and parses relevant arguments for tool comands.
Returns:
A dictionary representing the arguments.
"""
arg_parser = argparse.ArgumentParser()
arg_parser.usage = __doc__
arg_parser.add_argument(
'-b',
'--build-dir',
type=str,
required=True,
help='The build directory, the path needs to be relative to the root of '
'the checkout.')
arg_parser.add_argument(
'-o',
'--output-dir',
type=str,
required=True,
help='Output directory for generated artifacts.')
arg_parser.add_argument(
'-f',
'--filters',
action='append',
required=False,
help='Directories or files to get code coverage for, and all files under '
'the directories are included recursively.')
arg_parser.add_argument(
'-i',
'--ignore-filename-regex',
type=str,
help='Skip source code files with file paths that match the given '
'regular expression. For example, use -i=\'.*/out/.*|.*/third_party/.*\' '
'to exclude files in third_party/ and out/ folders from the report.')
arg_parser.add_argument(
'-v',
'--verbose',
action='store_true',
help='Prints additional output for diagnostics.')
args = arg_parser.parse_args()
return args
def Main():
"""Execute tool commands."""
_ValidateCurrentPlatformIsSupported()
# Change directory to source root to aid in relative paths calculations.
os.chdir(SRC_ROOT_DIR)
args = _ParseCommandArguments()
_ConfigureLogging(args)
_ConfigureLLVMCoverageTools()
build_dir = _GetFullPath(args.build_dir)
if not os.path.isdir(build_dir):
raise Exception('Build directory: "%s" doesn\'t exist. '
'Please run "gn gen" to generate.' % build_dir)
output_dir = _GetFullPath(args.output_dir)
if not os.path.isdir(output_dir):
os.makedirs(output_dir)
absolute_filter_paths = []
if args.filters:
absolute_filter_paths = _VerifyPathsAndReturnAbsolutes(args.filters)
_ValidateBuildingWithClangCoverage(build_dir)
_BuildAllTargets(build_dir)
# Get all test targets from the run_unittest script.
targets = _GetGTestTargets()
target_profdata_file_paths = _GetTargetProfDataPathsByExecutingBinaries(
targets, build_dir, output_dir)
coverage_profdata_file_path = (
_CreateCoverageProfileDataFromTargetProfDataFiles(
target_profdata_file_paths, output_dir))
binary_paths = _GetBinaryPathsFromTargets(targets, build_dir)
logging.info('Generating code coverage report in html (this can take a while '
'depending on size of target!).')
per_file_coverage_summary = _GeneratePerFileCoverageSummary(
binary_paths, coverage_profdata_file_path, absolute_filter_paths,
args.ignore_filename_regex, output_dir)
_GeneratePerFileLineByLineCoverageInHtml(
binary_paths, coverage_profdata_file_path, absolute_filter_paths,
args.ignore_filename_regex, output_dir)
_GenerateFileViewHtmlIndexFile(per_file_coverage_summary, output_dir)
per_directory_coverage_summary = _CalculatePerDirectoryCoverageSummary(
per_file_coverage_summary)
_GeneratePerDirectoryCoverageInHtml(per_directory_coverage_summary,
per_file_coverage_summary, output_dir)
_GenerateDirectoryViewHtmlIndexFile(output_dir)
# The default index file is generated only for the list of source files, needs
# to overwrite it to display per directory coverage view by default.
_OverwriteHtmlReportsIndexFile(output_dir)
html_index_file_path = 'file://' + _GetFullPath(_GetHtmlIndexPath(output_dir))
logging.info('Index file for html report is generated as: "%s".',
html_index_file_path)
if __name__ == '__main__':
sys.exit(Main())