1#!/usr/bin/env python3
2#
3# Script to summarize the outputs of other scripts. Operates on CSV files.
4#
5
6import functools as ft
7import collections as co
8import os
9import csv
10import re
11import math as m
12
13# displayable fields
14Field = co.namedtuple('Field', 'name,parse,acc,key,fmt,repr,null,ratio')
15FIELDS = [
16    # name, parse, accumulate, fmt, print, null
17    Field('code',
18        lambda r: int(r['code_size']),
19        sum,
20        lambda r: r,
21        '%7s',
22        lambda r: r,
23        '-',
24        lambda old, new: (new-old)/old),
25    Field('data',
26        lambda r: int(r['data_size']),
27        sum,
28        lambda r: r,
29        '%7s',
30        lambda r: r,
31        '-',
32        lambda old, new: (new-old)/old),
33    Field('stack',
34        lambda r: float(r['stack_limit']),
35        max,
36        lambda r: r,
37        '%7s',
38        lambda r: '∞' if m.isinf(r) else int(r),
39        '-',
40        lambda old, new: (new-old)/old),
41    Field('structs',
42        lambda r: int(r['struct_size']),
43        sum,
44        lambda r: r,
45        '%8s',
46        lambda r: r,
47        '-',
48        lambda old, new: (new-old)/old),
49    Field('coverage',
50        lambda r: (int(r['coverage_hits']), int(r['coverage_count'])),
51        lambda rs: ft.reduce(lambda a, b: (a[0]+b[0], a[1]+b[1]), rs),
52        lambda r: r[0]/r[1],
53        '%19s',
54        lambda r: '%11s %7s' % ('%d/%d' % (r[0], r[1]), '%.1f%%' % (100*r[0]/r[1])),
55        '%11s %7s' % ('-', '-'),
56        lambda old, new: ((new[0]/new[1]) - (old[0]/old[1])))
57]
58
59
60def main(**args):
61    def openio(path, mode='r'):
62        if path == '-':
63            if 'r' in mode:
64                return os.fdopen(os.dup(sys.stdin.fileno()), 'r')
65            else:
66                return os.fdopen(os.dup(sys.stdout.fileno()), 'w')
67        else:
68            return open(path, mode)
69
70    # find results
71    results = co.defaultdict(lambda: {})
72    for path in args.get('csv_paths', '-'):
73        try:
74            with openio(path) as f:
75                r = csv.DictReader(f)
76                for result in r:
77                    file = result.pop('file', '')
78                    name = result.pop('name', '')
79                    prev = results[(file, name)]
80                    for field in FIELDS:
81                        try:
82                            r = field.parse(result)
83                            if field.name in prev:
84                                results[(file, name)][field.name] = field.acc(
85                                    [prev[field.name], r])
86                            else:
87                                results[(file, name)][field.name] = r
88                        except (KeyError, ValueError):
89                            pass
90        except FileNotFoundError:
91            pass
92
93    # find fields
94    if args.get('all_fields'):
95        fields = FIELDS
96    elif args.get('fields') is not None:
97        fields_dict = {field.name: field for field in FIELDS}
98        fields = [fields_dict[f] for f in args['fields']]
99    else:
100        fields = []
101        for field in FIELDS:
102            if any(field.name in result for result in results.values()):
103                fields.append(field)
104
105    # find total for every field
106    total = {}
107    for result in results.values():
108        for field in fields:
109            if field.name in result and field.name in total:
110                total[field.name] = field.acc(
111                    [total[field.name], result[field.name]])
112            elif field.name in result:
113                total[field.name] = result[field.name]
114
115    # find previous results?
116    if args.get('diff'):
117        prev_results = co.defaultdict(lambda: {})
118        try:
119            with openio(args['diff']) as f:
120                r = csv.DictReader(f)
121                for result in r:
122                    file = result.pop('file', '')
123                    name = result.pop('name', '')
124                    prev = prev_results[(file, name)]
125                    for field in FIELDS:
126                        try:
127                            r = field.parse(result)
128                            if field.name in prev:
129                                prev_results[(file, name)][field.name] = field.acc(
130                                    [prev[field.name], r])
131                            else:
132                                prev_results[(file, name)][field.name] = r
133                        except (KeyError, ValueError):
134                            pass
135        except FileNotFoundError:
136            pass
137
138        prev_total = {}
139        for result in prev_results.values():
140            for field in fields:
141                if field.name in result and field.name in prev_total:
142                    prev_total[field.name] = field.acc(
143                        [prev_total[field.name], result[field.name]])
144                elif field.name in result:
145                    prev_total[field.name] = result[field.name]
146
147    # print results
148    def dedup_entries(results, by='name'):
149        entries = co.defaultdict(lambda: {})
150        for (file, func), result in results.items():
151            entry = (file if by == 'file' else func)
152            prev = entries[entry]
153            for field in fields:
154                if field.name in result and field.name in prev:
155                    entries[entry][field.name] = field.acc(
156                        [prev[field.name], result[field.name]])
157                elif field.name in result:
158                    entries[entry][field.name] = result[field.name]
159        return entries
160
161    def sorted_entries(entries):
162        if args.get('sort') is not None:
163            field = {field.name: field for field in FIELDS}[args['sort']]
164            return sorted(entries, key=lambda x: (
165                -(field.key(x[1][field.name])) if field.name in x[1] else -1, x))
166        elif args.get('reverse_sort') is not None:
167            field = {field.name: field for field in FIELDS}[args['reverse_sort']]
168            return sorted(entries, key=lambda x: (
169                +(field.key(x[1][field.name])) if field.name in x[1] else -1, x))
170        else:
171            return sorted(entries)
172
173    def print_header(by=''):
174        if not args.get('diff'):
175            print('%-36s' % by, end='')
176            for field in fields:
177                print((' '+field.fmt) % field.name, end='')
178            print()
179        else:
180            print('%-36s' % by, end='')
181            for field in fields:
182                print((' '+field.fmt) % field.name, end='')
183                print(' %-9s' % '', end='')
184            print()
185
186    def print_entry(name, result):
187        print('%-36s' % name, end='')
188        for field in fields:
189            r = result.get(field.name)
190            if r is not None:
191                print((' '+field.fmt) % field.repr(r), end='')
192            else:
193                print((' '+field.fmt) % '-', end='')
194        print()
195
196    def print_diff_entry(name, old, new):
197        print('%-36s' % name, end='')
198        for field in fields:
199            n = new.get(field.name)
200            if n is not None:
201                print((' '+field.fmt) % field.repr(n), end='')
202            else:
203                print((' '+field.fmt) % '-', end='')
204            o = old.get(field.name)
205            ratio = (
206                0.0 if m.isinf(o or 0) and m.isinf(n or 0)
207                    else +float('inf') if m.isinf(n or 0)
208                    else -float('inf') if m.isinf(o or 0)
209                    else 0.0 if not o and not n
210                    else +1.0 if not o
211                    else -1.0 if not n
212                    else field.ratio(o, n))
213            print(' %-9s' % (
214                '' if not ratio
215                    else '(+∞%)' if ratio > 0 and m.isinf(ratio)
216                    else '(-∞%)' if ratio < 0 and m.isinf(ratio)
217                    else '(%+.1f%%)' % (100*ratio)), end='')
218        print()
219
220    def print_entries(by='name'):
221        entries = dedup_entries(results, by=by)
222
223        if not args.get('diff'):
224            print_header(by=by)
225            for name, result in sorted_entries(entries.items()):
226                print_entry(name, result)
227        else:
228            prev_entries = dedup_entries(prev_results, by=by)
229            print_header(by='%s (%d added, %d removed)' % (by,
230                sum(1 for name in entries if name not in prev_entries),
231                sum(1 for name in prev_entries if name not in entries)))
232            for name, result in sorted_entries(entries.items()):
233                if args.get('all') or result != prev_entries.get(name, {}):
234                    print_diff_entry(name, prev_entries.get(name, {}), result)
235
236    def print_totals():
237        if not args.get('diff'):
238            print_entry('TOTAL', total)
239        else:
240            print_diff_entry('TOTAL', prev_total, total)
241
242    if args.get('summary'):
243        print_header()
244        print_totals()
245    elif args.get('files'):
246        print_entries(by='file')
247        print_totals()
248    else:
249        print_entries(by='name')
250        print_totals()
251
252
253if __name__ == "__main__":
254    import argparse
255    import sys
256    parser = argparse.ArgumentParser(
257        description="Summarize measurements")
258    parser.add_argument('csv_paths', nargs='*', default='-',
259        help="Description of where to find *.csv files. May be a directory \
260            or list of paths. *.csv files will be merged to show the total \
261            coverage.")
262    parser.add_argument('-d', '--diff',
263        help="Specify CSV file to diff against.")
264    parser.add_argument('-a', '--all', action='store_true',
265        help="Show all objects, not just the ones that changed.")
266    parser.add_argument('-e', '--all-fields', action='store_true',
267        help="Show all fields, even those with no results.")
268    parser.add_argument('-f', '--fields', type=lambda x: re.split('\s*,\s*', x),
269        help="Comma separated list of fields to print, by default all fields \
270            that are found in the CSV files are printed.")
271    parser.add_argument('-s', '--sort',
272        help="Sort by this field.")
273    parser.add_argument('-S', '--reverse-sort',
274        help="Sort by this field, but backwards.")
275    parser.add_argument('-F', '--files', action='store_true',
276        help="Show file-level calls.")
277    parser.add_argument('-Y', '--summary', action='store_true',
278        help="Only show the totals.")
279    sys.exit(main(**vars(parser.parse_args())))
280