coverage.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393
  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. def openio(path, mode='r'):
  20. if path == '-':
  21. if 'r' in mode:
  22. return os.fdopen(os.dup(sys.stdin.fileno()), 'r')
  23. else:
  24. return os.fdopen(os.dup(sys.stdout.fileno()), 'w')
  25. else:
  26. return open(path, mode)
  27. class CoverageResult(co.namedtuple('CoverageResult',
  28. 'line_hits,line_count,branch_hits,branch_count')):
  29. __slots__ = ()
  30. def __new__(cls, line_hits=0, line_count=0, branch_hits=0, branch_count=0):
  31. return super().__new__(cls,
  32. int(line_hits),
  33. int(line_count),
  34. int(branch_hits),
  35. int(branch_count))
  36. def __add__(self, other):
  37. return self.__class__(
  38. self.line_hits + other.line_hits,
  39. self.line_count + other.line_count,
  40. self.branch_hits + other.branch_hits,
  41. self.branch_count + other.branch_count)
  42. def __sub__(self, other):
  43. return CoverageDiff(other, self)
  44. def key(self, **args):
  45. line_ratio = (self.line_hits/self.line_count
  46. if self.line_count else -1)
  47. branch_ratio = (self.branch_hits/self.branch_count
  48. if self.branch_count else -1)
  49. if args.get('line_sort'):
  50. return (-line_ratio, -branch_ratio)
  51. elif args.get('reverse_line_sort'):
  52. return (+line_ratio, +branch_ratio)
  53. elif args.get('branch_sort'):
  54. return (-branch_ratio, -line_ratio)
  55. elif args.get('reverse_branch_sort'):
  56. return (+branch_ratio, +line_ratio)
  57. else:
  58. return None
  59. _header = '%19s %19s' % ('hits/line', 'hits/branch')
  60. def __str__(self):
  61. return '%11s %7s %11s %7s' % (
  62. '%d/%d' % (self.line_hits, self.line_count)
  63. if self.line_count else '-',
  64. '%.1f%%' % (100*self.line_hits/self.line_count)
  65. if self.line_count else '-',
  66. '%d/%d' % (self.branch_hits, self.branch_count)
  67. if self.branch_count else '-',
  68. '%.1f%%' % (100*self.branch_hits/self.branch_count)
  69. if self.branch_count else '-')
  70. class CoverageDiff(co.namedtuple('CoverageDiff', 'old,new')):
  71. __slots__ = ()
  72. def line_hits_diff(self):
  73. return self.new.line_hits - self.old.line_hits
  74. def line_count_diff(self):
  75. return self.new.line_count - self.old.line_count
  76. def line_ratio(self):
  77. return ((self.new.line_hits/self.new.line_count
  78. if self.new.line_count else 1.0)
  79. - (self.old.line_hits / self.old.line_count
  80. if self.old.line_count else 1.0))
  81. def branch_hits_diff(self):
  82. return self.new.branch_hits - self.old.branch_hits
  83. def branch_count_diff(self):
  84. return self.new.branch_count - self.old.branch_count
  85. def branch_ratio(self):
  86. return ((self.new.branch_hits/self.new.branch_count
  87. if self.new.branch_count else 1.0)
  88. - (self.old.branch_hits / self.old.branch_count
  89. if self.old.branch_count else 1.0))
  90. def key(self, **args):
  91. new_key = self.new.key(**args)
  92. line_ratio = self.line_ratio()
  93. branch_ratio = self.branch_ratio()
  94. if new_key is not None:
  95. return new_key
  96. else:
  97. return (-line_ratio, -branch_ratio)
  98. def __bool__(self):
  99. return bool(self.line_ratio() or self.branch_ratio())
  100. _header = '%23s %23s %23s' % ('old', 'new', 'diff')
  101. def __str__(self):
  102. line_ratio = self.line_ratio()
  103. branch_ratio = self.branch_ratio()
  104. return '%11s %11s %11s %11s %11s %11s%-10s%s' % (
  105. '%d/%d' % (self.old.line_hits, self.old.line_count)
  106. if self.old.line_count else '-',
  107. '%d/%d' % (self.old.branch_hits, self.old.branch_count)
  108. if self.old.branch_count else '-',
  109. '%d/%d' % (self.new.line_hits, self.new.line_count)
  110. if self.new.line_count else '-',
  111. '%d/%d' % (self.new.branch_hits, self.new.branch_count)
  112. if self.new.branch_count else '-',
  113. '%+d/%+d' % (self.line_hits_diff(), self.line_count_diff()),
  114. '%+d/%+d' % (self.branch_hits_diff(), self.branch_count_diff()),
  115. ' (%+.1f%%)' % (100*line_ratio) if line_ratio else '',
  116. ' (%+.1f%%)' % (100*branch_ratio) if branch_ratio else '')
  117. def collect(paths, **args):
  118. results = {}
  119. for path in paths:
  120. # map to source file
  121. src_path = re.sub('\.t\.a\.gcda$', '.c', path)
  122. # TODO test this
  123. if args.get('build_dir'):
  124. src_path = re.sub('%s/*' % re.escape(args['build_dir']), '',
  125. src_path)
  126. # get coverage info through gcov's json output
  127. # note, gcov-tool may contain extra args
  128. cmd = args['gcov_tool'] + ['-b', '-t', '--json-format', path]
  129. if args.get('verbose'):
  130. print(' '.join(shlex.quote(c) for c in cmd))
  131. proc = sp.Popen(cmd,
  132. stdout=sp.PIPE,
  133. stderr=sp.PIPE if not args.get('verbose') else None,
  134. universal_newlines=True,
  135. errors='replace')
  136. data = json.load(proc.stdout)
  137. proc.wait()
  138. if proc.returncode != 0:
  139. if not args.get('verbose'):
  140. for line in proc.stderr:
  141. sys.stdout.write(line)
  142. sys.exit(-1)
  143. # collect line/branch coverage
  144. for file in data['files']:
  145. if file['file'] != src_path:
  146. continue
  147. for line in file['lines']:
  148. func = line.get('function_name', '(inlined)')
  149. # discard internal function (this includes injected test cases)
  150. if not args.get('everything'):
  151. if func.startswith('__'):
  152. continue
  153. results[(src_path, func, line['line_number'])] = (
  154. line['count'],
  155. CoverageResult(
  156. line_hits=1 if line['count'] > 0 else 0,
  157. line_count=1,
  158. branch_hits=sum(
  159. 1 if branch['count'] > 0 else 0
  160. for branch in line['branches']),
  161. branch_count=len(line['branches'])))
  162. # merge into functions, since this is what other scripts use
  163. func_results = co.defaultdict(lambda: CoverageResult())
  164. for (file, func, _), (_, result) in results.items():
  165. func_results[(file, func)] += result
  166. return func_results, results
  167. def main(**args):
  168. # find sizes
  169. if not args.get('use', None):
  170. # find .gcda files
  171. paths = []
  172. for path in args['gcda_paths']:
  173. if os.path.isdir(path):
  174. path = path + '/*.gcda'
  175. for path in glob.glob(path):
  176. paths.append(path)
  177. if not paths:
  178. print('no .gcda files found in %r?' % args['gcda_paths'])
  179. sys.exit(-1)
  180. # TODO consistent behavior between this and stack.py for deps?
  181. results, line_results = collect(paths, **args)
  182. else:
  183. with openio(args['use']) as f:
  184. r = csv.DictReader(f)
  185. results = {
  186. (result['file'], result['name']): CoverageResult(**{
  187. k: v for k, v in result.items()
  188. if k in CoverageResult._fields})
  189. for result in r
  190. if all(result.get(f) not in {None, ''}
  191. for f in CoverageResult._fields)}
  192. # find previous results?
  193. if args.get('diff'):
  194. try:
  195. with openio(args['diff']) as f:
  196. r = csv.DictReader(f)
  197. prev_results = {
  198. (result['file'], result['name']): CoverageResult(**{
  199. k: v for k, v in result.items()
  200. if k in CoverageResult._fields})
  201. for result in r
  202. if all(result.get(f) not in {None, ''}
  203. for f in CoverageResult._fields)}
  204. except FileNotFoundError:
  205. prev_results = []
  206. # write results to CSV
  207. if args.get('output'):
  208. merged_results = co.defaultdict(lambda: {})
  209. other_fields = []
  210. # merge?
  211. if args.get('merge'):
  212. try:
  213. with openio(args['merge']) as f:
  214. r = csv.DictReader(f)
  215. for result in r:
  216. file = result.pop('file', '')
  217. func = result.pop('name', '')
  218. for f in CoverageResult._fields:
  219. result.pop(f, None)
  220. merged_results[(file, func)] = result
  221. other_fields = result.keys()
  222. except FileNotFoundError:
  223. pass
  224. for (file, func), result in results.items():
  225. for f in CoverageResult._fields:
  226. merged_results[(file, func)][f] = getattr(result, f)
  227. with openio(args['output'], 'w') as f:
  228. w = csv.DictWriter(f, ['file', 'name',
  229. *other_fields, *CoverageResult._fields])
  230. w.writeheader()
  231. for (file, func), result in sorted(merged_results.items()):
  232. w.writerow({'file': file, 'name': func, **result})
  233. # print results
  234. def print_header(by):
  235. if by == 'total':
  236. entry = lambda k: 'TOTAL'
  237. elif by == 'file':
  238. entry = lambda k: k[0]
  239. else:
  240. entry = lambda k: k[1]
  241. if not args.get('diff'):
  242. print('%-36s %s' % (by, CoverageResult._header))
  243. else:
  244. old = {entry(k) for k in results.keys()}
  245. new = {entry(k) for k in prev_results.keys()}
  246. print('%-36s %s' % (
  247. '%s (%d added, %d removed)' % (by,
  248. sum(1 for k in new if k not in old),
  249. sum(1 for k in old if k not in new))
  250. if by else '',
  251. CoverageDiff._header))
  252. def print_entries(by):
  253. if by == 'total':
  254. entry = lambda k: 'TOTAL'
  255. elif by == 'file':
  256. entry = lambda k: k[0]
  257. else:
  258. entry = lambda k: k[1]
  259. entries = co.defaultdict(lambda: CoverageResult())
  260. for k, result in results.items():
  261. entries[entry(k)] += result
  262. if not args.get('diff'):
  263. for name, result in sorted(entries.items(),
  264. key=lambda p: (p[1].key(**args), p)):
  265. print('%-36s %s' % (name, result))
  266. else:
  267. prev_entries = co.defaultdict(lambda: CoverageResult())
  268. for k, result in prev_results.items():
  269. prev_entries[entry(k)] += result
  270. diff_entries = {name: entries[name] - prev_entries[name]
  271. for name in (entries.keys() | prev_entries.keys())}
  272. for name, diff in sorted(diff_entries.items(),
  273. key=lambda p: (p[1].key(**args), p)):
  274. if diff or args.get('all'):
  275. print('%-36s %s' % (name, diff))
  276. if args.get('quiet'):
  277. pass
  278. elif args.get('summary'):
  279. print_header('')
  280. print_entries('total')
  281. elif args.get('files'):
  282. print_header('file')
  283. print_entries('file')
  284. print_entries('total')
  285. else:
  286. print_header('function')
  287. print_entries('function')
  288. print_entries('total')
  289. # catch lack of coverage
  290. if args.get('error_on_lines') and any(
  291. r.line_hits < r.line_count for r in results.values()):
  292. sys.exit(2)
  293. elif args.get('error_on_branches') and any(
  294. r.branch_hits < r.branch_count for r in results.values()):
  295. sys.exit(3)
  296. if __name__ == "__main__":
  297. import argparse
  298. import sys
  299. parser = argparse.ArgumentParser(
  300. description="Find coverage info after running tests.")
  301. parser.add_argument('gcda_paths', nargs='*', default=GCDA_PATHS,
  302. help="Description of where to find *.gcda files. May be a directory \
  303. or a list of paths. Defaults to %r." % GCDA_PATHS)
  304. parser.add_argument('-v', '--verbose', action='store_true',
  305. help="Output commands that run behind the scenes.")
  306. parser.add_argument('-q', '--quiet', action='store_true',
  307. help="Don't show anything, useful with -o.")
  308. parser.add_argument('-o', '--output',
  309. help="Specify CSV file to store results.")
  310. parser.add_argument('-u', '--use',
  311. help="Don't compile and find code sizes, instead use this CSV file.")
  312. parser.add_argument('-d', '--diff',
  313. help="Specify CSV file to diff code size against.")
  314. parser.add_argument('-m', '--merge',
  315. help="Merge with an existing CSV file when writing to output.")
  316. parser.add_argument('-a', '--all', action='store_true',
  317. help="Show all functions, not just the ones that changed.")
  318. parser.add_argument('-A', '--everything', action='store_true',
  319. help="Include builtin and libc specific symbols.")
  320. parser.add_argument('-s', '--line-sort', action='store_true',
  321. help="Sort by line coverage.")
  322. parser.add_argument('-S', '--reverse-line-sort', action='store_true',
  323. help="Sort by line coverage, but backwards.")
  324. parser.add_argument('--branch-sort', action='store_true',
  325. help="Sort by branch coverage.")
  326. parser.add_argument('--reverse-branch-sort', action='store_true',
  327. help="Sort by branch coverage, but backwards.")
  328. parser.add_argument('-F', '--files', action='store_true',
  329. help="Show file-level coverage.")
  330. parser.add_argument('-Y', '--summary', action='store_true',
  331. help="Only show the total coverage.")
  332. parser.add_argument('-e', '--error-on-lines', action='store_true',
  333. help="Error if any lines are not covered.")
  334. parser.add_argument('-E', '--error-on-branches', action='store_true',
  335. help="Error if any branches are not covered.")
  336. parser.add_argument('--gcov-tool', default=['gcov'],
  337. type=lambda x: x.split(),
  338. help="Path to the gcov tool to use.")
  339. parser.add_argument('--build-dir',
  340. help="Specify the relative build directory. Used to map object files \
  341. to the correct source files.")
  342. sys.exit(main(**{k: v
  343. for k, v in vars(parser.parse_args()).items()
  344. if v is not None}))