coverage.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521
  1. #!/usr/bin/env python3
  2. #
  3. # Script to find test coverage. Basically just a big wrapper around gcov with
  4. # some extra conveniences for comparing builds. Heavily inspired by Linux's
  5. # Bloat-O-Meter.
  6. #
  7. import collections as co
  8. import csv
  9. import glob
  10. import itertools as it
  11. import json
  12. import os
  13. import re
  14. import shlex
  15. import subprocess as sp
  16. # TODO use explode_asserts to avoid counting assert branches?
  17. # TODO use dwarf=info to find functions for inline functions?
  18. GCDA_PATHS = ['*.gcda']
  19. class CoverageResult(co.namedtuple('CoverageResult',
  20. 'coverage_line_hits,coverage_line_count,'
  21. 'coverage_branch_hits,coverage_branch_count')):
  22. __slots__ = ()
  23. def __new__(cls,
  24. coverage_line_hits=0, coverage_line_count=0,
  25. coverage_branch_hits=0, coverage_branch_count=0):
  26. return super().__new__(cls,
  27. int(coverage_line_hits),
  28. int(coverage_line_count),
  29. int(coverage_branch_hits),
  30. int(coverage_branch_count))
  31. def __add__(self, other):
  32. return self.__class__(
  33. self.coverage_line_hits + other.coverage_line_hits,
  34. self.coverage_line_count + other.coverage_line_count,
  35. self.coverage_branch_hits + other.coverage_branch_hits,
  36. self.coverage_branch_count + other.coverage_branch_count)
  37. def __sub__(self, other):
  38. return CoverageDiff(other, self)
  39. def __rsub__(self, other):
  40. return self.__class__.__sub__(other, self)
  41. def key(self, **args):
  42. ratio_line = (self.coverage_line_hits/self.coverage_line_count
  43. if self.coverage_line_count else -1)
  44. ratio_branch = (self.coverage_branch_hits/self.coverage_branch_count
  45. if self.coverage_branch_count else -1)
  46. if args.get('line_sort'):
  47. return (-ratio_line, -ratio_branch)
  48. elif args.get('reverse_line_sort'):
  49. return (+ratio_line, +ratio_branch)
  50. elif args.get('branch_sort'):
  51. return (-ratio_branch, -ratio_line)
  52. elif args.get('reverse_branch_sort'):
  53. return (+ratio_branch, +ratio_line)
  54. else:
  55. return None
  56. _header = '%19s %19s' % ('hits/line', 'hits/branch')
  57. def __str__(self):
  58. line_hits = self.coverage_line_hits
  59. line_count = self.coverage_line_count
  60. branch_hits = self.coverage_branch_hits
  61. branch_count = self.coverage_branch_count
  62. return '%11s %7s %11s %7s' % (
  63. '%d/%d' % (line_hits, line_count)
  64. if line_count else '-',
  65. '%.1f%%' % (100*line_hits/line_count)
  66. if line_count else '-',
  67. '%d/%d' % (branch_hits, branch_count)
  68. if branch_count else '-',
  69. '%.1f%%' % (100*branch_hits/branch_count)
  70. if branch_count else '-')
  71. class CoverageDiff(co.namedtuple('CoverageDiff', 'old,new')):
  72. __slots__ = ()
  73. def ratio_line(self):
  74. old_line_hits = (self.old.coverage_line_hits
  75. if self.old is not None else 0)
  76. old_line_count = (self.old.coverage_line_count
  77. if self.old is not None else 0)
  78. new_line_hits = (self.new.coverage_line_hits
  79. if self.new is not None else 0)
  80. new_line_count = (self.new.coverage_line_count
  81. if self.new is not None else 0)
  82. return ((new_line_hits/new_line_count if new_line_count else 1.0)
  83. - (old_line_hits/old_line_count if old_line_count else 1.0))
  84. def ratio_branch(self):
  85. old_branch_hits = (self.old.coverage_branch_hits
  86. if self.old is not None else 0)
  87. old_branch_count = (self.old.coverage_branch_count
  88. if self.old is not None else 0)
  89. new_branch_hits = (self.new.coverage_branch_hits
  90. if self.new is not None else 0)
  91. new_branch_count = (self.new.coverage_branch_count
  92. if self.new is not None else 0)
  93. return ((new_branch_hits/new_branch_count if new_branch_count else 1.0)
  94. - (old_branch_hits/old_branch_count if old_branch_count else 1.0))
  95. def key(self, **args):
  96. return (
  97. self.new.key(**args) if self.new is not None else 0,
  98. -self.ratio_line(),
  99. -self.ratio_branch())
  100. def __bool__(self):
  101. return bool(self.ratio_line() or self.ratio_branch())
  102. _header = '%23s %23s %23s' % ('old', 'new', 'diff')
  103. def __str__(self):
  104. old_line_hits = (self.old.coverage_line_hits
  105. if self.old is not None else 0)
  106. old_line_count = (self.old.coverage_line_count
  107. if self.old is not None else 0)
  108. old_branch_hits = (self.old.coverage_branch_hits
  109. if self.old is not None else 0)
  110. old_branch_count = (self.old.coverage_branch_count
  111. if self.old is not None else 0)
  112. new_line_hits = (self.new.coverage_line_hits
  113. if self.new is not None else 0)
  114. new_line_count = (self.new.coverage_line_count
  115. if self.new is not None else 0)
  116. new_branch_hits = (self.new.coverage_branch_hits
  117. if self.new is not None else 0)
  118. new_branch_count = (self.new.coverage_branch_count
  119. if self.new is not None else 0)
  120. diff_line_hits = new_line_hits - old_line_hits
  121. diff_line_count = new_line_count - old_line_count
  122. diff_branch_hits = new_branch_hits - old_branch_hits
  123. diff_branch_count = new_branch_count - old_branch_count
  124. ratio_line = self.ratio_line()
  125. ratio_branch = self.ratio_branch()
  126. return '%11s %11s %11s %11s %11s %11s%-10s%s' % (
  127. '%d/%d' % (old_line_hits, old_line_count)
  128. if old_line_count else '-',
  129. '%d/%d' % (old_branch_hits, old_branch_count)
  130. if old_branch_count else '-',
  131. '%d/%d' % (new_line_hits, new_line_count)
  132. if new_line_count else '-',
  133. '%d/%d' % (new_branch_hits, new_branch_count)
  134. if new_branch_count else '-',
  135. '%+d/%+d' % (diff_line_hits, diff_line_count),
  136. '%+d/%+d' % (diff_branch_hits, diff_branch_count),
  137. ' (%+.1f%%)' % (100*ratio_line) if ratio_line else '',
  138. ' (%+.1f%%)' % (100*ratio_branch) if ratio_branch else '')
  139. def openio(path, mode='r'):
  140. if path == '-':
  141. if 'r' in mode:
  142. return os.fdopen(os.dup(sys.stdin.fileno()), 'r')
  143. else:
  144. return os.fdopen(os.dup(sys.stdout.fileno()), 'w')
  145. else:
  146. return open(path, mode)
  147. def color(**args):
  148. if args.get('color') == 'auto':
  149. return sys.stdout.isatty()
  150. elif args.get('color') == 'always':
  151. return True
  152. else:
  153. return False
  154. def collect(paths, **args):
  155. results = {}
  156. for path in paths:
  157. # map to source file
  158. src_path = re.sub('\.t\.a\.gcda$', '.c', path)
  159. # TODO test this
  160. if args.get('build_dir'):
  161. src_path = re.sub('%s/*' % re.escape(args['build_dir']), '',
  162. src_path)
  163. # get coverage info through gcov's json output
  164. # note, gcov-tool may contain extra args
  165. cmd = args['gcov_tool'] + ['-b', '-t', '--json-format', path]
  166. if args.get('verbose'):
  167. print(' '.join(shlex.quote(c) for c in cmd))
  168. proc = sp.Popen(cmd,
  169. stdout=sp.PIPE,
  170. stderr=sp.PIPE if not args.get('verbose') else None,
  171. universal_newlines=True,
  172. errors='replace')
  173. data = json.load(proc.stdout)
  174. proc.wait()
  175. if proc.returncode != 0:
  176. if not args.get('verbose'):
  177. for line in proc.stderr:
  178. sys.stdout.write(line)
  179. sys.exit(-1)
  180. # collect line/branch coverage
  181. for file in data['files']:
  182. if file['file'] != src_path:
  183. continue
  184. for line in file['lines']:
  185. func = line.get('function_name', '(inlined)')
  186. # discard internal function (this includes injected test cases)
  187. if not args.get('everything'):
  188. if func.startswith('__'):
  189. continue
  190. results[(src_path, func, line['line_number'])] = (
  191. line['count'],
  192. CoverageResult(
  193. coverage_line_hits=1 if line['count'] > 0 else 0,
  194. coverage_line_count=1,
  195. coverage_branch_hits=sum(
  196. 1 if branch['count'] > 0 else 0
  197. for branch in line['branches']),
  198. coverage_branch_count=len(line['branches'])))
  199. # merge into functions, since this is what other scripts use
  200. func_results = co.defaultdict(lambda: CoverageResult())
  201. for (file, func, _), (_, result) in results.items():
  202. func_results[(file, func)] += result
  203. return func_results, results
  204. def annotate(paths, results, **args):
  205. for path in paths:
  206. # map to source file
  207. src_path = re.sub('\.t\.a\.gcda$', '.c', path)
  208. # TODO test this
  209. if args.get('build_dir'):
  210. src_path = re.sub('%s/*' % re.escape(args['build_dir']), '',
  211. src_path)
  212. # flatten to line info
  213. line_results = {line: (hits, result)
  214. for (_, _, line), (hits, result) in results.items()}
  215. # calculate spans to show
  216. if not args.get('annotate'):
  217. spans = []
  218. last = None
  219. for line, (hits, result) in sorted(line_results.items()):
  220. if ((args.get('lines') and hits == 0)
  221. or (args.get('branches')
  222. and result.coverage_branch_hits
  223. < result.coverage_branch_count)):
  224. if last is not None and line - last.stop <= args['context']:
  225. last = range(
  226. last.start,
  227. line+1+args['context'])
  228. else:
  229. if last is not None:
  230. spans.append(last)
  231. last = range(
  232. line-args['context'],
  233. line+1+args['context'])
  234. if last is not None:
  235. spans.append(last)
  236. with open(src_path) as f:
  237. skipped = False
  238. for i, line in enumerate(f):
  239. # skip lines not in spans?
  240. if (not args.get('annotate')
  241. and not any(i+1 in s for s in spans)):
  242. skipped = True
  243. continue
  244. if skipped:
  245. skipped = False
  246. print('%s@@ %s:%d @@%s' % (
  247. '\x1b[36m' if color(**args) else '',
  248. src_path,
  249. i+1,
  250. '\x1b[m' if color(**args) else ''))
  251. # build line
  252. if line.endswith('\n'):
  253. line = line[:-1]
  254. if i+1 in line_results:
  255. hits, result = line_results[i+1]
  256. line = '%-*s // %d hits, %d/%d branches' % (
  257. args['width'],
  258. line,
  259. hits,
  260. result.coverage_branch_hits,
  261. result.coverage_branch_count)
  262. if color(**args):
  263. if args.get('lines') and hits == 0:
  264. line = '\x1b[1;31m%s\x1b[m' % line
  265. elif (args.get('branches') and
  266. result.coverage_branch_hits
  267. < result.coverage_branch_count):
  268. line = '\x1b[35m%s\x1b[m' % line
  269. print(line)
  270. def main(**args):
  271. # find sizes
  272. if not args.get('use', None):
  273. # find .gcda files
  274. paths = []
  275. for path in args['gcda_paths']:
  276. if os.path.isdir(path):
  277. path = path + '/*.gcda'
  278. for path in glob.glob(path):
  279. paths.append(path)
  280. if not paths:
  281. print('no .gcda files found in %r?' % args['gcda_paths'])
  282. sys.exit(-1)
  283. results, line_results = collect(paths, **args)
  284. else:
  285. with openio(args['use']) as f:
  286. r = csv.DictReader(f)
  287. results = {
  288. (result['file'], result['name']): CoverageResult(
  289. *(result[f] for f in CoverageResult._fields))
  290. for result in r
  291. if all(result.get(f) not in {None, ''}
  292. for f in CoverageResult._fields)}
  293. paths = []
  294. line_results = {}
  295. # find previous results?
  296. if args.get('diff'):
  297. try:
  298. with openio(args['diff']) as f:
  299. r = csv.DictReader(f)
  300. prev_results = {
  301. (result['file'], result['name']): CoverageResult(
  302. *(result[f] for f in CoverageResult._fields))
  303. for result in r
  304. if all(result.get(f) not in {None, ''}
  305. for f in CoverageResult._fields)}
  306. except FileNotFoundError:
  307. prev_results = []
  308. # write results to CSV
  309. if args.get('output'):
  310. merged_results = co.defaultdict(lambda: {})
  311. other_fields = []
  312. # merge?
  313. if args.get('merge'):
  314. try:
  315. with openio(args['merge']) as f:
  316. r = csv.DictReader(f)
  317. for result in r:
  318. file = result.pop('file', '')
  319. func = result.pop('name', '')
  320. for f in CoverageResult._fields:
  321. result.pop(f, None)
  322. merged_results[(file, func)] = result
  323. other_fields = result.keys()
  324. except FileNotFoundError:
  325. pass
  326. for (file, func), result in results.items():
  327. merged_results[(file, func)] |= result._asdict()
  328. with openio(args['output'], 'w') as f:
  329. w = csv.DictWriter(f, ['file', 'name',
  330. *other_fields, *CoverageResult._fields])
  331. w.writeheader()
  332. for (file, func), result in sorted(merged_results.items()):
  333. w.writerow({'file': file, 'name': func, **result})
  334. # print results
  335. def print_header(by):
  336. if by == 'total':
  337. entry = lambda k: 'TOTAL'
  338. elif by == 'file':
  339. entry = lambda k: k[0]
  340. else:
  341. entry = lambda k: k[1]
  342. if not args.get('diff'):
  343. print('%-36s %s' % (by, CoverageResult._header))
  344. else:
  345. old = {entry(k) for k in results.keys()}
  346. new = {entry(k) for k in prev_results.keys()}
  347. print('%-36s %s' % (
  348. '%s (%d added, %d removed)' % (by,
  349. sum(1 for k in new if k not in old),
  350. sum(1 for k in old if k not in new))
  351. if by else '',
  352. CoverageDiff._header))
  353. def print_entries(by):
  354. if by == 'total':
  355. entry = lambda k: 'TOTAL'
  356. elif by == 'file':
  357. entry = lambda k: k[0]
  358. else:
  359. entry = lambda k: k[1]
  360. entries = co.defaultdict(lambda: CoverageResult())
  361. for k, result in results.items():
  362. entries[entry(k)] += result
  363. if not args.get('diff'):
  364. for name, result in sorted(entries.items(),
  365. key=lambda p: (p[1].key(**args), p)):
  366. print('%-36s %s' % (name, result))
  367. else:
  368. prev_entries = co.defaultdict(lambda: CoverageResult())
  369. for k, result in prev_results.items():
  370. prev_entries[entry(k)] += result
  371. diff_entries = {name: entries.get(name) - prev_entries.get(name)
  372. for name in (entries.keys() | prev_entries.keys())}
  373. for name, diff in sorted(diff_entries.items(),
  374. key=lambda p: (p[1].key(**args), p)):
  375. if diff or args.get('all'):
  376. print('%-36s %s' % (name, diff))
  377. if args.get('quiet'):
  378. pass
  379. elif (args.get('annotate')
  380. or args.get('lines')
  381. or args.get('branches')):
  382. annotate(paths, line_results, **args)
  383. elif args.get('summary'):
  384. print_header('')
  385. print_entries('total')
  386. elif args.get('files'):
  387. print_header('file')
  388. print_entries('file')
  389. print_entries('total')
  390. else:
  391. print_header('function')
  392. print_entries('function')
  393. print_entries('total')
  394. # catch lack of coverage
  395. if args.get('error_on_lines') and any(
  396. r.coverage_line_hits < r.coverage_line_count
  397. for r in results.values()):
  398. sys.exit(2)
  399. elif args.get('error_on_branches') and any(
  400. r.coverage_branch_hits < r.coverage_branch_count
  401. for r in results.values()):
  402. sys.exit(3)
  403. if __name__ == "__main__":
  404. import argparse
  405. import sys
  406. parser = argparse.ArgumentParser(
  407. description="Find coverage info after running tests.")
  408. parser.add_argument('gcda_paths', nargs='*', default=GCDA_PATHS,
  409. help="Description of where to find *.gcda files. May be a directory \
  410. or a list of paths. Defaults to %r." % GCDA_PATHS)
  411. parser.add_argument('-v', '--verbose', action='store_true',
  412. help="Output commands that run behind the scenes.")
  413. parser.add_argument('-q', '--quiet', action='store_true',
  414. help="Don't show anything, useful with -o.")
  415. parser.add_argument('-o', '--output',
  416. help="Specify CSV file to store results.")
  417. parser.add_argument('-u', '--use',
  418. help="Don't compile and find code sizes, instead use this CSV file.")
  419. parser.add_argument('-d', '--diff',
  420. help="Specify CSV file to diff code size against.")
  421. parser.add_argument('-m', '--merge',
  422. help="Merge with an existing CSV file when writing to output.")
  423. parser.add_argument('-a', '--all', action='store_true',
  424. help="Show all functions, not just the ones that changed.")
  425. parser.add_argument('-A', '--everything', action='store_true',
  426. help="Include builtin and libc specific symbols.")
  427. parser.add_argument('-s', '--line-sort', action='store_true',
  428. help="Sort by line coverage.")
  429. parser.add_argument('-S', '--reverse-line-sort', action='store_true',
  430. help="Sort by line coverage, but backwards.")
  431. parser.add_argument('--branch-sort', action='store_true',
  432. help="Sort by branch coverage.")
  433. parser.add_argument('--reverse-branch-sort', action='store_true',
  434. help="Sort by branch coverage, but backwards.")
  435. parser.add_argument('-F', '--files', action='store_true',
  436. help="Show file-level coverage.")
  437. parser.add_argument('-Y', '--summary', action='store_true',
  438. help="Only show the total coverage.")
  439. parser.add_argument('-p', '--annotate', action='store_true',
  440. help="Show source files annotated with coverage info.")
  441. parser.add_argument('-l', '--lines', action='store_true',
  442. help="Show uncovered lines.")
  443. parser.add_argument('-b', '--branches', action='store_true',
  444. help="Show uncovered branches.")
  445. parser.add_argument('-c', '--context', type=lambda x: int(x, 0), default=3,
  446. help="Show a additional lines of context. Defaults to 3.")
  447. parser.add_argument('-W', '--width', type=lambda x: int(x, 0), default=80,
  448. help="Assume source is styled with this many columns. Defaults to 80.")
  449. parser.add_argument('--color',
  450. choices=['never', 'always', 'auto'], default='auto',
  451. help="When to use terminal colors.")
  452. parser.add_argument('-e', '--error-on-lines', action='store_true',
  453. help="Error if any lines are not covered.")
  454. parser.add_argument('-E', '--error-on-branches', action='store_true',
  455. help="Error if any branches are not covered.")
  456. parser.add_argument('--gcov-tool', default=['gcov'],
  457. type=lambda x: x.split(),
  458. help="Path to the gcov tool to use.")
  459. parser.add_argument('--build-dir',
  460. help="Specify the relative build directory. Used to map object files \
  461. to the correct source files.")
  462. sys.exit(main(**{k: v
  463. for k, v in vars(parser.parse_args()).items()
  464. if v is not None}))