coverage.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413
  1. #!/usr/bin/env python3
  2. #
  3. import os
  4. import glob
  5. import csv
  6. import re
  7. import collections as co
  8. import bisect as b
  9. RESULTDIR = 'results'
  10. #RULES = """
  11. #define FLATTEN
  12. #%(sizedir)s/%(build)s.$(subst /,.,$(target)): $(target)
  13. # ( echo "#line 1 \\"$$<\\"" ; %(cat)s $$< ) > $$@
  14. #%(sizedir)s/%(build)s.$(subst /,.,$(target:.c=.size)): \\
  15. # %(sizedir)s/%(build)s.$(subst /,.,$(target:.c=.o))
  16. # $(NM) --size-sort $$^ | sed 's/^/$(subst /,\\/,$(target:.c=.o)):/' > $$@
  17. #endef
  18. #$(foreach target,$(SRC),$(eval $(FLATTEN)))
  19. #
  20. #-include %(sizedir)s/*.d
  21. #.SECONDARY:
  22. #
  23. #%%.size: $(foreach t,$(subst /,.,$(OBJ:.o=.size)),%%.$t)
  24. # cat $^ > $@
  25. #"""
  26. #CATS = {
  27. # 'code': 'cat',
  28. # 'code_inlined': 'sed \'s/^static\( inline\)\?//\'',
  29. #}
  30. #
  31. #def build(**args):
  32. # # mkdir -p sizedir
  33. # os.makedirs(args['sizedir'], exist_ok=True)
  34. #
  35. # if args.get('inlined', False):
  36. # builds = ['code', 'code_inlined']
  37. # else:
  38. # builds = ['code']
  39. #
  40. # # write makefiles for the different types of builds
  41. # makefiles = []
  42. # targets = []
  43. # for build in builds:
  44. # path = args['sizedir'] + '/' + build
  45. # with open(path + '.mk', 'w') as mk:
  46. # mk.write(RULES.replace(4*' ', '\t') % dict(
  47. # sizedir=args['sizedir'],
  48. # build=build,
  49. # cat=CATS[build]))
  50. # mk.write('\n')
  51. #
  52. # # pass on defines
  53. # for d in args['D']:
  54. # mk.write('%s: override CFLAGS += -D%s\n' % (
  55. # path+'.size', d))
  56. #
  57. # makefiles.append(path + '.mk')
  58. # targets.append(path + '.size')
  59. #
  60. # # build in parallel
  61. # cmd = (['make', '-f', 'Makefile'] +
  62. # list(it.chain.from_iterable(['-f', m] for m in makefiles)) +
  63. # [target for target in targets])
  64. # if args.get('verbose', False):
  65. # print(' '.join(shlex.quote(c) for c in cmd))
  66. # proc = sp.Popen(cmd,
  67. # stdout=sp.DEVNULL if not args.get('verbose', False) else None)
  68. # proc.wait()
  69. # if proc.returncode != 0:
  70. # sys.exit(-1)
  71. #
  72. # # find results
  73. # build_results = co.defaultdict(lambda: 0)
  74. # # notes
  75. # # - filters type
  76. # # - discards internal/debug functions (leading __)
  77. # pattern = re.compile(
  78. # '^(?P<file>[^:]+)' +
  79. # ':(?P<size>[0-9a-fA-F]+)' +
  80. # ' (?P<type>[%s])' % re.escape(args['type']) +
  81. # ' (?!__)(?P<name>.+?)$')
  82. # for build in builds:
  83. # path = args['sizedir'] + '/' + build
  84. # with open(path + '.size') as size:
  85. # for line in size:
  86. # match = pattern.match(line)
  87. # if match:
  88. # file = match.group('file')
  89. # # discard .8449 suffixes created by optimizer
  90. # name = re.sub('\.[0-9]+', '', match.group('name'))
  91. # size = int(match.group('size'), 16)
  92. # build_results[(build, file, name)] += size
  93. #
  94. # results = []
  95. # for (build, file, name), size in build_results.items():
  96. # if build == 'code':
  97. # results.append((file, name, size, False))
  98. # elif (build == 'code_inlined' and
  99. # ('inlined', file, name) not in results):
  100. # results.append((file, name, size, True))
  101. #
  102. # return results
  103. def collect(covfuncs, covlines, path, **args):
  104. with open(path) as f:
  105. file = None
  106. filter = args['filter'].split() if args.get('filter') else None
  107. pattern = re.compile(
  108. '^(?P<file>file'
  109. ':(?P<file_name>.*))' +
  110. '|(?P<func>function' +
  111. ':(?P<func_lineno>[0-9]+)' +
  112. ',(?P<func_hits>[0-9]+)' +
  113. ',(?P<func_name>.*))' +
  114. '|(?P<line>lcount' +
  115. ':(?P<line_lineno>[0-9]+)' +
  116. ',(?P<line_hits>[0-9]+))$')
  117. for line in f:
  118. match = pattern.match(line)
  119. if match:
  120. if match.group('file'):
  121. file = match.group('file_name')
  122. # filter?
  123. if filter and file not in filter:
  124. file = None
  125. elif file is not None and match.group('func'):
  126. lineno = int(match.group('func_lineno'))
  127. name, hits = covfuncs[(file, lineno)]
  128. covfuncs[(file, lineno)] = (
  129. name or match.group('func_name'),
  130. hits + int(match.group('func_hits')))
  131. elif file is not None and match.group('line'):
  132. lineno = int(match.group('line_lineno'))
  133. covlines[(file, lineno)] += int(match.group('line_hits'))
  134. def coverage(**args):
  135. # find *.gcov files
  136. gcovpaths = []
  137. for gcovpath in args.get('gcovpaths') or [args['results']]:
  138. if os.path.isdir(gcovpath):
  139. gcovpath = gcovpath + '/*.gcov'
  140. for path in glob.glob(gcovpath):
  141. gcovpaths.append(path)
  142. if not gcovpaths:
  143. print('no gcov files found in %r?'
  144. % (args.get('gcovpaths') or [args['results']]))
  145. sys.exit(-1)
  146. # collect coverage info
  147. covfuncs = co.defaultdict(lambda: (None, 0))
  148. covlines = co.defaultdict(lambda: 0)
  149. for path in gcovpaths:
  150. collect(covfuncs, covlines, path, **args)
  151. # merge? go ahead and handle that here, but
  152. # with a copy so we only report on the current coverage
  153. if args.get('merge', None):
  154. if os.path.isfile(args['merge']):
  155. accfuncs = covfuncs.copy()
  156. acclines = covlines.copy()
  157. collect(accfuncs, acclines, args['merge']) # don't filter!
  158. else:
  159. accfuncs = covfuncs
  160. acclines = covlines
  161. accfiles = sorted({file for file, _ in acclines.keys()})
  162. accfuncs, i = sorted(accfuncs.items()), 0
  163. acclines, j = sorted(acclines.items()), 0
  164. with open(args['merge'], 'w') as f:
  165. for file in accfiles:
  166. f.write('file:%s\n' % file)
  167. while i < len(accfuncs) and accfuncs[i][0][0] == file:
  168. ((_, lineno), (name, hits)) = accfuncs[i]
  169. f.write('function:%d,%d,%s\n' % (lineno, hits, name))
  170. i += 1
  171. while j < len(acclines) and acclines[j][0][0] == file:
  172. ((_, lineno), hits) = acclines[j]
  173. f.write('lcount:%d,%d\n' % (lineno, hits))
  174. j += 1
  175. # annotate?
  176. if args.get('annotate', False):
  177. # annotate(covlines, **args)
  178. pass
  179. # condense down to file/function results
  180. funcs = sorted(covfuncs.items())
  181. func_lines = [(file, lineno) for (file, lineno), _ in funcs]
  182. func_names = [name for _, (name, _) in funcs]
  183. def line_func(file, lineno):
  184. i = b.bisect(func_lines, (file, lineno))
  185. if i and func_lines[i-1][0] == file:
  186. return func_names[i-1]
  187. else:
  188. return '???'
  189. func_results = co.defaultdict(lambda: (0, 0))
  190. for ((file, lineno), hits) in covlines.items():
  191. func = line_func(file, lineno)
  192. branch_hits, branches = func_results[(file, func)]
  193. func_results[(file, func)] = (branch_hits + (hits > 0), branches + 1)
  194. results = []
  195. for (file, func), (hits, branches) in func_results.items():
  196. # discard internal/testing functions (test_* injected with
  197. # internal testing)
  198. if func == '???' or func.startswith('__') or func.startswith('test_'):
  199. continue
  200. # discard .8449 suffixes created by optimizer
  201. func = re.sub('\.[0-9]+', '', func)
  202. results.append((file, func, hits, branches))
  203. return results
  204. def main(**args):
  205. # find coverage
  206. if not args.get('input', None):
  207. results = coverage(**args)
  208. else:
  209. with open(args['input']) as f:
  210. r = csv.DictReader(f)
  211. results = [
  212. ( result['file'],
  213. result['function'],
  214. int(result['hits']),
  215. int(result['branches']))
  216. for result in r]
  217. total_hits, total_branches = 0, 0
  218. for _, _, hits, branches in results:
  219. total_hits += hits
  220. total_branches += branches
  221. # find previous results?
  222. if args.get('diff', None):
  223. with open(args['diff']) as f:
  224. r = csv.DictReader(f)
  225. prev_results = [
  226. ( result['file'],
  227. result['function'],
  228. int(result['hits']),
  229. int(result['branches']))
  230. for result in r]
  231. prev_total_hits, prev_total_branches = 0, 0
  232. for _, _, hits, branches in prev_results:
  233. prev_total_hits += hits
  234. prev_total_branches += branches
  235. # write results to CSV
  236. if args.get('output', None):
  237. results.sort(key=lambda x: (-(x[2]/x[3]), -x[3], x))
  238. with open(args['output'], 'w') as f:
  239. w = csv.writer(f)
  240. w.writerow(['file', 'function', 'hits', 'branches'])
  241. for file, func, hits, branches in results:
  242. w.writerow((file, func, hits, branches))
  243. # print results
  244. def dedup_entries(results, by='function'):
  245. entries = co.defaultdict(lambda: (0, 0))
  246. for file, func, hits, branches in results:
  247. entry = (file if by == 'file' else func)
  248. entry_hits, entry_branches = entries[entry]
  249. entries[entry] = (entry_hits + hits, entry_branches + branches)
  250. return entries
  251. def diff_entries(olds, news):
  252. diff = co.defaultdict(lambda: (None, None, None, None, None, None))
  253. for name, (new_hits, new_branches) in news.items():
  254. diff[name] = (
  255. 0, 0,
  256. new_hits, new_branches,
  257. new_hits, new_branches)
  258. for name, (old_hits, old_branches) in olds.items():
  259. new_hits = diff[name][2] or 0
  260. new_branches = diff[name][3] or 0
  261. diff[name] = (
  262. old_hits, old_branches,
  263. new_hits, new_branches,
  264. new_hits-old_hits, new_branches-old_branches)
  265. return diff
  266. def print_header(by=''):
  267. if not args.get('diff', False):
  268. print('%-36s %11s' % (by, 'branches'))
  269. else:
  270. print('%-36s %11s %11s %11s' % (by, 'old', 'new', 'diff'))
  271. def print_entries(by='function'):
  272. entries = dedup_entries(results, by=by)
  273. if not args.get('diff', None):
  274. print_header(by=by)
  275. for name, (hits, branches) in sorted(entries.items(),
  276. key=lambda x: (-(x[1][0]-x[1][1]), -x[1][1], x)):
  277. print("%-36s %11s (%.2f%%)" % (name,
  278. '%d/%d' % (hits, branches),
  279. 100*(hits/branches if branches else 1.0)))
  280. else:
  281. prev_entries = dedup_entries(prev_results, by=by)
  282. diff = diff_entries(prev_entries, entries)
  283. print_header(by='%s (%d added, %d removed)' % (by,
  284. sum(1 for _, old, _, _, _, _ in diff.values() if not old),
  285. sum(1 for _, _, _, new, _, _ in diff.values() if not new)))
  286. for name, (
  287. old_hits, old_branches,
  288. new_hits, new_branches,
  289. diff_hits, diff_branches) in sorted(diff.items(),
  290. key=lambda x: (
  291. -(x[1][4]-x[1][5]), -x[1][5], -x[1][3], x)):
  292. ratio = ((new_hits/new_branches if new_branches else 1.0)
  293. - (old_hits/old_branches if old_branches else 1.0))
  294. if diff_hits or diff_branches or args.get('all', False):
  295. print("%-36s %11s %11s %11s%s" % (name,
  296. '%d/%d' % (old_hits, old_branches)
  297. if old_branches else '-',
  298. '%d/%d' % (new_hits, new_branches)
  299. if new_branches else '-',
  300. '%+d/%+d' % (diff_hits, diff_branches),
  301. ' (%+.2f%%)' % (100*ratio) if ratio else ''))
  302. def print_totals():
  303. if not args.get('diff', None):
  304. print("%-36s %11s (%.2f%%)" % ('TOTALS',
  305. '%d/%d' % (total_hits, total_branches),
  306. 100*(total_hits/total_branches if total_branches else 1.0)))
  307. else:
  308. ratio = ((total_hits/total_branches
  309. if total_branches else 1.0)
  310. - (prev_total_hits/prev_total_branches
  311. if prev_total_branches else 1.0))
  312. print("%-36s %11s %11s %11s%s" % ('TOTALS',
  313. '%d/%d' % (prev_total_hits, prev_total_branches),
  314. '%d/%d' % (total_hits, total_branches),
  315. '%+d/%+d' % (total_hits-prev_total_hits,
  316. total_branches-prev_total_branches),
  317. ' (%+.2f%%)' % (100*ratio) if ratio else ''))
  318. def print_status():
  319. if not args.get('diff', None):
  320. print("%d/%d (%.2f%%)" % (total_hits, total_branches,
  321. 100*(total_hits/total_branches if total_branches else 1.0)))
  322. else:
  323. ratio = ((total_hits/total_branches
  324. if total_branches else 1.0)
  325. - (prev_total_hits/prev_total_branches
  326. if prev_total_branches else 1.0))
  327. print("%d/%d (%+.2f%%)" % (total_hits, total_branches,
  328. (100*ratio) if ratio else ''))
  329. if args.get('quiet', False):
  330. pass
  331. elif args.get('status', False):
  332. print_status()
  333. elif args.get('summary', False):
  334. print_header()
  335. print_totals()
  336. elif args.get('files', False):
  337. print_entries(by='file')
  338. print_totals()
  339. else:
  340. print_entries(by='function')
  341. print_totals()
  342. if __name__ == "__main__":
  343. import argparse
  344. import sys
  345. parser = argparse.ArgumentParser(
  346. description="Show/manipulate coverage info")
  347. parser.add_argument('gcovpaths', nargs='*',
  348. help="Description of *.gcov files to use for coverage info. May be \
  349. a directory or list of files. Coverage files will be merged to \
  350. show the total coverage. Defaults to \"%s\"." % RESULTDIR)
  351. parser.add_argument('--results', default=RESULTDIR,
  352. help="Directory to store results. Created implicitly. Used if \
  353. annotated files are requested. Defaults to \"%s\"." % RESULTDIR)
  354. parser.add_argument('--merge',
  355. help="Merge coverage info into the specified file, writing the \
  356. cumulative coverage info to the file. The output from this script \
  357. does not include the coverage from the merge file.")
  358. parser.add_argument('--filter',
  359. help="Specify files with care about, all other coverage info (system \
  360. headers, test framework, etc) will be discarded.")
  361. parser.add_argument('--annotate', action='store_true',
  362. help="Output annotated source files into the result directory. Each \
  363. line will be annotated with the number of hits during testing. \
  364. This is useful for finding out which lines do not have test \
  365. coverage.")
  366. parser.add_argument('-v', '--verbose', action='store_true',
  367. help="Output commands that run behind the scenes.")
  368. parser.add_argument('-i', '--input',
  369. help="Don't do any work, instead use this CSV file.")
  370. parser.add_argument('-o', '--output',
  371. help="Specify CSV file to store results.")
  372. parser.add_argument('-d', '--diff',
  373. help="Specify CSV file to diff code size against.")
  374. parser.add_argument('-a', '--all', action='store_true',
  375. help="Show all functions, not just the ones that changed.")
  376. parser.add_argument('--files', action='store_true',
  377. help="Show file-level coverage.")
  378. parser.add_argument('-s', '--summary', action='store_true',
  379. help="Only show the total coverage.")
  380. parser.add_argument('-S', '--status', action='store_true',
  381. help="Show minimum info useful for a single-line status.")
  382. parser.add_argument('-q', '--quiet', action='store_true',
  383. help="Don't show anything, useful with -o.")
  384. sys.exit(main(**vars(parser.parse_args())))