summaryrefslogtreecommitdiffstats
path: root/test/tools/gcov_coveralls.py
diff options
context:
space:
mode:
Diffstat (limited to 'test/tools/gcov_coveralls.py')
-rwxr-xr-xtest/tools/gcov_coveralls.py206
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()