#!/usr/bin/env python3 # # Parse and report coverage info from .info files generated by lcov # import os import glob import csv import re import collections as co import bisect as b INFO_PATHS = ['tests/*.toml.info'] def collect(paths, **args): file = None funcs = [] lines = co.defaultdict(lambda: 0) pattern = re.compile( '^(?PSF:/?(?P.*))$' '|^(?PFN:(?P[0-9]*),(?P.*))$' '|^(?PDA:(?P[0-9]*),(?P[0-9]*))$') for path in paths: with open(path) as f: for line in f: m = pattern.match(line) if m and m.group('file'): file = m.group('file_name') elif m and file and m.group('func'): funcs.append((file, int(m.group('func_lineno')), m.group('func_name'))) elif m and file and m.group('line'): lines[(file, int(m.group('line_lineno')))] += ( int(m.group('line_hits'))) # map line numbers to functions funcs.sort() def func_from_lineno(file, lineno): i = b.bisect(funcs, (file, lineno)) if i and funcs[i-1][0] == file: return funcs[i-1][2] else: return None # reduce to function info reduced_funcs = co.defaultdict(lambda: (0, 0)) for (file, line_lineno), line_hits in lines.items(): func = func_from_lineno(file, line_lineno) if not func: continue hits, count = reduced_funcs[(file, func)] reduced_funcs[(file, func)] = (hits + (line_hits > 0), count + 1) results = [] for (file, func), (hits, count) in reduced_funcs.items(): # discard internal/testing functions (test_* injected with # internal testing) if not args.get('everything'): if func.startswith('__') or func.startswith('test_'): continue # discard .8449 suffixes created by optimizer func = re.sub('\.[0-9]+', '', func) results.append((file, func, hits, count)) return results def main(**args): def openio(path, mode='r'): if path == '-': if 'r' in mode: return os.fdopen(os.dup(sys.stdin.fileno()), 'r') else: return os.fdopen(os.dup(sys.stdout.fileno()), 'w') else: return open(path, mode) # find coverage if not args.get('use'): # find *.info files paths = [] for path in args['info_paths']: if os.path.isdir(path): path = path + '/*.gcov' for path in glob.glob(path): paths.append(path) if not paths: print('no .info files found in %r?' % args['info_paths']) sys.exit(-1) results = collect(paths, **args) else: with openio(args['use']) as f: r = csv.DictReader(f) results = [ ( result['file'], result['name'], int(result['coverage_hits']), int(result['coverage_count'])) for result in r if result.get('coverage_hits') not in {None, ''} if result.get('coverage_count') not in {None, ''}] total_hits, total_count = 0, 0 for _, _, hits, count in results: total_hits += hits total_count += count # find previous results? if args.get('diff'): try: with openio(args['diff']) as f: r = csv.DictReader(f) prev_results = [ ( result['file'], result['name'], int(result['coverage_hits']), int(result['coverage_count'])) for result in r if result.get('coverage_hits') not in {None, ''} if result.get('coverage_count') not in {None, ''}] except FileNotFoundError: prev_results = [] prev_total_hits, prev_total_count = 0, 0 for _, _, hits, count in prev_results: prev_total_hits += hits prev_total_count += count # write results to CSV if args.get('output'): merged_results = co.defaultdict(lambda: {}) other_fields = [] # merge? if args.get('merge'): try: with openio(args['merge']) as f: r = csv.DictReader(f) for result in r: file = result.pop('file', '') func = result.pop('name', '') result.pop('coverage_hits', None) result.pop('coverage_count', None) merged_results[(file, func)] = result other_fields = result.keys() except FileNotFoundError: pass for file, func, hits, count in results: merged_results[(file, func)]['coverage_hits'] = hits merged_results[(file, func)]['coverage_count'] = count with openio(args['output'], 'w') as f: w = csv.DictWriter(f, ['file', 'name', *other_fields, 'coverage_hits', 'coverage_count']) w.writeheader() for (file, func), result in sorted(merged_results.items()): w.writerow({'file': file, 'name': func, **result}) # print results def dedup_entries(results, by='name'): entries = co.defaultdict(lambda: (0, 0)) for file, func, hits, count in results: entry = (file if by == 'file' else func) entry_hits, entry_count = entries[entry] entries[entry] = (entry_hits + hits, entry_count + count) return entries def diff_entries(olds, news): diff = co.defaultdict(lambda: (0, 0, 0, 0, 0, 0, 0)) for name, (new_hits, new_count) in news.items(): diff[name] = ( 0, 0, new_hits, new_count, new_hits, new_count, (new_hits/new_count if new_count else 1.0) - 1.0) for name, (old_hits, old_count) in olds.items(): _, _, new_hits, new_count, _, _, _ = diff[name] diff[name] = ( old_hits, old_count, new_hits, new_count, new_hits-old_hits, new_count-old_count, ((new_hits/new_count if new_count else 1.0) - (old_hits/old_count if old_count else 1.0))) return diff def sorted_entries(entries): if args.get('coverage_sort'): return sorted(entries, key=lambda x: (-(x[1][0]/x[1][1] if x[1][1] else -1), x)) elif args.get('reverse_coverage_sort'): return sorted(entries, key=lambda x: (+(x[1][0]/x[1][1] if x[1][1] else -1), x)) else: return sorted(entries) def sorted_diff_entries(entries): if args.get('coverage_sort'): return sorted(entries, key=lambda x: (-(x[1][2]/x[1][3] if x[1][3] else -1), x)) elif args.get('reverse_coverage_sort'): return sorted(entries, key=lambda x: (+(x[1][2]/x[1][3] if x[1][3] else -1), x)) else: return sorted(entries, key=lambda x: (-x[1][6], x)) def print_header(by=''): if not args.get('diff'): print('%-36s %19s' % (by, 'hits/line')) else: print('%-36s %19s %19s %11s' % (by, 'old', 'new', 'diff')) def print_entry(name, hits, count): print("%-36s %11s %7s" % (name, '%d/%d' % (hits, count) if count else '-', '%.1f%%' % (100*hits/count) if count else '-')) def print_diff_entry(name, old_hits, old_count, new_hits, new_count, diff_hits, diff_count, ratio): print("%-36s %11s %7s %11s %7s %11s%s" % (name, '%d/%d' % (old_hits, old_count) if old_count else '-', '%.1f%%' % (100*old_hits/old_count) if old_count else '-', '%d/%d' % (new_hits, new_count) if new_count else '-', '%.1f%%' % (100*new_hits/new_count) if new_count else '-', '%+d/%+d' % (diff_hits, diff_count), ' (%+.1f%%)' % (100*ratio) if ratio else '')) def print_entries(by='name'): entries = dedup_entries(results, by=by) if not args.get('diff'): print_header(by=by) for name, (hits, count) in sorted_entries(entries.items()): print_entry(name, hits, count) else: prev_entries = dedup_entries(prev_results, by=by) diff = diff_entries(prev_entries, entries) print_header(by='%s (%d added, %d removed)' % (by, sum(1 for _, old, _, _, _, _, _ in diff.values() if not old), sum(1 for _, _, _, new, _, _, _ in diff.values() if not new))) for name, ( old_hits, old_count, new_hits, new_count, diff_hits, diff_count, ratio) in sorted_diff_entries( diff.items()): if ratio or args.get('all'): print_diff_entry(name, old_hits, old_count, new_hits, new_count, diff_hits, diff_count, ratio) def print_totals(): if not args.get('diff'): print_entry('TOTAL', total_hits, total_count) else: ratio = ((total_hits/total_count if total_count else 1.0) - (prev_total_hits/prev_total_count if prev_total_count else 1.0)) print_diff_entry('TOTAL', prev_total_hits, prev_total_count, total_hits, total_count, total_hits-prev_total_hits, total_count-prev_total_count, ratio) if args.get('quiet'): pass elif args.get('summary'): print_header() print_totals() elif args.get('files'): print_entries(by='file') print_totals() else: print_entries(by='name') print_totals() if __name__ == "__main__": import argparse import sys parser = argparse.ArgumentParser( description="Parse and report coverage info from .info files \ generated by lcov") parser.add_argument('info_paths', nargs='*', default=INFO_PATHS, help="Description of where to find *.info files. May be a directory \ or list of paths. *.info files will be merged to show the total \ coverage. Defaults to %r." % INFO_PATHS) parser.add_argument('-v', '--verbose', action='store_true', help="Output commands that run behind the scenes.") parser.add_argument('-o', '--output', help="Specify CSV file to store results.") parser.add_argument('-u', '--use', help="Don't do any work, instead use this CSV file.") parser.add_argument('-d', '--diff', help="Specify CSV file to diff code size against.") parser.add_argument('-m', '--merge', help="Merge with an existing CSV file when writing to output.") parser.add_argument('-a', '--all', action='store_true', help="Show all functions, not just the ones that changed.") parser.add_argument('-A', '--everything', action='store_true', help="Include builtin and libc specific symbols.") parser.add_argument('-s', '--coverage-sort', action='store_true', help="Sort by coverage.") parser.add_argument('-S', '--reverse-coverage-sort', action='store_true', help="Sort by coverage, but backwards.") parser.add_argument('-F', '--files', action='store_true', help="Show file-level coverage.") parser.add_argument('-Y', '--summary', action='store_true', help="Only show the total coverage.") parser.add_argument('-q', '--quiet', action='store_true', help="Don't show anything, useful with -o.") parser.add_argument('--build-dir', help="Specify the relative build directory. Used to map object files \ to the correct source files.") sys.exit(main(**vars(parser.parse_args())))