1#!/usr/bin/env python3
2#
3# Script to find coverage info after running tests.
4#
5# Example:
6# ./scripts/cov.py \
7#     lfs.t.a.gcda lfs_util.t.a.gcda \
8#     -Flfs.c -Flfs_util.c -slines
9#
10# Copyright (c) 2022, The littlefs authors.
11# Copyright (c) 2020, Arm Limited. All rights reserved.
12# SPDX-License-Identifier: BSD-3-Clause
13#
14
15import collections as co
16import csv
17import itertools as it
18import json
19import math as m
20import os
21import re
22import shlex
23import subprocess as sp
24
25# TODO use explode_asserts to avoid counting assert branches?
26# TODO use dwarf=info to find functions for inline functions?
27
28GCOV_PATH = ['gcov']
29
30
31# integer fields
32class Int(co.namedtuple('Int', 'x')):
33    __slots__ = ()
34    def __new__(cls, x=0):
35        if isinstance(x, Int):
36            return x
37        if isinstance(x, str):
38            try:
39                x = int(x, 0)
40            except ValueError:
41                # also accept +-∞ and +-inf
42                if re.match('^\s*\+?\s*(?:∞|inf)\s*$', x):
43                    x = m.inf
44                elif re.match('^\s*-\s*(?:∞|inf)\s*$', x):
45                    x = -m.inf
46                else:
47                    raise
48        assert isinstance(x, int) or m.isinf(x), x
49        return super().__new__(cls, x)
50
51    def __str__(self):
52        if self.x == m.inf:
53            return '∞'
54        elif self.x == -m.inf:
55            return '-∞'
56        else:
57            return str(self.x)
58
59    def __int__(self):
60        assert not m.isinf(self.x)
61        return self.x
62
63    def __float__(self):
64        return float(self.x)
65
66    none = '%7s' % '-'
67    def table(self):
68        return '%7s' % (self,)
69
70    diff_none = '%7s' % '-'
71    diff_table = table
72
73    def diff_diff(self, other):
74        new = self.x if self else 0
75        old = other.x if other else 0
76        diff = new - old
77        if diff == +m.inf:
78            return '%7s' % '+∞'
79        elif diff == -m.inf:
80            return '%7s' % '-∞'
81        else:
82            return '%+7d' % diff
83
84    def ratio(self, other):
85        new = self.x if self else 0
86        old = other.x if other else 0
87        if m.isinf(new) and m.isinf(old):
88            return 0.0
89        elif m.isinf(new):
90            return +m.inf
91        elif m.isinf(old):
92            return -m.inf
93        elif not old and not new:
94            return 0.0
95        elif not old:
96            return 1.0
97        else:
98            return (new-old) / old
99
100    def __add__(self, other):
101        return self.__class__(self.x + other.x)
102
103    def __sub__(self, other):
104        return self.__class__(self.x - other.x)
105
106    def __mul__(self, other):
107        return self.__class__(self.x * other.x)
108
109# fractional fields, a/b
110class Frac(co.namedtuple('Frac', 'a,b')):
111    __slots__ = ()
112    def __new__(cls, a=0, b=None):
113        if isinstance(a, Frac) and b is None:
114            return a
115        if isinstance(a, str) and b is None:
116            a, b = a.split('/', 1)
117        if b is None:
118            b = a
119        return super().__new__(cls, Int(a), Int(b))
120
121    def __str__(self):
122        return '%s/%s' % (self.a, self.b)
123
124    def __float__(self):
125        return float(self.a)
126
127    none = '%11s %7s' % ('-', '-')
128    def table(self):
129        t = self.a.x/self.b.x if self.b.x else 1.0
130        return '%11s %7s' % (
131            self,
132            '∞%' if t == +m.inf
133            else '-∞%' if t == -m.inf
134            else '%.1f%%' % (100*t))
135
136    diff_none = '%11s' % '-'
137    def diff_table(self):
138        return '%11s' % (self,)
139
140    def diff_diff(self, other):
141        new_a, new_b = self if self else (Int(0), Int(0))
142        old_a, old_b = other if other else (Int(0), Int(0))
143        return '%11s' % ('%s/%s' % (
144            new_a.diff_diff(old_a).strip(),
145            new_b.diff_diff(old_b).strip()))
146
147    def ratio(self, other):
148        new_a, new_b = self if self else (Int(0), Int(0))
149        old_a, old_b = other if other else (Int(0), Int(0))
150        new = new_a.x/new_b.x if new_b.x else 1.0
151        old = old_a.x/old_b.x if old_b.x else 1.0
152        return new - old
153
154    def __add__(self, other):
155        return self.__class__(self.a + other.a, self.b + other.b)
156
157    def __sub__(self, other):
158        return self.__class__(self.a - other.a, self.b - other.b)
159
160    def __mul__(self, other):
161        return self.__class__(self.a * other.a, self.b + other.b)
162
163    def __lt__(self, other):
164        self_t = self.a.x/self.b.x if self.b.x else 1.0
165        other_t = other.a.x/other.b.x if other.b.x else 1.0
166        return (self_t, self.a.x) < (other_t, other.a.x)
167
168    def __gt__(self, other):
169        return self.__class__.__lt__(other, self)
170
171    def __le__(self, other):
172        return not self.__gt__(other)
173
174    def __ge__(self, other):
175        return not self.__lt__(other)
176
177# coverage results
178class CovResult(co.namedtuple('CovResult', [
179        'file', 'function', 'line',
180        'calls', 'hits', 'funcs', 'lines', 'branches'])):
181    _by = ['file', 'function', 'line']
182    _fields = ['calls', 'hits', 'funcs', 'lines', 'branches']
183    _sort = ['funcs', 'lines', 'branches', 'hits', 'calls']
184    _types = {
185        'calls': Int, 'hits': Int,
186        'funcs': Frac, 'lines': Frac, 'branches': Frac}
187
188    __slots__ = ()
189    def __new__(cls, file='', function='', line=0,
190            calls=0, hits=0, funcs=0, lines=0, branches=0):
191        return super().__new__(cls, file, function, int(Int(line)),
192            Int(calls), Int(hits), Frac(funcs), Frac(lines), Frac(branches))
193
194    def __add__(self, other):
195        return CovResult(self.file, self.function, self.line,
196            max(self.calls, other.calls),
197            max(self.hits, other.hits),
198            self.funcs + other.funcs,
199            self.lines + other.lines,
200            self.branches + other.branches)
201
202
203def openio(path, mode='r', buffering=-1):
204    # allow '-' for stdin/stdout
205    if path == '-':
206        if mode == 'r':
207            return os.fdopen(os.dup(sys.stdin.fileno()), mode, buffering)
208        else:
209            return os.fdopen(os.dup(sys.stdout.fileno()), mode, buffering)
210    else:
211        return open(path, mode, buffering)
212
213def collect(gcda_paths, *,
214        gcov_path=GCOV_PATH,
215        sources=None,
216        everything=False,
217        **args):
218    results = []
219    for path in gcda_paths:
220        # get coverage info through gcov's json output
221        # note, gcov-path may contain extra args
222        cmd = GCOV_PATH + ['-b', '-t', '--json-format', path]
223        if args.get('verbose'):
224            print(' '.join(shlex.quote(c) for c in cmd))
225        proc = sp.Popen(cmd,
226            stdout=sp.PIPE,
227            stderr=sp.PIPE if not args.get('verbose') else None,
228            universal_newlines=True,
229            errors='replace',
230            close_fds=False)
231        data = json.load(proc.stdout)
232        proc.wait()
233        if proc.returncode != 0:
234            if not args.get('verbose'):
235                for line in proc.stderr:
236                    sys.stdout.write(line)
237            sys.exit(-1)
238
239        # collect line/branch coverage
240        for file in data['files']:
241            # ignore filtered sources
242            if sources is not None:
243                if not any(
244                        os.path.abspath(file['file']) == os.path.abspath(s)
245                        for s in sources):
246                    continue
247            else:
248                # default to only cwd
249                if not everything and not os.path.commonpath([
250                        os.getcwd(),
251                        os.path.abspath(file['file'])]) == os.getcwd():
252                    continue
253
254            # simplify path
255            if os.path.commonpath([
256                    os.getcwd(),
257                    os.path.abspath(file['file'])]) == os.getcwd():
258                file_name = os.path.relpath(file['file'])
259            else:
260                file_name = os.path.abspath(file['file'])
261
262            for func in file['functions']:
263                func_name = func.get('name', '(inlined)')
264                # discard internal functions (this includes injected test cases)
265                if not everything:
266                    if func_name.startswith('__'):
267                        continue
268
269                # go ahead and add functions, later folding will merge this if
270                # there are other hits on this line
271                results.append(CovResult(
272                    file_name, func_name, func['start_line'],
273                    func['execution_count'], 0,
274                    Frac(1 if func['execution_count'] > 0 else 0, 1),
275                    0,
276                    0))
277
278            for line in file['lines']:
279                func_name = line.get('function_name', '(inlined)')
280                # discard internal function (this includes injected test cases)
281                if not everything:
282                    if func_name.startswith('__'):
283                        continue
284
285                # go ahead and add lines, later folding will merge this if
286                # there are other hits on this line
287                results.append(CovResult(
288                    file_name, func_name, line['line_number'],
289                    0, line['count'],
290                    0,
291                    Frac(1 if line['count'] > 0 else 0, 1),
292                    Frac(
293                        sum(1 if branch['count'] > 0 else 0
294                            for branch in line['branches']),
295                        len(line['branches']))))
296
297    return results
298
299
300def fold(Result, results, *,
301        by=None,
302        defines=None,
303        **_):
304    if by is None:
305        by = Result._by
306
307    for k in it.chain(by or [], (k for k, _ in defines or [])):
308        if k not in Result._by and k not in Result._fields:
309            print("error: could not find field %r?" % k)
310            sys.exit(-1)
311
312    # filter by matching defines
313    if defines is not None:
314        results_ = []
315        for r in results:
316            if all(getattr(r, k) in vs for k, vs in defines):
317                results_.append(r)
318        results = results_
319
320    # organize results into conflicts
321    folding = co.OrderedDict()
322    for r in results:
323        name = tuple(getattr(r, k) for k in by)
324        if name not in folding:
325            folding[name] = []
326        folding[name].append(r)
327
328    # merge conflicts
329    folded = []
330    for name, rs in folding.items():
331        folded.append(sum(rs[1:], start=rs[0]))
332
333    return folded
334
335def table(Result, results, diff_results=None, *,
336        by=None,
337        fields=None,
338        sort=None,
339        summary=False,
340        all=False,
341        percent=False,
342        **_):
343    all_, all = all, __builtins__.all
344
345    if by is None:
346        by = Result._by
347    if fields is None:
348        fields = Result._fields
349    types = Result._types
350
351    # fold again
352    results = fold(Result, results, by=by)
353    if diff_results is not None:
354        diff_results = fold(Result, diff_results, by=by)
355
356    # organize by name
357    table = {
358        ','.join(str(getattr(r, k) or '') for k in by): r
359        for r in results}
360    diff_table = {
361        ','.join(str(getattr(r, k) or '') for k in by): r
362        for r in diff_results or []}
363    names = list(table.keys() | diff_table.keys())
364
365    # sort again, now with diff info, note that python's sort is stable
366    names.sort()
367    if diff_results is not None:
368        names.sort(key=lambda n: tuple(
369            types[k].ratio(
370                getattr(table.get(n), k, None),
371                getattr(diff_table.get(n), k, None))
372            for k in fields),
373            reverse=True)
374    if sort:
375        for k, reverse in reversed(sort):
376            names.sort(
377                key=lambda n: tuple(
378                    (getattr(table[n], k),)
379                    if getattr(table.get(n), k, None) is not None else ()
380                    for k in ([k] if k else [
381                        k for k in Result._sort if k in fields])),
382                reverse=reverse ^ (not k or k in Result._fields))
383
384
385    # build up our lines
386    lines = []
387
388    # header
389    header = []
390    header.append('%s%s' % (
391        ','.join(by),
392        ' (%d added, %d removed)' % (
393            sum(1 for n in table if n not in diff_table),
394            sum(1 for n in diff_table if n not in table))
395            if diff_results is not None and not percent else '')
396        if not summary else '')
397    if diff_results is None:
398        for k in fields:
399            header.append(k)
400    elif percent:
401        for k in fields:
402            header.append(k)
403    else:
404        for k in fields:
405            header.append('o'+k)
406        for k in fields:
407            header.append('n'+k)
408        for k in fields:
409            header.append('d'+k)
410    header.append('')
411    lines.append(header)
412
413    def table_entry(name, r, diff_r=None, ratios=[]):
414        entry = []
415        entry.append(name)
416        if diff_results is None:
417            for k in fields:
418                entry.append(getattr(r, k).table()
419                    if getattr(r, k, None) is not None
420                    else types[k].none)
421        elif percent:
422            for k in fields:
423                entry.append(getattr(r, k).diff_table()
424                    if getattr(r, k, None) is not None
425                    else types[k].diff_none)
426        else:
427            for k in fields:
428                entry.append(getattr(diff_r, k).diff_table()
429                    if getattr(diff_r, k, None) is not None
430                    else types[k].diff_none)
431            for k in fields:
432                entry.append(getattr(r, k).diff_table()
433                    if getattr(r, k, None) is not None
434                    else types[k].diff_none)
435            for k in fields:
436                entry.append(types[k].diff_diff(
437                        getattr(r, k, None),
438                        getattr(diff_r, k, None)))
439        if diff_results is None:
440            entry.append('')
441        elif percent:
442            entry.append(' (%s)' % ', '.join(
443                '+∞%' if t == +m.inf
444                else '-∞%' if t == -m.inf
445                else '%+.1f%%' % (100*t)
446                for t in ratios))
447        else:
448            entry.append(' (%s)' % ', '.join(
449                    '+∞%' if t == +m.inf
450                    else '-∞%' if t == -m.inf
451                    else '%+.1f%%' % (100*t)
452                    for t in ratios
453                    if t)
454                if any(ratios) else '')
455        return entry
456
457    # entries
458    if not summary:
459        for name in names:
460            r = table.get(name)
461            if diff_results is None:
462                diff_r = None
463                ratios = None
464            else:
465                diff_r = diff_table.get(name)
466                ratios = [
467                    types[k].ratio(
468                        getattr(r, k, None),
469                        getattr(diff_r, k, None))
470                    for k in fields]
471                if not all_ and not any(ratios):
472                    continue
473            lines.append(table_entry(name, r, diff_r, ratios))
474
475    # total
476    r = next(iter(fold(Result, results, by=[])), None)
477    if diff_results is None:
478        diff_r = None
479        ratios = None
480    else:
481        diff_r = next(iter(fold(Result, diff_results, by=[])), None)
482        ratios = [
483            types[k].ratio(
484                getattr(r, k, None),
485                getattr(diff_r, k, None))
486            for k in fields]
487    lines.append(table_entry('TOTAL', r, diff_r, ratios))
488
489    # find the best widths, note that column 0 contains the names and column -1
490    # the ratios, so those are handled a bit differently
491    widths = [
492        ((max(it.chain([w], (len(l[i]) for l in lines)))+1+4-1)//4)*4-1
493        for w, i in zip(
494            it.chain([23], it.repeat(7)),
495            range(len(lines[0])-1))]
496
497    # print our table
498    for line in lines:
499        print('%-*s  %s%s' % (
500            widths[0], line[0],
501            ' '.join('%*s' % (w, x)
502                for w, x in zip(widths[1:], line[1:-1])),
503            line[-1]))
504
505
506def annotate(Result, results, *,
507        annotate=False,
508        lines=False,
509        branches=False,
510        **args):
511    # if neither branches/lines specified, color both
512    if annotate and not lines and not branches:
513        lines, branches = True, True
514
515    for path in co.OrderedDict.fromkeys(r.file for r in results).keys():
516        # flatten to line info
517        results = fold(Result, results, by=['file', 'line'])
518        table = {r.line: r for r in results if r.file == path}
519
520        # calculate spans to show
521        if not annotate:
522            spans = []
523            last = None
524            func = None
525            for line, r in sorted(table.items()):
526                if ((lines and int(r.hits) == 0)
527                        or (branches and r.branches.a < r.branches.b)):
528                    if last is not None and line - last.stop <= args['context']:
529                        last = range(
530                            last.start,
531                            line+1+args['context'])
532                    else:
533                        if last is not None:
534                            spans.append((last, func))
535                        last = range(
536                            line-args['context'],
537                            line+1+args['context'])
538                        func = r.function
539            if last is not None:
540                spans.append((last, func))
541
542        with open(path) as f:
543            skipped = False
544            for i, line in enumerate(f):
545                # skip lines not in spans?
546                if not annotate and not any(i+1 in s for s, _ in spans):
547                    skipped = True
548                    continue
549
550                if skipped:
551                    skipped = False
552                    print('%s@@ %s:%d: %s @@%s' % (
553                        '\x1b[36m' if args['color'] else '',
554                        path,
555                        i+1,
556                        next(iter(f for _, f in spans)),
557                        '\x1b[m' if args['color'] else ''))
558
559                # build line
560                if line.endswith('\n'):
561                    line = line[:-1]
562
563                if i+1 in table:
564                    r = table[i+1]
565                    line = '%-*s // %s hits%s' % (
566                        args['width'],
567                        line,
568                        r.hits,
569                        ', %s branches' % (r.branches,)
570                            if int(r.branches.b) else '')
571
572                    if args['color']:
573                        if lines and int(r.hits) == 0:
574                            line = '\x1b[1;31m%s\x1b[m' % line
575                        elif branches and r.branches.a < r.branches.b:
576                            line = '\x1b[35m%s\x1b[m' % line
577
578                print(line)
579
580
581def main(gcda_paths, *,
582        by=None,
583        fields=None,
584        defines=None,
585        sort=None,
586        hits=False,
587        **args):
588    # figure out what color should be
589    if args.get('color') == 'auto':
590        args['color'] = sys.stdout.isatty()
591    elif args.get('color') == 'always':
592        args['color'] = True
593    else:
594        args['color'] = False
595
596    # find sizes
597    if not args.get('use', None):
598        results = collect(gcda_paths, **args)
599    else:
600        results = []
601        with openio(args['use']) as f:
602            reader = csv.DictReader(f, restval='')
603            for r in reader:
604                if not any('cov_'+k in r and r['cov_'+k].strip()
605                        for k in CovResult._fields):
606                    continue
607                try:
608                    results.append(CovResult(
609                        **{k: r[k] for k in CovResult._by
610                            if k in r and r[k].strip()},
611                        **{k: r['cov_'+k]
612                            for k in CovResult._fields
613                            if 'cov_'+k in r
614                                and r['cov_'+k].strip()}))
615                except TypeError:
616                    pass
617
618    # fold
619    results = fold(CovResult, results, by=by, defines=defines)
620
621    # sort, note that python's sort is stable
622    results.sort()
623    if sort:
624        for k, reverse in reversed(sort):
625            results.sort(
626                key=lambda r: tuple(
627                    (getattr(r, k),) if getattr(r, k) is not None else ()
628                    for k in ([k] if k else CovResult._sort)),
629                reverse=reverse ^ (not k or k in CovResult._fields))
630
631    # write results to CSV
632    if args.get('output'):
633        with openio(args['output'], 'w') as f:
634            writer = csv.DictWriter(f,
635                (by if by is not None else CovResult._by)
636                + ['cov_'+k for k in (
637                    fields if fields is not None else CovResult._fields)])
638            writer.writeheader()
639            for r in results:
640                writer.writerow(
641                    {k: getattr(r, k) for k in (
642                        by if by is not None else CovResult._by)}
643                    | {'cov_'+k: getattr(r, k) for k in (
644                        fields if fields is not None else CovResult._fields)})
645
646    # find previous results?
647    if args.get('diff'):
648        diff_results = []
649        try:
650            with openio(args['diff']) as f:
651                reader = csv.DictReader(f, restval='')
652                for r in reader:
653                    if not any('cov_'+k in r and r['cov_'+k].strip()
654                            for k in CovResult._fields):
655                        continue
656                    try:
657                        diff_results.append(CovResult(
658                            **{k: r[k] for k in CovResult._by
659                                if k in r and r[k].strip()},
660                            **{k: r['cov_'+k]
661                                for k in CovResult._fields
662                                if 'cov_'+k in r
663                                    and r['cov_'+k].strip()}))
664                    except TypeError:
665                        pass
666        except FileNotFoundError:
667            pass
668
669        # fold
670        diff_results = fold(CovResult, diff_results,
671            by=by, defines=defines)
672
673    # print table
674    if not args.get('quiet'):
675        if (args.get('annotate')
676                or args.get('lines')
677                or args.get('branches')):
678            # annotate sources
679            annotate(CovResult, results, **args)
680        else:
681            # print table
682            table(CovResult, results,
683                diff_results if args.get('diff') else None,
684                by=by if by is not None else ['function'],
685                fields=fields if fields is not None
686                    else ['lines', 'branches'] if not hits
687                    else ['calls', 'hits'],
688                sort=sort,
689                **args)
690
691    # catch lack of coverage
692    if args.get('error_on_lines') and any(
693            r.lines.a < r.lines.b for r in results):
694        sys.exit(2)
695    elif args.get('error_on_branches') and any(
696            r.branches.a < r.branches.b for r in results):
697        sys.exit(3)
698
699
700if __name__ == "__main__":
701    import argparse
702    import sys
703    parser = argparse.ArgumentParser(
704        description="Find coverage info after running tests.",
705        allow_abbrev=False)
706    parser.add_argument(
707        'gcda_paths',
708        nargs='*',
709        help="Input *.gcda files.")
710    parser.add_argument(
711        '-v', '--verbose',
712        action='store_true',
713        help="Output commands that run behind the scenes.")
714    parser.add_argument(
715        '-q', '--quiet',
716        action='store_true',
717        help="Don't show anything, useful with -o.")
718    parser.add_argument(
719        '-o', '--output',
720        help="Specify CSV file to store results.")
721    parser.add_argument(
722        '-u', '--use',
723        help="Don't parse anything, use this CSV file.")
724    parser.add_argument(
725        '-d', '--diff',
726        help="Specify CSV file to diff against.")
727    parser.add_argument(
728        '-a', '--all',
729        action='store_true',
730        help="Show all, not just the ones that changed.")
731    parser.add_argument(
732        '-p', '--percent',
733        action='store_true',
734        help="Only show percentage change, not a full diff.")
735    parser.add_argument(
736        '-b', '--by',
737        action='append',
738        choices=CovResult._by,
739        help="Group by this field.")
740    parser.add_argument(
741        '-f', '--field',
742        dest='fields',
743        action='append',
744        choices=CovResult._fields,
745        help="Show this field.")
746    parser.add_argument(
747        '-D', '--define',
748        dest='defines',
749        action='append',
750        type=lambda x: (lambda k,v: (k, set(v.split(','))))(*x.split('=', 1)),
751        help="Only include results where this field is this value.")
752    class AppendSort(argparse.Action):
753        def __call__(self, parser, namespace, value, option):
754            if namespace.sort is None:
755                namespace.sort = []
756            namespace.sort.append((value, True if option == '-S' else False))
757    parser.add_argument(
758        '-s', '--sort',
759        nargs='?',
760        action=AppendSort,
761        help="Sort by this field.")
762    parser.add_argument(
763        '-S', '--reverse-sort',
764        nargs='?',
765        action=AppendSort,
766        help="Sort by this field, but backwards.")
767    parser.add_argument(
768        '-Y', '--summary',
769        action='store_true',
770        help="Only show the total.")
771    parser.add_argument(
772        '-F', '--source',
773        dest='sources',
774        action='append',
775        help="Only consider definitions in this file. Defaults to anything "
776            "in the current directory.")
777    parser.add_argument(
778        '--everything',
779        action='store_true',
780        help="Include builtin and libc specific symbols.")
781    parser.add_argument(
782        '--hits',
783        action='store_true',
784        help="Show total hits instead of coverage.")
785    parser.add_argument(
786        '-A', '--annotate',
787        action='store_true',
788        help="Show source files annotated with coverage info.")
789    parser.add_argument(
790        '-L', '--lines',
791        action='store_true',
792        help="Show uncovered lines.")
793    parser.add_argument(
794        '-B', '--branches',
795        action='store_true',
796        help="Show uncovered branches.")
797    parser.add_argument(
798        '-c', '--context',
799        type=lambda x: int(x, 0),
800        default=3,
801        help="Show n additional lines of context. Defaults to 3.")
802    parser.add_argument(
803        '-W', '--width',
804        type=lambda x: int(x, 0),
805        default=80,
806        help="Assume source is styled with this many columns. Defaults to 80.")
807    parser.add_argument(
808        '--color',
809        choices=['never', 'always', 'auto'],
810        default='auto',
811        help="When to use terminal colors. Defaults to 'auto'.")
812    parser.add_argument(
813        '-e', '--error-on-lines',
814        action='store_true',
815        help="Error if any lines are not covered.")
816    parser.add_argument(
817        '-E', '--error-on-branches',
818        action='store_true',
819        help="Error if any branches are not covered.")
820    parser.add_argument(
821        '--gcov-path',
822        default=GCOV_PATH,
823        type=lambda x: x.split(),
824        help="Path to the gcov executable, may include paths. "
825            "Defaults to %r." % GCOV_PATH)
826    sys.exit(main(**{k: v
827        for k, v in vars(parser.parse_intermixed_args()).items()
828        if v is not None}))
829