diff options
Diffstat (limited to 'test/tools/gcov_coveralls.py')
-rwxr-xr-x | test/tools/gcov_coveralls.py | 206 |
1 files changed, 206 insertions, 0 deletions
diff --git a/test/tools/gcov_coveralls.py b/test/tools/gcov_coveralls.py new file mode 100755 index 0000000..dcbd93f --- /dev/null +++ b/test/tools/gcov_coveralls.py @@ -0,0 +1,206 @@ +#!/usr/bin/env python3 +""" +Script to save coverage info for C source files in JSON for coveralls.io + +When C code compiled with --coverage flag, for each object files *.gcno is +generated, it contains information to reconstruct the basic block graphs and +assign source line numbers to blocks + +When binary executed *.gcda file is written on exit, with same base name as +corresponding *.gcno file. It contains some summary information, counters, e.t.c. + +gcov(1) utility can be used to get information from *.gcda file and write text +reports to *.gocov file (one file for each source file from which object was compiled). + +The script finds *.gcno files, uses gcov to generate *.gcov files, parses them +and accumulates statistics for all source files. + +This script was written with quite a few assumptions: + + * Code was build using absolute path to source directory (and absolute path + stored in object file debug symbols). + + * Current directory is writable and there is no useful *.gcov files in it + (because they will be deleted). + + * Object file has same base name as *.gcno file (e. g. foo.c.gcno and foo.c.o). + This is the case for cmake builds, but probably not for other build systems + + * Source file names contain only ASCII characters. +""" + +import argparse +from collections import defaultdict +from glob import glob +import hashlib +import json +import os +from os.path import isabs, join, normpath, relpath +import os.path +import subprocess +import sys + + +def warn(*args, **kwargs): + print(*args, file=sys.stderr, **kwargs) + + +def parse_gcov_file(gcov_file): + """Parses the content of .gcov file written by gcov -i + + Returns: + str: Source file name + dict: coverage info { line_number: hits } + """ + count = {} + with open(gcov_file) as fh: + for line in fh: + tag, value = line.split(':') + if tag == 'file': + src_file = value.rstrip() + elif tag == 'lcount': + line_num, exec_count = value.split(',') + count[int(line_num)] = int(exec_count) + + return src_file, count + + +def run_gcov(filename, coverage, args): + """ * run gcov on given file + * parse generated .gcov files and update coverage structure + * store source file md5 (if not yet stored) + * delete .gcov files + """ + if args.verbose: + warn("calling:", 'gcov', '-i', filename) + stdout = None + else: + # gcov is noisy and don't have quit flag so redirect stdout to /dev/null + stdout = subprocess.DEVNULL + + subprocess.check_call(['gcov', '-i', filename], stdout=stdout) + + for gcov_file in glob('*.gcov'): + if args.verbose: + warn('parsing', gcov_file) + src_file, count = parse_gcov_file(gcov_file) + os.remove(gcov_file) + + if src_file not in coverage: + coverage[src_file] = defaultdict(int, count) + else: + # sum execution counts + for line, exe_cnt in count.items(): + coverage[src_file][line] += exe_cnt + + +def main(): + parser = argparse.ArgumentParser( + description='Save gcov coverage results in JSON file for coveralls.io.') + parser.add_argument( + '-v', + '--verbose', + action="store_true", + help='Display additional information and gcov command output.') + parser.add_argument( + '-e', + '--exclude', + action='append', + metavar='DIR', + help= + ("Don't look for .gcno/.gcda files in this directories (repeat option to skip several directories). " + "Path is relative to the directory where script was started, e. g. '.git'")) + parser.add_argument( + '-p', + '--prefix', + action='append', + help= + ("Strip this prefix from absolute path to source file. " + "If this option is provided, then only files with given prefixes in absolute path " + "will be added to coverage (option can be repeated).")) + parser.add_argument( + '--out', + type=argparse.FileType('w'), + required=True, + metavar='FILE', + help='Save JSON payload to this file') + args = parser.parse_args() + + # ensure that there is no unrelated .gcov files in current directory + for gcov_file in glob('*.gcov'): + os.remove(gcov_file) + warn("Warning: {} deleted".format(gcov_file)) + + # dict { src_file_name: {line1: exec_count1, line2: exec_count2, ...} } + coverage = {} + + # find . -name '*.gcno' (respecting args.exclude) + for root, dirs, files in os.walk('.'): + for f in files: + # Usually gcov called with a source file as an argument, but this + # name used only to find .gcno and .gcda files. To find source + # file information from debug symbols is used. So we can call gcov + # on .gcno file. + if f.endswith('.gcno'): + run_gcov(join(root, f), coverage, args) + + # don't look into excluded dirs + for subdir in dirs: + # path relative to start dir + path = normpath(join(root, subdir)) + if path in args.exclude: + if args.verbose: + warn('directory "{}" excluded'.format(path)) + dirs.remove(subdir) + + # prepare JSON pyload for coveralls.io API + # https://docs.coveralls.io/api-introduction + coveralls_data = {'source_files': []} + + for src_file in coverage: + # filter by prefix and save path with stripped prefix + src_file_rel = src_file + if args.prefix and isabs(src_file): + for prefix in args.prefix: + if src_file.startswith(prefix): + src_file_rel = relpath(src_file, start=prefix) + break + else: + # skip file outside given prefixes + # it can be e. g. library include file + if args.verbose: + warn('file "{}" is not matched by prefix, skipping'.format(src_file)) + continue + + try: + with open(src_file, mode='rb') as fh: + line_count = sum(1 for _ in fh) + fh.seek(0) + md5 = hashlib.md5(fh.read()).hexdigest() + except OSError as err: + # skip files for which source file is not available + warn(err, 'not adding to coverage') + continue + + coverage_array = [None] * line_count + + for line_num, exe_cnt in coverage[src_file].items(): + # item at index 0 representing the coverage for line 1 of the source code + assert 1 <= line_num <= line_count + coverage_array[line_num - 1] = exe_cnt + + coveralls_data['source_files'].append({ + 'name': src_file_rel, + 'coverage': coverage_array, + 'source_digest': md5 + }) + + args.out.write(json.dumps(coveralls_data)) + + if args.verbose: + warn('Coverage for {} source files was written'.format( + len(coveralls_data['source_files']))) + + +if __name__ == '__main__': + main() |